源码地址
- 文档: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>, // options
plugins?: 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 返回值部分
```typescript
interface 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 没有传的话,默认处理成 false
const { 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-update
const 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 hooks
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
// 第一次加载后,针对 manual = false 的自动 run 处理
useMount(() => {
if (!manual) {
// useCachePlugin can set fetchInstance.state.params from cache when init
const params = fetchInstance.state.params || options.defaultParams || [];
// @ts-ignore
fetchInstance.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 hooks
pluginImpls: 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>>,
// 用户传进来的 options
public options: Options<TData, TParams>,
// 订阅操作,这里主要传了 useUpdate,强制组件刷新函数进来
public subscribe: Subscribe,
// 所有插件的 initState 合集(遍历各个插件的 onInit 方法后,得到的值,合并后得来的)
public initState: Partial<FetchState<TData, TParams>> = {},
) {
// 整一个大合并,到 state
this.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-ignore
const 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 canceled
return 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 为 false
2. 执行插件的 onCanel 钩子函数
```typescript
cancel() {
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 生命周期钩子函数
```typescript
mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
let targetData: TData | undefined;
if (typeof data === 'function') {
// @ts-ignore
targetData = 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 执行 run
fetchInstance.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
```typescript
const 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.runAsync
const _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-834800398
fetchInstance.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 = true
timerRef.current = setTimeout(() => {
fetchInstance.setState({
loading: true,
});
}, loadingDelay);
// 先改写成 false
return {
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 = true
if (!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 backoff
const timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.current, 30000);
timerRef.current = setTimeout(() => {
triggerByRetry.current = true; // 调用在重试之前,会将 triggerByRetry 设置为 true
fetchInstance.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();
},
}; };
对于标签页激活,实现了一个简单的发布-订阅:
```typescript
const 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 默认参数设置)![截屏2022-04-25 16.24.11.png](https://cdn.nlark.com/yuque/0/2022/png/22895623/1650875070532-3dec5097-7139-4889-9e0f-c5d17b4448d4.png#clientId=u4933b4de-7830-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=148&id=cDQgW&margin=%5Bobject%20Object%5D&name=%E6%88%AA%E5%B1%8F2022-04-25%2016.24.11.png&originHeight=296&originWidth=908&originalType=binary&ratio=1&rotation=0&showTitle=false&size=45874&status=done&style=none&taskId=u99798829-afdd-4f71-a34e-5694fe621cf&title=&width=454)
- setCache,首先对已有 key 的 cache 进行清除(主要是清除计时器),再针对这个 key 进行重新注册
- 同时暴露 getCache 与 clearCache 操作,对应实现也比较简单
```typescript
export 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 对应的 cache
const 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 it
timer = setTimeout(() => {
cache.delete(key);
}, cacheTime);
}
cache.set(key, {
...cachedData,
timer,
});
};
// 获取对应 cache
const 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 是 cacheKey
const cachePromise = new Map<CachedKey, Promise<any>>();
// 读取 promise cache
const getCachePromise = (cacheKey: CachedKey) => {
return cachePromise.get(cacheKey);
};
// 写入 promise cache
const 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 promise
cachePromise.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 读 promise
let servicePromise = cachePromise.getCachePromise(cacheKey);
// 2.1: 如果有,则直接返回这个 promise
if (servicePromise && servicePromise !== currentPromiseRef.current) {
return { servicePromise };
}
// 2.2: 如果没有,则构造一个缓存,然后返回这个 promise
servicePromise = 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 update
unSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (data) => {
fetchInstance.setState({ data });
});
}, []);
// 组件注销,退订 cache set
useUnmount(() => {
unSubscribeRef.current?.();
});
// ...
return {
onBefore: (params) => {
// ...
},
onRequest: (service, args) => {
// ...
},
onSuccess: (data, params) => {
if (cacheKey) {
// cancel subscribe, avoid trgger self
unSubscribeRef.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 self
unSubscribeRef.current?.();
_setCache(cacheKey, {
data,
params: fetchInstance.state.params,
time: new Date().getTime(),
});
// resubscribe
unSubscribeRef.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 内进行使用
```typescript
const 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.count
async runAsync() {
// ...
try {
// replace service
let { 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 canceled
return new Promise(() => {});
}
// ...
}
// ...
}
每个 plugin hooks 其实都只是处理 options 内的某些参数而已
// useRequest 内注册插件时
// 会把当前的实例化Fetch 与 全量 options 往下传递
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
// useAutoRunPlugin
// 只接收了 manual / ready / defaultParams / refreshDeps / refreshDepsAction
const useAutoRunPlugin: Plugin<any, any[]> = (
fetchInstance,
{ manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction },
) => { ... }
// useCachePlugin
// 只接收了 cacheKey / cacheTime / staleTime / setCache / getCache
const useCachePlugin: Plugin<any, any[]> = (
fetchInstance,
{
cacheKey,
cacheTime = 5 * 60 * 1000,
staleTime = 0,
setCache: customSetCache,
getCache: customGetCache,
},
) => { ... }
// 等等以此类推
疑问点保留
- plugins 参数是开放出去的(系统默认带上公共插件),意味着用户可以自定义插件?
- plugins 注册的顺序是否有关系?看上去是个数组
- Fetch 内 runPluginHandler 内会根据 plugins 的注册进行调用,调用返回结果会统一存于一个大 object 内,所以这里后一个插件调用的结果值,有可能会覆盖前面的,是否比较容易出错?或者书写插件时比较小心翼翼?
一点点感悟
- 代码组织可参考借鉴,以插件方式进行不同功能管理,易扩展
- Class 与 Hooks 也可完美结合,有点意思
- 对异步数据请求封装抽象出不同生命周期进行管理组织,清晰易懂