源码地址
- 文档:https://ahooks.gitee.io/zh-CN/hooks/use-request/index
- GIT:https://github.com/alibaba/hooks/tree/master/packages/hooks/src/useRequest
先搞清楚几个点
为什么需要
回忆我们最常见的业务场景,列表展示。 进入页面,在页面初始化钩子内,调用后端接口拉取数据,考虑到用户体验问题会分为几个状态去展示:
- 拉取接口时,给到一个 loading icon 展示,记录一个 loading true / false 的值
- 成功拉取数据情况,正常的列表展示,记录一个 data 的数据
- 接口炸了等失败情况,展示错误提示 tips,记录一个 error 的数据
模版代码往往类似是这样的:
import sampleApi from '???'import { useEffect, useState } from 'react'const Home = () => {const [loading, setLoading] = useState(false)const [error, setError] = useState()const [data, setData] = useState()const init = async () => {setLoading(true)try {const result = await sampleApi()setData(result)} catch (error) {setError(error)} finally {setLoading(false)}}useEffect(() => { init() }, [])if (loading) return 'loading'if (error) return 'sorry, error'return <div>{JSON.stringify(data)}</div>}export default Home
大致所有页面交互都离不开这样的过程,自然就会想到过程封装:
import sampleApi from '???'const Home = () => {const { loading, error, data } = useRequest(sampleApi)if (loading) return 'loading'if (error) return 'sorry, error'return <div>{JSON.stringify(data)}</div>}export default Home
代码量从原来 15 行的关键逻辑变为 1行,若有 100 个类似的过程,也就减少约了 (15-1) X 100 = 1400 行代码,因此这是针对重复逻辑的封装,减少代码量
不仅如此
再来回忆高级一点点的场景。
- 这个列表有实时性展示的要求,需要每 10s 中请求一次,展示到最新的数据(轮询)
- 在连续请求的场景内,希望只触发到最后一次(防抖)
- 在连续请求的场景内,希望一段时间内,只触发一次(节流)
因此不仅仅是针对一个异步操作过程的简单封装,甚至可以丰富更多的功能,比如 useRequest 提供的:
与具体请求库无关
只要求返回 Promise 函数都可使用,因此可以根据个人喜好,在项目内集成对应的请求库
export type Service<TData, TParams extends any[]> = (...args: TParams) => Promise<TData>;
整体架构

- 暴露对外的 props / methods:蓝色
- 插件 / 插件暴露钩子:红色
- Class Fetch:黄绿色
-
1. Main 流程
initState:遍历调用 plugins 暴露的 onInit 方法,获取到 initState,并对此进行合并
- 实例化 Fetch:把 props 与上步获取的 initState 传至 Fetch 进行实例化
- 插件执行&注册:遍历调用 plugins hooks 方法,并把 hooks 保存至实例化后的 fetchInstance.pluginImpls
- 第一次加载时:判断 manual(是否手工触发)是否为 false,若是,则调用 fetchInstance.run 方法进行执行异步过程
- hooks 卸载:调用 fetchInstance.cancel 方法进行销毁
2. Class Fetch
异步数据管理器,维护了一个异步过程,定义了相关的生命周期。并且管理了所有的功能插件 hooks。3. 插件式代码组织
useRequest 内除基础功能以外的附加功能,都是以一个个小插件的方式进行管理的(如图红色标注部分,为插件管理的逻辑),然而每一个插件功能,又是一个 hooks:目录结构
目前总共包含了 8 个 plugin hooks(如果左侧红色 Plugins),自动执行 / 缓存 / 防抖 / 节流 / 轮询 / loading 延迟 / 错误重试 / 屏幕重新请求:
插件初始化
除了 plugin hooks 本身需要导出外,还需要导出 onInit 函数,提供给 useRequest 获取 initState 使用(如图红色 onInit),当然如果 plugin 本身不关注此钩子外,也无需导出插件生命周期
plugin hooks 执行后可以返回本身插件所关注的,主异步流程执行的生命周期函数(如图右侧 run / runAsync 内红色标注的生命周期)
TS 类型定义
有了上面的大致分析,可以来查看相关的 TS 类型定义了,也有方便于接下来的源码解读:
useRequest 入参部分
总共三个参数,异步操作 + options + plugins,plugins 文档未公开使用,暂不讨论
// hooks 的入参// TData 异步操作后的返回值,TParams 异步操作的入参function useRequest<TData, TParams extends any[]>(service: Service<TData, TParams>, // 异步操作options?: Options<TData, TParams>, // optionsplugins?: Plugin<TData, TParams>[], // 插件)
services:异步操作
// 入参是 TParams,返回值是 Promise<TData>,是个异步函数type Service<TData, TParams extends any[]> = (...args: TParams) => Promise<TData>;
options:功能 options
// 以下所有参数都是可选的,说明都是可有可无的,都是附加功能选项而已interface Options<TData, TParams extends any[]> {// 是否开启人工触发,区别就是第一次加载之后,会不会自动给你触发异步请求// 默认是会给你自动触发的,不需要的话需要自行 false 关闭manual?: boolean;// 这几个都是用户可以订阅的异步请求生命周期函数(对应上面图 run / runAsync 内蓝色部分标记)onBefore?: (params: TParams) => void;onSuccess?: (data: TData, params: TParams) => void;onError?: (e: Error, params: TParams) => void;// formatResult?: (res: any) => TData;onFinally?: (params: TParams, data?: TData, e?: Error) => void;// 默认请求参数defaultParams?: TParams;// refreshDeps// 主要对应到的是 useAutoRunPlugin 插件 hooks 内的功能refreshDeps?: DependencyList;refreshDepsAction?: () => void;ready?: boolean;// loading delay// 主要对应到的是 useLoadingDelayPlugin 插件 hooks 内的功能loadingDelay?: number;// polling// 主要对应到的是 usePollingPlugin 插件 hooks 内的功能pollingInterval?: number;pollingWhenHidden?: boolean;// refresh on window focus// 主要对应到的是 useRefreshOnWindowFocusPlugin 插件 hooks 内的功能refreshOnWindowFocus?: boolean;focusTimespan?: number;// debounce// 主要对应到的是 useDebouncePlugin 插件 hooks 内的功能debounceWait?: number;debounceLeading?: boolean;debounceTrailing?: boolean;debounceMaxWait?: number;// throttle// 主要对应到的是 useThrottlePlugin 插件 hooks 内的功能throttleWait?: number;throttleLeading?: boolean;throttleTrailing?: boolean;// cache//主要对应到的是 useCachePlugin 插件 hooks 内的功能cacheKey?: string;cacheTime?: number;staleTime?: number;setCache?: (data: CachedData<TData, TParams>) => void;getCache?: (params: TParams) => CachedData<TData, TParams> | undefined;// retry// 主要对应到的是 useRetryPlugin 插件 hooks 内的功能retryCount?: number;retryInterval?: number;}
plugins:plugins 文档未公开使用,但可以看到一个 plugin 定义的规范是怎样的 ```typescript // 这里是一个 Plugin 的定义 // 一个 Plugin = Plugin Hooks + onInit type Plugin
= { // Plugin Hooks,props = 实例化的Fetch + 用户传入的 options // Plugin Hooks,return = 订阅异步过程的生命周期函数 list (fetchInstance: Fetch , options: Options ): PluginReturn< TData, TParams ; // onInit,主要为获取 initState 使用 onInit?: (options: Options
) => Partial >; };
// Plugin Hooks 的返回值定义
// 订阅异步过程的生命周期函数 list(对应上面图 run / runAsync 内红色部分标记)
interface PluginReturn
onRequest?: (
service: Service
onSuccess?: (data: TData, params: TParams) => void; onError?: (e: Error, params: TParams) => void; onFinally?: (params: TParams, data?: TData, e?: Error) => void; onCancel?: () => void; onMutate?: (data: TData) => void; }
<a name="n5vh9"></a>#### useRequest 返回值部分```typescriptinterface Result<TData, TParams extends any[]> {// 异步过程中的几个状态 & 数据 & 参数loading: boolean;data?: TData;error?: Error;params: TParams | [];// 提供给到外部,可以直接执行的方法// 暴露的方法都是从 Class Fetch 内直接获取的,从这里就可以看出 Fetch 的作用了cancel: Fetch<TData, TParams>['cancel'];refresh: Fetch<TData, TParams>['refresh'];refreshAsync: Fetch<TData, TParams>['refreshAsync'];run: Fetch<TData, TParams>['run'];runAsync: Fetch<TData, TParams>['runAsync'];mutate: Fetch<TData, TParams>['mutate'];}
源码分析
useRequest Main 流程
function useRequestImplement<TData, TParams extends any[]>(service: Service<TData, TParams>,options: Options<TData, TParams> = {},plugins: Plugin<TData, TParams>[] = [],) {// 1. 默认参数处理过程,如果 manual 没有传的话,默认处理成 falseconst { manual = false, ...rest } = options;const fetchOptions = {manual,...rest,};// 2. 异步函数使用 useLatest 进行存储// https://ahooks.js.org/zh-CN/hooks/use-latest// 作用主要是保持最新值的 service,useLatest 内部使用 useRef 进行存储const serviceRef = useLatest(service);// 3. 生成强制组件渲染函数,提供给到 Fetch 使用的// https://ahooks.js.org/zh-CN/hooks/use-updateconst update = useUpdate();// 4. Fetch 实例化过程const fetchInstance = useCreation(() => {// 4.1 InitState,逐个 plugin 获取 state,进行合并const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);// 4.2 Fetch 实例化return new Fetch<TData, TParams>(serviceRef,fetchOptions,update,Object.assign({}, ...initState),);}, []);fetchInstance.options = fetchOptions;// 5. 注册&执行插件过程,逐一调用并且存至 fetchInstance 的 pluginImpls 内// run all plugins hooksfetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));// 第一次加载后,针对 manual = false 的自动 run 处理useMount(() => {if (!manual) {// useCachePlugin can set fetchInstance.state.params from cache when initconst params = fetchInstance.state.params || options.defaultParams || [];// @ts-ignorefetchInstance.run(...params);}});// 卸载的时候,调用 fetchInstance 的 cancel 函数进行销毁即可useUnmount(() => {fetchInstance.cancel();});// hooks 导出的,实则全是 fetchInstance 内的值 或者 方法,所以关键主要看 Fetch 的实现return {loading: fetchInstance.state.loading,data: fetchInstance.state.data,error: fetchInstance.state.error,params: fetchInstance.state.params || [],cancel: useMemoizedFn(fetchInstance.cancel.bind(fetchInstance)),refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),refreshAsync: useMemoizedFn(fetchInstance.refreshAsync.bind(fetchInstance)),run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),runAsync: useMemoizedFn(fetchInstance.runAsync.bind(fetchInstance)),mutate: useMemoizedFn(fetchInstance.mutate.bind(fetchInstance)),} as Result<TData, TParams>;}
Class Fetch
先从大致的结构开始分析,Fetch 主要存储了什么,还有提供了些什么内部的处理方法:
export default class Fetch<TData, TParams extends any[]> {// 保存所有的 plugin hookspluginImpls: PluginReturn<TData, TParams>[];// 计时器,执行了多少遍count: number = 0;// 异步数据管理// loading:是否正在执行// params:请求参数// data:返回值// error:错误时候的值state: FetchState<TData, TParams> = {loading: false,params: undefined,data: undefined,error: undefined,};constructor(// 用户传进来的异步操作,并且经过 useLatest hooks 导出的 ref 最新值public serviceRef: MutableRefObject<Service<TData, TParams>>,// 用户传进来的 optionspublic options: Options<TData, TParams>,// 订阅操作,这里主要传了 useUpdate,强制组件刷新函数进来public subscribe: Subscribe,// 所有插件的 initState 合集(遍历各个插件的 onInit 方法后,得到的值,合并后得来的)public initState: Partial<FetchState<TData, TParams>> = {},) {// 整一个大合并,到 statethis.state = {...this.state,// 如果不需要手工执行,那么实例化的时候就是 loading 中的// 如果需要手工执行,那么实例化的时候就不是 loading 中的loading: !options.manual,...initState,};}// 合并 state 操作,再触发订阅函数即可setState(s: Partial<FetchState<TData, TParams>> = {}) {this.state = {...this.state,...s,};this.subscribe();}// 执行插件的生命周期函数,遍历执行,并且返回数据大集合runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {// @ts-ignoreconst r = this.pluginImpls.map((i) => i[event]?.(...rest)).filter(Boolean);return Object.assign({}, ...r);}// ---------------------------------// 下面的在下面展开讲讲,先大概记下这几个方法是干什么的// 两个主要执行异步操作的方法async runAsync(...params: TParams): Promise<TData> { ... }run(...params: TParams) { ... }// 一个取消方法cancel() { ... }// 两个刷新的方法refresh() { ... }refreshAsync() { ... }// 一个立即修改 data 数据的方法mutate(data?: TData | ((oldData?: TData) => TData | undefined)) { ... }}
再展开讲讲:
run / runAsnyc(对应流程图 run / runAsync): ```typescript async runAsync(…params: TParams): Promise
{ // 1. 每执行一次都会 count + 1,并且记录下当前的执行 count 到 currentCount 内 // 这个点可以跳跃到下面有个判断 currentCount !== this.count 这里粗略看看,主要是判断是不是被 cancel 使用的 // 也可以看看 cancel 的操作先,cancel 内会对 this.count + 1 this.count += 1; const currentCount = this.count; // 2. 遍历执行插件的 onBefore 钩子函数 const { stopNow = false, returnNow = false, …state } = this.runPluginHandler(‘onBefore’, params);
// 3. 如果插件操作后的结果,是 stopNow,则停止 // stop request if (stopNow) { return new Promise(() => {}); }
// 4. 设置当前 state // 这里可以看到插件生命周期钩子 onBefore 也会影响 state this.setState({ loading: true, params, …state, });
// 5. 如果插件操作后的结果,是 returnNow,则获取当前的 state 内的 data 进行 resolve 返回 // return now if (returnNow) { return Promise.resolve(state.data); }
// 6. 执行用户传入的 onBefore 生命周期钩子 this.options.onBefore?.(params);
// 7. 执行异步过程重头戏!整一个 try catch // 异步过程的成功与失败的主要执行过程其实是类似的 try { // —-执行部分——————————————————————- // 7.1 执行插件的 onRequest 钩子,有可能会在插件内替换掉这个 service // replace service let { servicePromise } = this.runPluginHandler(‘onRequest’, this.serviceRef.current, params); if (!servicePromise) {
servicePromise = this.serviceRef.current(...params);
} // 7.2 执行 service const res = await servicePromise; // —-执行部分——————————————————————-
// 下面这段开始,其实跟 catch error 逻辑大部分类似的// ---执行成功部分---------------------------------------------// 判断是不是被 cancel 了if (currentCount !== this.count) {// prevent run.then when request is canceledreturn new Promise(() => {});}// 设置新的 state,error 要清空this.setState({data: res,error: undefined,loading: false,});// 先执行用户的 onSuccess 生命周期钩子函数this.options.onSuccess?.(res, params);// 再插件的 onSuccess 生命周期钩子函数this.runPluginHandler('onSuccess', res, params);// 先执行用户的 onFinally 生命周期钩子函数this.options.onFinally?.(params, res, undefined);// 再插件的 onFinally 生命周期钩子函数if (currentCount === this.count) {this.runPluginHandler('onFinally', params, res, undefined);}return res; // 返回成功值// ---执行成功部分---------------------------------------------
} catch (error) { // —-执行失败部分——————————————————————- if (currentCount !== this.count) { // prevent run.then when request is canceled return new Promise(() => {}); }
// 设置新的 state,data 不会覆盖上次的this.setState({error,loading: false,});// 先执行用户的 onError 生命周期钩子函数this.options.onError?.(error, params);// 再插件的 onError 生命周期钩子函数this.runPluginHandler('onError', error, params);// 先执行用户的 onFinally 生命周期钩子函数this.options.onFinally?.(params, undefined, error);// 再插件的 onFinally 生命周期钩子函数if (currentCount === this.count) {this.runPluginHandler('onFinally', params, undefined, error);}throw error; // 抛出异常// ---执行失败部分---------------------------------------------
} }
// 实际只是调用 runAsync // 作用就是在 useRequest 内部进行 catch 住 error,不对外抛 // 如果使用 runAsync 的话,就需要用户自己 catch 住错误 run(…params: TParams) { this.runAsync(…params).catch((error) => { if (!this.options.onError) { console.error(error); } }); }
- **cancel:(对应流程图 cancel)**1. count + 1 处理1. 事实上判断当前请求是不是被取消,是通过这个 count 去判断的,对应到 runAsync 内有个判断(**currentCount !== this.count** 的话,就直接返回空的 Promise)1. cancel 过程是同步的,异步过程是异步的,所有就能判断到不一致的情况1. **这里要注意的是,异步过程终究还是执行了,不是真的对这个异步过程取消执行,只是这个过程执行了,但不需要返回而已**2. 设置 state 的 loading 为 false2. 执行插件的 onCanel 钩子函数```typescriptcancel() {this.count += 1;this.setState({loading: false,});this.runPluginHandler('onCancel');}
- refresh / refreshAsync(对应流程图 refresh / refreshAsnyc):只是对 run 进行包装一层,用的是上次的存储的 params 进行重刷 ```typescript refresh() { // @ts-ignore this.run(…(this.state.params || [])); }
refreshAsync() { // @ts-ignore return this.runAsync(…(this.state.params || [])); }
- **mutate: **立即执行更新内部 data 数据,并且触发 plugins 的 onMutate 生命周期钩子函数```typescriptmutate(data?: TData | ((oldData?: TData) => TData | undefined)) {let targetData: TData | undefined;if (typeof data === 'function') {// @ts-ignoretargetData = data(this.state.data);} else {targetData = data;}this.runPluginHandler('onMutate', targetData);this.setState({data: targetData,});}
Plugin Hooks
useAutoRunPlugin
依赖刷新 & ready 功能 主要处理 options 内 refreshDeps / refreshDepsAction/ ready 参数逻辑
- 内部 hasAutoRun 保存了当前异步操作是否正在执行,每次重新执行该 hooks 时,hasAutoRun 都会被默认初始化为 false,如果 ready 与 refreshDeps 都同时改变了,那么会首先执行 ready 的副作用函数,ready 内操作使用 defaultParams 执行 fetchInstance.run,并同时设置 hasAutoRun 为 true。那么 refreshDeps 触发的 effect 操作就不会被执行(因为里面首先判断 hasAutoRun 为 true 时,不执行)
- useUpdateEffect 会忽略首次执行,只在依赖更新时执行
- ready 参数 = false,即当前异步操作没准备好,那么需要中断流程,中断流程是通过暴露 onBefore 钩子,返回 stopNow = true,进行异步流程中断的
- refreshDeps 的副作用函数内,是否执行 refreshDepsAction / refresh,仅仅只是判断了 manual,没有通过 ready 去判断,所以但凡 refreshDeps 变更了,都会执行到 refreshDepsAction / refresh。因为 ready = false 中断,是通过 onBefore 返回的 stopNow 标志去中断的。
另外,在之前针对 Fetch 实例化时,判断 loading 默认 true / false,只针对 manual 去判断,那么需要加上 ready 参数来联合判断,因此需要导出 onInit 钩子函数 ```typescript // support refreshDeps & ready const useAutoRunPlugin: Plugin
= ( fetchInstance, { manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction }, ) => { const hasAutoRun = useRef(false); hasAutoRun.current = false; // 每次组件渲染都会初始化为 false useUpdateEffect(() => { // 如果 ready 并且不需要人工触发的话,则需要自动执行 run if (!manual && ready) {
hasAutoRun.current = true;// 开启 ready 的话,使用 defaultParams 执行 runfetchInstance.run(...defaultParams);
} }, [ready]);
useUpdateEffect(() => { // 说明正在执行上面的 run if (hasAutoRun.current) {
return;
}
// 这里只判断了 manual 的情况,没有把 ready 参数带进来,本质有没有应该无所谓? // 如果有 ready 判断,refresh 操作执行可以忽略 // 目前来看的话,其实每次 refreshDeps 变化,都会执行到 refresh if (!manual) {
hasAutoRun.current = true;// 如果有传递 refreshDepsAction 操作函数,则不会执行 fetch 的刷新操作if (refreshDepsAction) {refreshDepsAction();} else {fetchInstance.refresh();}
} }, […refreshDeps]);
return { // 订阅 onBefore 钩子,返回 stopNow 标志 onBefore: () => {
// 如果 ready 为 false,则设置 stopNow 标志进行流程中断if (!ready) {return {stopNow: true,};}
}, }; };
// 在 Fetch 内原来的 loading 判断逻辑,是单独通过 manual 来判断的 // 但是这里加上 ready 之后,其实需要多一个判断逻辑 // 非手工触发 并且 准备好之后,loading 才是 true // 否则,loading 都是 false 的,一开始都是不自动执行的 useAutoRunPlugin.onInit = ({ ready = true, manual }) => { return { loading: !manual && ready, }; };
<a name="jlMyH"></a>####<a name="rKXSh"></a>#### useDebouncePlugin / useThrottlePlugin> 防抖 / 节流,两个处理操作差不多,一起讲> 主要处理 options 内 debounceWait / debounceLeading / debounceTrailing / debounceMaxWait / throttleWait / throttleLeading / throttleTrailing- debounce 依赖 lodash/debounce 功能,throttle 依赖 lodash / throttle 功能- [https://lodash.com/docs/4.17.15#debounce](https://lodash.com/docs/4.17.15#debounce)- [https://lodash.com/docs/4.17.15#throttle](https://lodash.com/docs/4.17.15#throttle)- 对 options 进行重新格式化,转成 lodash 内置函数参数- 防抖 / 节流,针对 fetchInstance.runAsync 函数进行重写,加一层 debounce / throttle 函数进行执行,当组件销毁时,取消 debounce / throttle,并且对 fetchInstance.runAsync 函数进行还原- 如果开启防抖 / 节流功能,需要订阅 onCanel 钩子函数,进行 取消 debounce / throttle```typescriptconst useDebouncePlugin: Plugin<any, any[]> = (fetchInstance,{ debounceWait, debounceLeading, debounceTrailing, debounceMaxWait },) => {const debouncedRef = useRef<DebouncedFunc<any>>();// 1. 参数格式化const options = useMemo(() => {const ret: DebounceSettings = {};if (debounceLeading !== undefined) {ret.leading = debounceLeading;}if (debounceTrailing !== undefined) {ret.trailing = debounceTrailing;}if (debounceMaxWait !== undefined) {ret.maxWait = debounceMaxWait;}return ret;}, [debounceLeading, debounceTrailing, debounceMaxWait]);// 2. 如果 debounceWait, options 参数变化,就触发副作用函数useEffect(() => {if (debounceWait) {// 保存原来的 fetchInstance.runAsyncconst _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);debouncedRef.current = debounce((callback) => {callback();},debounceWait,options,);// 改写 fetchInstance.runAsync,加一层防抖或者节流,在回调内去执行真正的 runAsync// debounce runAsync should be promise// https://github.com/lodash/lodash/issues/4400#issuecomment-834800398fetchInstance.runAsync = (...args) => {return new Promise((resolve, reject) => {debouncedRef.current?.(() => {_originRunAsync(...args).then(resolve).catch(reject);});});};// 销毁时,取消节流 / 防抖函数并且还原回原来的 fetchInstance.runAsync 函数return () => {debouncedRef.current?.cancel();fetchInstance.runAsync = _originRunAsync;};}}, [debounceWait, options]);// 如果没有开启防抖 / 节流,则不需要返回生命周期钩子函数if (!debounceWait) {return {};}// 如果开启防抖 / 节流,需要订阅 onCanel 的钩子函数// 主要为了取消防抖 / 节流return {onCancel: () => {debouncedRef.current?.cancel();},};};
useThrottlePlugin 类似,不另外赘述
useLoadingDelayPlugin
可以延迟 loading 变成 true 的时间,有效防止闪烁。 主要处理 options 内 loadingDelay
- 订阅 onBefore 操作,在这个期间,注册一个定时器,定时器就是延迟 xx 秒,再设置 loading 为 true。同时改写当前的 loading 状态为 false
onFinally 和 onCanel,都需要取消这个定时器,有几种情况,如果执行时异步时间 < loading延迟时间,因为 onFinally 会取消定时器,故 loading 效果不会显示出来,也不会造成闪退的问题,其他情况也可以自行脑补下
const useLoadingDelayPlugin: Plugin<any, any[]> = (fetchInstance, { loadingDelay }) => {const timerRef = useRef<Timeout>();if (!loadingDelay) {return {};}const cancelTimeout = () => {if (timerRef.current) {clearTimeout(timerRef.current);}};return {onBefore: () => {cancelTimeout();// 延迟设置 loading = truetimerRef.current = setTimeout(() => {fetchInstance.setState({loading: true,});}, loadingDelay);// 先改写成 falsereturn {loading: false,};},onFinally: () => {cancelTimeout(); // 取消定时器},onCancel: () => {cancelTimeout(); // 取消定时器},};};
useRetryPlugin
通过设置 options.retryCount,指定错误重试次数,则 useRequest 在失败后会进行重试。 主要处理 options 内 retryCount / retryInterval
整体重试流程可以概括为以下的流程图:
- count 记录当前重试次数,triggerByRetry 记录当前的请求,是不是重试触发的,time 存储当前的定时器
- onError 内先对当前的重试次数 + 1,如果说在重试次数之内的话,则设置 setTimeout 进行重试,否则对 count 清零
- 理解难点在 triggerByRetry,需要区分当前请求是不是重试触发的,主要是为了解决未到重试时间时,人为触发了异步过程。如果说人为触发了,需要对之前未到的重试 setTimeout 清除,并且重置 count,具体可以看以下代码注释
onSuccess 与 onCancel,都是类似的操作,对 count 清零,并且清除已存在的定时器(onSuccess 代码里面没有 clearTimeout,应该有问题)
const useRetryPlugin: Plugin<any, any[]> = (fetchInstance, { retryInterval, retryCount }) => {const timerRef = useRef<Timeout>();const countRef = useRef(0);const triggerByRetry = useRef(false);// 如果没有开启重试功能,不需要返回钩子函数if (!retryCount) {return {};}return {onBefore: () => {// 说明这是人为导致的异步触发,需要重置 count// 因为重试执行的异步过程 triggerByRetry 为 true// 可以看到 setTimeout 里面 refresh 之前,设置了 triggerByRetry = trueif (!triggerByRetry.current) {countRef.current = 0;}// 每次请求前,都会重置 triggerByRetry 标志为 false// 并且清除已有的定时器// 因为有可能在定时器还没到时间之前,人为进行触发了异步,那么之前在等待重试的异步过程,需要清掉triggerByRetry.current = false;if (timerRef.current) {clearTimeout(timerRef.current);}},onSuccess: () => {// onSuccess 这里只是 count 清零,没有清除已有的定时器,是否有问题?// 正在 setTimeout 等待重试的过程中,用户手动点击重刷异步操作,并且成功了,这个时候应该是要清除的?countRef.current = 0;},onError: () => {countRef.current += 1;if (retryCount === -1 || countRef.current <= retryCount) {// Exponential backoffconst timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.current, 30000);timerRef.current = setTimeout(() => {triggerByRetry.current = true; // 调用在重试之前,会将 triggerByRetry 设置为 truefetchInstance.refresh(); // 调用刷新}, timeout);} else {countRef.current = 0;}},onCancel: () => {countRef.current = 0;if (timerRef.current) {clearTimeout(timerRef.current);}},};};
usePollingPlugin
通过设置 options.pollingInterval,进入轮询模式,useRequest 会定时触发 service 执行。 主要处理 options 内 pollingInterval
- onFinally 内,设置 setTimeout 定时器,进行 refresh
- onFinally 内,如果设置了 pollingWhenHidden = false(页面隐藏时,停止轮询),并且当前标签页不是激活状态下,订阅【标签页激活事件】,停止轮询直接 return,那么当页面再次打开时,则会执行回调进行异步 refresh。执行 refresh 后,当再次进入 onFinally 钩子时,因为不命中标签页隐藏条件, 则又开启轮询
- onBefore & onCancel,clearTimeout
监听 pollingInterval 值,从 大于零 —更新为—> 零,同样需要 clear 操作 ```typescript const usePollingPlugin: Plugin
= ( fetchInstance, { pollingInterval, pollingWhenHidden = true }, ) => { const timerRef = useRef (); const unsubscribeRef = useRef<() => void>(); // 停止定时器,并且退订标签页激活事件 const stopPolling = () => { if (timerRef.current) {
clearTimeout(timerRef.current);
} unsubscribeRef.current?.(); };
// 若关闭轮询,则销毁定时器 useUpdateEffect(() => { if (!pollingInterval) {
stopPolling();
} }, [pollingInterval]);
// 若没有开启轮询功能,不需要返回钩子事件 if (!pollingInterval) { return {}; }
return { onBefore: () => { stopPolling(); }, onFinally: () => { // 如果设置 pollingWhenHidden = false 并且 当前标签页隐藏状态 // 仅仅只是订阅 re visible 事件,停止轮询 return // 当再次激活标签页时,触发 refresh 重刷接口 if (!pollingWhenHidden && !isDocumentVisible()) { unsubscribeRef.current = subscribeReVisible(() => { fetchInstance.refresh(); }); return; }
// 设置轮询timerRef.current = setTimeout(() => {fetchInstance.refresh();}, pollingInterval);},onCancel: () => {stopPolling();},
}; };
对于标签页激活,实现了一个简单的发布-订阅:```typescriptconst listeners: any[] = []; // 全局保存了回调操作 list// 事件订阅function subscribe(listener: () => void) {listeners.push(listener); // 放于 list 内// 返回退订函数,退订函数内知识讲该回调剔除出 list 之外return function unsubscribe() {const index = listeners.indexOf(listener);listeners.splice(index, 1);};}if (canUseDom()) {// visibilitychange 事件,当前如果隐藏,则不操作// 当前如果是激活,则遍历 listeners,逐个回调依次执行const revalidate = () => {if (!isDocumentVisible()) return;for (let i = 0; i < listeners.length; i++) {const listener = listeners[i];listener();}};// 监听 visibilitychange 事件window.addEventListener('visibilitychange', revalidate, false);}
useRefreshOnWindowFocusPlugin
通过设置 options.refreshOnWindowFocus,在浏览器窗口 refocus 和 revisible 时,会重新发起请求。 主要处理 options 内 refreshOnWindowFocus
- 当 refreshOnWindowFocus = true 时,订阅 focus 事件,当 focus 时,重试执行异步的 refresh 操作,这里的 limit 函数限制了执行间隔,可以看到具体的实现
- 在组件销毁 / refreshOnWindowFocus 变为 false 时,注销订阅事件
具体 subscribeFocus 实现,与上面 subscribeReVisible 实现类似,全局管理了一个 listeners 队列,回调后遍历执行
const useRefreshOnWindowFocusPlugin: Plugin<any, any[]> = (fetchInstance,{ refreshOnWindowFocus, focusTimespan = 5000 },) => {const unsubscribeRef = useRef<() => void>();// 注销const stopSubscribe = () => {unsubscribeRef.current?.();};useEffect(() => {// refreshOnWindowFocus = true 时候订阅执行if (refreshOnWindowFocus) {const limitRefresh = limit(fetchInstance.refresh.bind(fetchInstance), focusTimespan);unsubscribeRef.current = subscribeFocus(() => {limitRefresh();});}// 注销return () => {stopSubscribe();};}, [refreshOnWindowFocus, focusTimespan]);// 注销useUnmount(() => {stopSubscribe();});// 不需要监听任何生命周期,因为这是个一次性的操作,与任何请求阶段无关return {};};
用 pending 标志控制是否执行,pending 为 false 的时候执行 fn,当 timespan 后,再设置会标志为 pending 为 true,因此在 timespan 内 pending = true,都会被直接 return 掉。以此来控制 fn 执行的时间间隔。 ```typescript export default function limit(fn: any, timespan: number) { let pending = false; return (…args: any[]) => { if (pending) return; pending = true; fn(…args); setTimeout(() => {
pending = false;
}, timespan); }; }
<a name="YO3EI"></a>#### useCachePlugin> SWR,数据缓存能力> 主要处理 options 内 cacheKey / cacheTime / staleTime / setCache / getCache**数据新鲜**<br />先查看 utils/cache 代码,实现了一个简单的内存 cache 操作:- 全局保存了 cache Map,以 cacheKey 作为 key,对应 value 存储了 data - 异步返回结果,params - 请求参数,time - 清除缓存定时器(默认 cacheTime = 5min,plugin hooks 默认参数设置)- setCache,首先对已有 key 的 cache 进行清除(主要是清除计时器),再针对这个 key 进行重新注册- 同时暴露 getCache 与 clearCache 操作,对应实现也比较简单```typescriptexport interface CachedData<TData = any, TParams = any> {data: TData; // 异步执行结果params: TParams; // 异步请求参数time: number; // 缓存那一刻的时间}interface RecordData extends CachedData {timer: Timer | undefined; // 清除缓存定时器}// map 存储数据,全局一份const cache = new Map<CachedKey, RecordData>();// 设置key 对应的 cacheconst setCache = (key: CachedKey, cacheTime: number, cachedData: CachedData) => {// 先清除const currentCache = cache.get(key);if (currentCache?.timer) {clearTimeout(currentCache.timer);}// 再重新登记let timer: Timer | undefined = undefined;// 如果是 -1 的话,timer 为空,永不清除if (cacheTime > -1) {// if cache out, clear ittimer = setTimeout(() => {cache.delete(key);}, cacheTime);}cache.set(key, {...cachedData,timer,});};// 获取对应 cacheconst getCache = (key: CachedKey) => {return cache.get(key);};// 清除某些 cache,可以一次清除多个或者一个,不传就是全清除const clearCache = (key?: string | string[]) => {if (key) {const cacheKeys = Array.isArray(key) ? key : [key];cacheKeys.forEach((cacheKey) => cache.delete(cacheKey));} else {cache.clear();}};
对于基础的异步数据缓存能力,结合代码,大致可以分为以下几种情况来描述:
| 流程描述 | 图 | 对应代码实现(主要关注红框部分实现即可) | 实际业务使用场景 |
|---|---|---|---|
| 在没有命中数据缓存的情况下:需要等到异步数据返回,才能展示数据 - loading:false -> true - data:无 -> 新 |
![]() |
onBefore: 不拦截请求,返回空数据即可 ![]() onSuccess: 如果设置了 cacheKey,则设置 ![]() |
loading && !data 联合判断下: - loading 状态会显示出来 - 异步结束时,展示数据 |
| 命中数据缓存,在 cacheTime 时间内,但不在 slateTime 时间内:先从 cache 获取展示旧 data,不阻塞异步操作,等异步数据回来后,再直接更新展示新 data - loading:false -> true - data:旧 -> 新 |
![]() |
onBefore: 在请求前,先从 cache 获取数据,先写到 data 里面,loading 和 params 还是正常请求的状态值 ![]() onSuccess: 如果设置了 cacheKey,则设置 ![]() hooks 初始化时: 命中缓存的情况下(缓存时间不在 slateTime 内),会从 cache 内获取数据先直接写入 params 和 data ![]() |
loading && !data 联合判断下: - loading 状态不会显示,会默认展示到上一次的数据 - 异步结束后,直接重新渲染新的数据 |
| 命中数据缓存,并且在 slateTime 时间内:不从异步操作获取数据,直接从 cache 获取 - loading:始终 false - data:无 / 旧 -> 新 |
![]() |
onBefore: slateTime 等于 -1 说明永远新鲜,或者 cache 时间间隔在 slateTime 内,则直接从 cache get 数据,返回 returnNow = true 标志停止异步过程 ![]() onSuccess: onBefore 直接 return 掉了,不会走到这个钩子的 hooks 初始化时: 在 slateTime 时间内,不需要展示 loading 状态(大框框的逻辑命中缓存时间内都会走到,小框框是只有 slateTime 时间内才会走,区别只是需不需要设置 loading 状态而已) ![]() |
loading && !data 联合判断下: - loading 状态不会显示,会默认展示到上一次的数据 - 上一次的数据,就是最新的数据 |
总结来说,具体缓存时间可以以下概括:
- 最开始没有缓存的时候,会触发异步操作,成功后,写 cache
- 深红色新鲜时间内,不会触发异步过程,直接读取 cache 数据
- 超过新鲜时间后,仍在缓存时间内,在展示旧数据的同时,会异步拉取数据,当数据回来后,再展示到新的数据,同时重新设置上新的 cache
- 若人为触发 clearCache,再重新触发异步,则再次循环以上过程
数据共享
若分别在两个 component 下,看下是怎么实现数据共享的,主要有两点:
- 对 service promise 的共享,同一份数据,接口只会发出一次
- 对数据的共享,A 组件已经获取过一次最新的数据,那么 B 如果也读到这份数据的话,不需要重复拉取
第一点,是怎么实现对异步操作 promise 缓存的,大致流程可以以下图概括,若 ComponentA 已经触发了异步请求,会对这个 service promise 缓存在全局内存中,当 ComponentB 组件紧接着也被触发之后,会直接从内存读取到这个 service promise:
utils / cachePromise 实现了对一个 Promise 的缓存:
type CachedKey = string | number;// 缓存 Map,key 是 cacheKeyconst cachePromise = new Map<CachedKey, Promise<any>>();// 读取 promise cacheconst getCachePromise = (cacheKey: CachedKey) => {return cachePromise.get(cacheKey);};// 写入 promise cacheconst setCachePromise = (cacheKey: CachedKey, promise: Promise<any>) => {// Should cache the same promise, cannot be promise.finally// Because the promise.finally will change the reference of the promisecachePromise.set(cacheKey, promise);// no use promise.finally for compatibility// 在 promise 结束的时候,then 和 cache 再删除 cache 即可promise.then((res) => {cachePromise.delete(cacheKey);return res;}).catch(() => {cachePromise.delete(cacheKey);});};
useCachePlugin 内的 onRequest 操作,以及 Fetch 内的 runAsync 过程:
// 关注 useCachePlugin 内的 onRequest:const useCachePlugin: Plugin<any, any[]> = (fetchInstance,{cacheKey,cacheTime = 5 * 60 * 1000,staleTime = 0,setCache: customSetCache,getCache: customGetCache,},) => {const currentPromiseRef = useRef<Promise<any>>();// ...return {onBefore: (params) => {// ...},onRequest: (service, args) => {// 1: 先从 cache 读 promiselet servicePromise = cachePromise.getCachePromise(cacheKey);// 2.1: 如果有,则直接返回这个 promiseif (servicePromise && servicePromise !== currentPromiseRef.current) {return { servicePromise };}// 2.2: 如果没有,则构造一个缓存,然后返回这个 promiseservicePromise = service(...args);currentPromiseRef.current = servicePromise;cachePromise.setCachePromise(cacheKey, servicePromise);return { servicePromise };},onSuccess: (data, params) => {// ...},onMutate: (data) => {// ...},};};// Fetch 的 runAsync 操作:async runAsync() {try {// 1. 从 onRequest 钩子拿到 servicePromise// 有可能是缓存的,也有可能不是,不是的话会顺便缓存一个let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);// 2. 兜底空的情况if (!servicePromise) {servicePromise = this.serviceRef.current(...params);}// 3. 执行这个 promise 获取数据const res = await servicePromise;// ...}}
针对第二点,数据的共享,大致流程图如下,假如 ComponentA 是被触发异步操作的,并且 ComponentB 渲染的数据与 ComponentA 是同一份(ComponentB 没有被触发异步操作):
可以先看下 utils/cacheSubscribe,实现了针对 cache key set 操作的发布-订阅:
type Listener = (data: any) => void;// 事件队列const listeners: Record<string, Listener[]> = {};// 触发过程,就是遍历 listeners 逐个传参数执行const trigger = (key: string, data: any) => {if (listeners[key]) {listeners[key].forEach((item) => item(data));}};const subscribe = (key: string, listener: Listener) => {if (!listeners[key]) {listeners[key] = [];}listeners[key].push(listener); // 订阅 push 函数到队列// 退订操作return function unsubscribe() {const index = listeners[key].indexOf(listener);listeners[key].splice(index, 1);};};export { trigger, subscribe };
结合 cacheSubscribe,在 useCachePlugin 实现相关逻辑:
- 触发的时机,在 setCache 的时候
- onSuccess 和 onMutate 的过程是类似的,过程都是先取消订阅,设置 cache,再重新订阅。这个过程有点迷,单看一个 component 可能分析不出来,其实完整的描述过程是(场景假设是只触发了 A 的异步,B 是被动更新),先取消订阅 A 的 trigger 事件(这样之后 set cache,就不会触发 A 本身 ),set cache(这时候会触发 B 的 trigger),再重新订阅回 A 的 trigger 事件(这样如果之后在 B 那边触发了,A 也是走这么个流程)
trigger 的回调都是 fetchInstance.setState({ data }),回忆一下 Fetch 内封装的逻辑,setState 会触发 update,update 强制更新 render
const useCachePlugin: Plugin<any, any[]> = (fetchInstance,{cacheKey,cacheTime = 5 * 60 * 1000,staleTime = 0,setCache: customSetCache,getCache: customGetCache,},) => {const unSubscribeRef = useRef<() => void>();// setCache 操作:会 trigger 队列里所有的事件const _setCache = (key: string, cachedData: CachedData) => {// ...cacheSubscribe.trigger(key, cachedData.data);};// ...useCreation(() => {// ...// 首次 hooks 注册订阅 cache set 事件// subscribe same cachekey update, trigger updateunSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (data) => {fetchInstance.setState({ data });});}, []);// 组件注销,退订 cache setuseUnmount(() => {unSubscribeRef.current?.();});// ...return {onBefore: (params) => {// ...},onRequest: (service, args) => {// ...},onSuccess: (data, params) => {if (cacheKey) {// cancel subscribe, avoid trgger selfunSubscribeRef.current?.(); // 1. 先注销自己// 2. 触发 set cache,set cache trigger 事件队列_setCache(cacheKey, {data,params,time: new Date().getTime(),});// resubscribe// 3. 重新订阅unSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (d) => {fetchInstance.setState({ data: d });});}},// 类似 onSuccess 的步骤,不赘述onMutate: (data) => {if (cacheKey) {// cancel subscribe, avoid trgger selfunSubscribeRef.current?.();_setCache(cacheKey, {data,params: fetchInstance.state.params,time: new Date().getTime(),});// resubscribeunSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (d) => {fetchInstance.setState({ data: d });});}},};};
综上所述,结合三个 utils 原材料,可以整体回顾下 useCachePlugin 的实现了,这里就不再重复总结了
细节分析
事实上 useRequest 内运用了很多基础 ahooks,比如 useUpdateEffect,useCreation 等,在看 useRequest 前,最后对其他基础的 ahooks 先有个大致印象会比较好,也更有助于阅读理解:
Fetch 在 hooks 内是如何避免重复实例化的?
使用的是 useCreation:https://ahooks.js.org/zh-CN/hooks/use-creation,具体实现如下:
本质还是使用 useRef,将 factory 函数执行的结果保存在 current.obj 内
- 使用 initialized 标记是不是已经初始化过 ```typescript import type { DependencyList } from ‘react’; import { useRef } from ‘react’; import depsAreSame from ‘../utils/depsAreSame’;
export default function useCreation
// 只有 initialized 是 false // 或者 依赖列表发生变化的时候 // 才需要重新执行 factory(), 获取新的实例 if (current.initialized === false || !depsAreSame(current.deps, deps)) { current.deps = deps; current.obj = factory(); current.initialized = true; }
// 返回这个实例 return current.obj as T; }
<a name="a0Si1"></a>### Fetch 是 Class,数据也是存储在 Class 内的,如何通知外层 useRequest hooks 需要做组件更新?使用的是 useUpdate:[https://ahooks.js.org/zh-CN/hooks/use-update](https://ahooks.js.org/zh-CN/hooks/use-update),具体实现如下:其实是设置了个空的 state,每次都强行设置 state,就能触发更新,然后把这个 hooks 返回的函数,传至 Fetch 内进行使用```typescriptconst useUpdate = () => {const [, setState] = useState({});return useCallback(() => setState({}), []);};
Cancel 没有真正意义上的取消,只是不返回
// Fetch cancel 对 count 进行 + 1 处理cancel() {this.count += 1;this.setState({loading: false,});this.runPluginHandler('onCancel');}// Fetch runAsync 执行后会判断 currentCount !== this.countasync runAsync() {// ...try {// replace servicelet { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);if (!servicePromise) {servicePromise = this.serviceRef.current(...params);}const res = await servicePromise;// 主要是这里if (currentCount !== this.count) {// prevent run.then when request is canceledreturn new Promise(() => {});}// ...}// ...}
每个 plugin hooks 其实都只是处理 options 内的某些参数而已
// useRequest 内注册插件时// 会把当前的实例化Fetch 与 全量 options 往下传递fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));// useAutoRunPlugin// 只接收了 manual / ready / defaultParams / refreshDeps / refreshDepsActionconst useAutoRunPlugin: Plugin<any, any[]> = (fetchInstance,{ manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction },) => { ... }// useCachePlugin// 只接收了 cacheKey / cacheTime / staleTime / setCache / getCacheconst useCachePlugin: Plugin<any, any[]> = (fetchInstance,{cacheKey,cacheTime = 5 * 60 * 1000,staleTime = 0,setCache: customSetCache,getCache: customGetCache,},) => { ... }// 等等以此类推
疑问点保留
- plugins 参数是开放出去的(系统默认带上公共插件),意味着用户可以自定义插件?
- plugins 注册的顺序是否有关系?看上去是个数组
- Fetch 内 runPluginHandler 内会根据 plugins 的注册进行调用,调用返回结果会统一存于一个大 object 内,所以这里后一个插件调用的结果值,有可能会覆盖前面的,是否比较容易出错?或者书写插件时比较小心翼翼?
一点点感悟
- 代码组织可参考借鉴,以插件方式进行不同功能管理,易扩展
- Class 与 Hooks 也可完美结合,有点意思
- 对异步数据请求封装抽象出不同生命周期进行管理组织,清晰易懂










