diff --git a/src/functions/sendRequest.ts b/src/functions/sendRequest.ts new file mode 100644 index 0000000..d19ba03 --- /dev/null +++ b/src/functions/sendRequest.ts @@ -0,0 +1,32 @@ +import { filterItem, map, objectKeys } from '@/helper'; +import { undefinedValue } from '@/helper/variables'; +import { Arg } from 'alova'; + +/** + * 构建完整的url + * @param base baseURL + * @param url 路径 + * @param params url参数 + * @returns 完整的url + */ +export const buildCompletedURL = (baseURL: string, url: string, params: Arg) => { + // baseURL如果以/结尾,则去掉/ + baseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL; + // 如果不是/或http协议开头的,则需要添加/ + url = url.match(/^(\/|https?:\/\/)/) ? url : `/${url}`; + + const completeURL = baseURL + url; + + // 将params对象转换为get字符串 + // 过滤掉值为undefined的 + const paramsStr = map( + filterItem(objectKeys(params), key => params[key] !== undefinedValue), + key => `${key}=${params[key]}` + ).join('&'); + // 将get参数拼接到url后面,注意url可能已存在参数 + return paramsStr + ? +completeURL.includes('?') + ? `${completeURL}&${paramsStr}` + : `${completeURL}?${paramsStr}` + : completeURL; +}; diff --git a/src/helper/index.ts b/src/helper/index.ts index b0601d0..a6b3a0d 100644 --- a/src/helper/index.ts +++ b/src/helper/index.ts @@ -1,4 +1,4 @@ -import { CacheExpire, LocalCacheConfig, Method } from 'alova'; +import { AlovaMethodHandler, CacheExpire, LocalCacheConfig, Method } from 'alova'; import { BackoffPolicy } from '~/typings/general'; import { ObjectCls, PromiseCls, StringCls, falseValue, nullValue, trueValue, undefinedValue } from './variables'; @@ -320,3 +320,49 @@ export const delayWithBackoff = (backoff: BackoffPolicy, retryTimes: number) => } return retryDelayFinally; }; +/** + * 获取请求方法对象 + * @param methodHandler 请求方法句柄 + * @param args 方法调用参数 + * @returns 请求方法对象 + */ +export const getHandlerMethod = ( + methodHandler: Method | AlovaMethodHandler, + args: any[] = [] +) => { + const methodInstance = isFn(methodHandler) ? methodHandler(...args) : methodHandler; + createAssert('scene')( + instanceOf(methodInstance, Method), + 'hook handler must be a method instance or a function that returns method instance' + ); + return methodInstance; +}; + +type AnyFn = (...args: any[]) => any; +export function useCallback(onCallbackChange?: (callbacks: Fn[]) => void) { + let callbacks: Fn[] = []; + + const setCallback = (fn: Fn) => { + if (!callbacks.includes(fn)) { + callbacks.push(fn); + onCallbackChange && onCallbackChange(callbacks); + } + // 返回取消注册函数 + return () => { + callbacks = filterItem(callbacks, e => e !== fn); + onCallbackChange && onCallbackChange(callbacks); + }; + }; + + const triggerCallback = (...args: any[]) => { + if (callbacks) { + return forEach(callbacks, fn => fn(args)); + } + }; + + const removeAllCallback = () => { + callbacks = []; + }; + + return [setCallback, triggerCallback as Fn, removeAllCallback] as const; +} diff --git a/src/hooks/useSSE.ts b/src/hooks/useSSE.ts new file mode 100644 index 0000000..48cab9a --- /dev/null +++ b/src/hooks/useSSE.ts @@ -0,0 +1,128 @@ +import { T$, T$$, TonMounted$, Tupd$, Twatch$, T_$, T_exp$, TonUnmounted$ } from '@/framework/type'; +import { AlovaMethodHandler, Method, useRequest } from 'alova'; +import { getConfig, getHandlerMethod, useCallback } from '@/helper'; +import { buildCompletedURL } from '@/functions/sendRequest'; +import { + AlovaSSEErrorEvent, + AlovaSSEEvent, + AlovaSSEMessageEvent, + SSEHookConfig, + SSEHookReadyState, + SSEOn, + SSEOnErrorTrigger, + SSEOnMessageTrigger, + SSEOnOpenTrigger +} from '~/typings/general'; + +// !! interceptByGlobalResponded 参数 尚未实现 + +export default ( + handler: Method | AlovaMethodHandler, + config: SSEHookConfig = {}, + $: T$, + $$: T$$, + _$: T_$, + _exp$: T_exp$, + upd$: Tupd$, + watch$: Twatch$, + onMounted$: TonMounted$, + onUnmounted$: TonUnmounted$ + // useFlag$: TuseFlag$, + // useMemorizedCallback$: TuseMemorizedCallback$ +) => { + // SSE 不需要传参(吧?) + const methodInstance = getHandlerMethod(handler); + const { baseURL, url } = methodInstance; + const { params, transformData, headers } = getConfig(methodInstance); + + const fullURL = buildCompletedURL(baseURL, url, params); + const eventSource = new EventSource(fullURL, { withCredentials: config.withCredentials }); + + const { data, update } = useRequest(handler, config); + const readyState = $(SSEHookReadyState.CONNECTING); + + // type: eventname & useCallback() + const eventMap: Map> = new Map(); + const [onOpen, triggerOnOpen, offOpen] = useCallback(); + const [onMessage, triggerOnMessage, offMessage] = useCallback>(); + const [onError, triggerOnError, offError] = useCallback(); + + const dataHandler = (data: any) => { + const transformedData = transformData ? transformData(data, (headers || {}) as RH) : data; + update({ data: transformedData }); + return data; + }; + + // 将 SourceEvent 产生的事件变为 AlovaSSEHook 的事件 + const createSSEEvent = (eventName: string, event: MessageEvent | Event) => { + if (eventName === 'open') { + return { + method: methodInstance, + eventSource + } as AlovaSSEEvent; + } + if (eventName === 'error') { + return { + method: methodInstance, + eventSource, + error: new Error('sse error') + } as AlovaSSEErrorEvent; + } + + // 其余名称的事件都是(类)message 的事件,data 交给 dataHandler 处理 + return { + method: methodInstance, + eventSource, + data: dataHandler((event as MessageEvent).data) + } as AlovaSSEMessageEvent; + }; + + const on: SSEOn = (eventName, handler) => { + if (!eventMap.has(eventName)) { + const useCallbackObject = useCallback<(event: AlovaSSEEvent) => void>(callbacks => { + if (callbacks.length === 0) { + eventSource.removeEventListener(eventName, useCallbackObject[1] as any); + eventMap.delete(eventName); + } + }); + + const trigger = useCallbackObject[1]; + eventMap.set(eventName, useCallbackObject); + eventSource.addEventListener(eventName, event => { + trigger(createSSEEvent(eventName, event)); + }); + } + + const [onEvent] = eventMap.get(eventName)!; + + return onEvent(handler); + }; + + eventSource.addEventListener('open', event => { + upd$(readyState, SSEHookReadyState.OPEN); + triggerOnOpen(createSSEEvent('open', event)); + }); + eventSource.addEventListener('error', event => { + upd$(readyState, SSEHookReadyState.CLOSED); + triggerOnError(createSSEEvent('error', event) as AlovaSSEErrorEvent); + }); + eventSource.addEventListener('message', event => { + triggerOnMessage(createSSEEvent('message', event) as AlovaSSEMessageEvent); + }); + + onUnmounted$(() => { + offOpen(); + offMessage(); + offError(); + }); + + return { + readyState: _exp$(readyState), + data, + eventSource, + onMessage, + onError, + onOpen, + on + }; +}; diff --git a/src/index.js b/src/index.js index 5c3e036..ee6dddf 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ import useAutoRequest_unified from '@/hooks/useAutoRequest'; import useCaptcha_unified from '@/hooks/useCaptcha'; import useForm_unified from '@/hooks/useForm'; import useRetriableRequest_unified from '@/hooks/useRetriableRequest'; +import useSSE_unified from '@/hooks/useSSE'; import { actionDelegationMiddleware as actionDelegationMiddleware_unified } from '@/middlewares/actionDelegation'; export const usePagination = (handler, config = {}) => @@ -93,3 +94,7 @@ forEach(objectKeys(useAutoRequest_unified), key => { trueValue ); }); + +// 导出useSSE +export const useSSE = (handler, config = {}) => + useSSE_unified(handler, config, $, $$, _$, _exp$, upd$, watch$, onMounted$, useFlag$, useMemorizedCallback$); \ No newline at end of file diff --git a/typings/general.d.ts b/typings/general.d.ts index 2c8ab00..11cc5f1 100644 --- a/typings/general.d.ts +++ b/typings/general.d.ts @@ -836,5 +836,52 @@ type AutoRequestHookConfig = { */ throttle?: number; } & RequestHookConfig; + +/** + * SSERequest配置 + */ +type SSEHookConfig = { + /** 会传给new EventSource */ + withCredentials?: boolean; + + /** 是否经过alova实例的responded拦截,默认为true */ + interceptByGlobalResponded?: boolean; + + /** 初始数据 */ + initialData?: any; +}; + +type SSEReturnType = { + readyState: ExportedType; + data: R; + eventSource: EventSource; + + onOpen(callback: (event: AlovaSSEEvent) => void): void; + onMessage(callback: (event: AlovaSSEMessageEvent) => void): void; + onError(callback: (event: AlovaSSEErrorEvent) => void): void; + on(eventName: 'open' | 'message' | 'error', handler: (event: AlovaSSEEvent) => void): () => void; +}; + +const enum SSEHookReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSED = 2 +} + +interface AlovaSSEEvent { + method: Method; // alova的method实例 + eventSource: EventSource; // eventSource实例 +} +interface AlovaSSEErrorEvent extends AlovaSSEEvent { + error: Error; // 错误对象 +} +interface AlovaSSEMessageEvent extends AlovaSSEEvent { + data: T; // 每次响应的,经过拦截器转换后的数据 +} +type SSEOnOpenTrigger = (event: AlovaSSEEvent) => void; +type SSEOnMessageTrigger = (event: AlovaSSEMessageEvent) => void; +type SSEOnErrorTrigger = (event: AlovaSSEErrorEvent) => void; +type SSEOn = (eventName: 'open' | 'message' | 'error', handler: (event: AlovaSSEEvent) => void) => () => void; + type NotifyHandler = () => void; type UnbindHandler = () => void;