前言
useRequest
是 ahooks 中提供的用于异步数据管理的 Hook
useRequest
通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能。目前已有能力包括:
- 自动请求/手动请求
- 轮询
- 防抖
- 节流
- 屏幕聚焦重新请求
- 错误重试
- loading delay
- SWR(stale-while-revalidate)
- 缓存
基本用法
默认请求
默认情况下,useRequest
第一个参数是一个异步函数,只需要返回一个 promise
即可。在组件初始化时,会自动执行该异步函数。同时自动管理该异步函数的 loading
, data
, error
等状态。
const { data, error, loading } = useRequest(service);
手动触发
如果设置了 options.manual = true
,则 useRequest
不会默认执行,需要通过 run
或者 runAsync
来触发执行。
const { loading, run, runAsync } = useRequest(service, {
manual: true
});
<button onClick={run} disabled={loading}>
{loading ? 'Loading' : 'Edit'}
</button>
run
与 runAsync
的区别在于:
-
run
是一个普通的同步函数,自动捕获异常,可以通过options.onError
来处理异常时的行为。 -
runAsync
是一个返回Promise
的异步函数,如果使用runAsync
来调用,则意味着需要手动捕获异常。
生命周期
useRequest
提供了以下几个生命周期配置项,以便在异步函数的不同阶段做一些处理。
onBefore
:请求之前触发onSuccess
:请求成功触发onError
:请求失败触发onFinally
:请求完成触发
const { loading, run } = useRequest(service, {
manual: true,
onBefore: (params) => {
console.log(params);
},
onSuccess: (result, params) => {
console.log(result, params);
},
onError: (error) => { },
onFinally: (params, result, error) => { },
});
刷新(重复上一次请求)
useRequest
提供了 refresh
和 refreshAsync
方法,可以使用上一次的参数,重新发起请求。
const { data, loading, run, refresh } = useRequest((id: number) => getUsername(id), {
manual: true,
});
refresh
和 refreshAsync
的区别和 run
和 runAsync
一样。
立即变更数据
useRequest
提供了 mutate
, 支持立即修改 useRequest
返回的 data
参数。
支持 mutate(newData)
和 mutate((oldData) => newData)
两种写法。
mutate
的应用场景:
不希望等接口调用成功之后,才给用户反馈。而是直接修改页面数据,同时在背后去调用修改接口,等修改接口返回之后,另外提供反馈。
取消响应
useRequest
提供了 cancel
函数,用于忽略当前 promise 返回的数据和错误
需要注意的是:调用 cancel
函数并不会取消 promise 的执行
同时 useRequest
会在以下时机自动忽略响应:
- 组件卸载时,忽略正在进行的 promise
- 竞态取消,当上一次 promise 还没返回时,又发起了下一次 promise,则会忽略上一次 promise 的响应
参数管理
useRequest
返回的 params
会记录当次调用 service
的参数数组。如触发了 run(1, 2, 3)
,则 params
等于 [1, 2, 3]
。
如果设置了 options.manual = false
,则首次调用 service
的参数可以通过 options.defaultParams
来设置。
轮询
通过设置 options.pollingInterval
,进入轮询模式,useRequest
会定时触发 service 执行。
const { data, run, cancel } = useRequest(getUsername, {
pollingInterval: 3000,
});
通过 cancel
来停止轮询,通过 run/runAsync
来启动轮询。
依赖刷新
useRequest 提供了一个 options.refreshDeps
参数,当它的值变化后,会重新触发请求。
const [userId, setUserId] = useState('1');
const { data, run } = useRequest(() => getUserSchool(userId), {
refreshDeps: [userId],
});
错误重试
通过设置 options.retryCount
,指定错误重试次数,则 useRequest
在失败后会进行重试。
const { data, run } = useRequest(getUsername, {
retryCount: 3,
});
缓存 & SWR
如果设置了 options.cacheKey
,useRequest
会将当前请求成功的数据缓存起来。下次组件初始化时,如果有缓存数据,会优先返回缓存数据,然后在背后发送新请求,也就是 SWR 。
通过 options.staleTime
设置数据保持新鲜时间,在该时间内,则认为数据是新鲜的,不会重新发起请求。
通过 options.cacheTime
设置数据缓存时间,超过该时间,会清空该条缓存数据。
const { data, loading } = useRequest(getArticle, {
cacheKey: 'staleTime-demo',
staleTime: 5000,
});
需要注意的是,同一个 cacheKey
的内容,在全局是共享的,这会带来以下几个特性
- 请求
Promise
共享,相同的cacheKey
同时只会有一个在发起请求,后发起的会共用同一个请求Promise
- 数据同步,任何时候,当改变其中某个
cacheKey
的内容时,其它相同cacheKey
的内容均会同步
ahooks 提供了一个 clearCache
方法,通过 clearCache
方法,可以清除指定 cacheKey
的缓存数据。
源码解析
正如前文所述,useRequest
源码非常简单。下面来看一下。
入口
function useRequest<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options?: Options<TData, TParams>,
plugins?: Plugin<TData, TParams>[],
) {
return useRequestImplement<TData, TParams>(service, options, [
...(plugins || []),
useDebouncePlugin,
useLoadingDelayPlugin,
usePollingPlugin,
useRefreshOnWindowFocusPlugin,
useThrottlePlugin,
useAutoRunPlugin,
useCachePlugin,
useRetryPlugin,
] as Plugin<TData, TParams>[]);
}
可以看到,useRequest
会将第三个参数作为 Plugin 传入。下面来看一下 Implement
。
useRequestImplement
function useRequestImplement<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options: Options<TData, TParams> = {},
plugins: Plugin<TData, TParams>[] = [],
) {
const { manual = false, ...rest } = options;
const fetchOptions = {
manual,
...rest,
};
const serviceRef = useLatest(service);
const update = useUpdate();
const fetchInstance = useCreation(() => {
const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
return new Fetch<TData, TParams>(
serviceRef,
fetchOptions,
update,
Object.assign({}, ...initState),
);
}, []);
fetchInstance.options = fetchOptions;
// run all plugins hooks
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
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);
}
});
useUnmount(() => {
fetchInstance.cancel();
});
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>;
}
大体逻辑如下:
- 创建请求实例
fetchInstance
,后续如果需要再请求,则通过实例运行,同时通过fetchInstance
管理请求参数以及请求状态。创建fetchInstance
时会传入fetchOptions
执行插件的onInit
方法,并将onInit
方法的返回值保存到fetchInstance
上。 - 传入
fetchInstance
以及fetchOptions
执行传入的 plugin,保存至fetchInstance
的pluginImpls
。 - 如果没有设置
manual
为true
,则执行请求。
核心 Fetch Class
可以看到, useRequest
的核心就是 fetchInstance
。 通过 fetchInstance
管理了请求参数,请求状态,竞态条件等等。下面一起来看一下 Fetch
。
export default class Fetch<TData, TParams extends any[]> {
pluginImpls: PluginReturn<TData, TParams>[];
count: number = 0;
state: FetchState<TData, TParams> = {
loading: false,
params: undefined,
data: undefined,
error: undefined,
};
constructor(
public serviceRef: MutableRefObject<Service<TData, TParams>>,
public options: Options<TData, TParams>,
public subscribe: Subscribe,
public initState: Partial<FetchState<TData, TParams>> = {},
) {
this.state = {
...this.state,
loading: !options.manual,
...initState,
};
}
setState(s: Partial<FetchState<TData, TParams>> = {}) {
this.state = {
...this.state,
...s,
};
this.subscribe();
}
runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {
const r = this.pluginImpls.map((i) => i[event]?.(...rest)).filter(Boolean);
return Object.assign({}, ...r);
}
async runAsync(...params: TParams): Promise<TData> {
this.count += 1;
const currentCount = this.count;
const {
stopNow = false,
returnNow = false,
...state
} = this.runPluginHandler('onBefore', params);
// stop request
if (stopNow) {
return new Promise(() => {});
}
this.setState({
loading: true,
params,
...state,
});
// return now
if (returnNow) {
return Promise.resolve(state.data);
}
this.options.onBefore?.(params);
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(() => {});
}
this.setState({
data: res,
error: undefined,
loading: false,
});
this.options.onSuccess?.(res, params);
this.runPluginHandler('onSuccess', res, params);
this.options.onFinally?.(params, res, undefined);
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(() => {});
}
this.setState({
error,
loading: false,
});
this.options.onError?.(error, params);
this.runPluginHandler('onError', error, params);
this.options.onFinally?.(params, undefined, error);
if (currentCount === this.count) {
this.runPluginHandler('onFinally', params, undefined, error);
}
throw error;
}
}
run(...params: TParams) {
this.runAsync(...params).catch((error) => {
if (!this.options.onError) {
console.error(error);
}
});
}
cancel() {
this.count += 1;
this.setState({
loading: false,
});
this.runPluginHandler('onCancel');
}
refresh() {
this.run(...(this.state.params || []));
}
refreshAsync() {
return this.runAsync(...(this.state.params || []));
}
mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
const targetData = isFunction(data) ? data(this.state.data) : data;
this.runPluginHandler('onMutate', targetData);
this.setState({
data: targetData,
});
}
可以看到,逻辑非常简单
在 ts 中,constructor
的入参中,带有可见性修饰符的参数会自动挂载到实例上。
Fetch
的核心就是 runPluginHandler
和 runAsync
方法。
runPluginHandler
负责执行插件中特定的生命周期。
runAsync
负责请求,每一次执行 runAsync
方法,都会将 this.count + 1
,并将当前的id保存下来,这个 count
其实就是请求的id。在后续的处理中,如果不是需要的id,则通过一个 pending
的 promise
忽略返回结果。同时会在特定的时机执行特定的生命周期函数(包括 useRequest
入参的生命周期和插件返回的生命周期。
插件
useRequest
的插件是一个函数,入参为 fetchInstance
及 useRequest
的 options
,返回一个生命周期对象,指定的函数会在请求对应的时机执行。
插件的 onInit
方法可以在初始化的时候修改 fetchInstance.state
。
export interface PluginReturn<TData, TParams extends any[]> {
onBefore?: (params: TParams) =>
| ({
stopNow?: boolean;
returnNow?: boolean;
} & Partial<FetchState<TData, TParams>>)
| void;
onRequest?: (
service: Service<TData, TParams>,
params: TParams,
) => {
servicePromise?: Promise<TData>;
};
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;
}
export type Plugin<TData, TParams extends any[]> = {
(fetchInstance: Fetch<TData, TParams>, options: Options<TData, TParams>): PluginReturn<
TData,
TParams
>;
onInit?: (options: Options<TData, TParams>) => Partial<FetchState<TData, TParams>>;
};
useRequest
提供的各种功能都是通过插件实现的。
下面来看一下缓存 & (SWR)的实现
缓存 & (SWR)
swr的实现是通过 useCachePlugin
实现的。 useCachePlugin
的核心方法如下。
const cache = new Map<CachedKey, RecordData>();
const setCache = (key: CachedKey, cacheTime: number, cachedData: CachedData) => {
const currentCache = cache.get(key);
if (currentCache?.timer) {
clearTimeout(currentCache.timer);
}
let timer: Timer | undefined = undefined;
if (cacheTime > -1) {
// if cache out, clear it
timer = setTimeout(() => {
cache.delete(key);
}, cacheTime);
}
cache.set(key, {
...cachedData,
timer,
});
};
const getCache = (key: CachedKey) => {
return cache.get(key);
};
const clearCache = (key?: string | string[]) => {
if (key) {
const cacheKeys = Array.isArray(key) ? key : [key];
cacheKeys.forEach((cacheKey) => cache.delete(cacheKey));
} else {
cache.clear();
}
};
通过一个共享 Map
对象保存请求的数据,缓存的时间,在后续发起请求时,如果数据有效,返回并终止请求,如果数据无效,返回缓存的数据并请求新的数据。同时由于是使用 Map
缓存,这意味着只要能保持引用稳定,可以使用任意 js 对象作为 key。核心逻辑如下:
return {
onBefore: (params) => {
const cacheData = _getCache(cacheKey, params);
if (!cacheData || !Object.hasOwnProperty.call(cacheData, 'data')) {
return {};
}
// If the data is fresh, stop request
if (staleTime === -1 || new Date().getTime() - cacheData.time <= staleTime) {
return {
loading: false,
data: cacheData?.data,
error: undefined,
returnNow: true,
};
} else {
// If the data is stale, return data, and request continue
return {
data: cacheData?.data,
error: undefined,
};
}
},
onRequest: (service, args) => {
let servicePromise = getCachePromise(cacheKey);
// If has servicePromise, and is not trigger by self, then use it
if (servicePromise && servicePromise !== currentPromiseRef.current) {
return { servicePromise };
}
servicePromise = service(...args);
currentPromiseRef.current = servicePromise;
setCachePromise(cacheKey, servicePromise);
return { servicePromise };
},
onSuccess: (data, params) => {
if (cacheKey) {
// cancel subscribe, avoid trgger self
unSubscribeRef.current?.();
_setCache(cacheKey, {
data,
params,
time: new Date().getTime(),
});
// resubscribe
unSubscribeRef.current = subscribe(cacheKey, (d) => {
fetchInstance.setState({ data: d });
});
}
},
onMutate: (data) => {
if (cacheKey) {
// cancel subscribe, avoid trigger self
unSubscribeRef.current?.();
_setCache(cacheKey, {
data,
params: fetchInstance.state.params,
time: new Date().getTime(),
});
// resubscribe
unSubscribeRef.current = subscribe(cacheKey, (d) => {
fetchInstance.setState({ data: d });
});
}
},
};
其他的插件也很简单,比如 防抖 和 节流 就是通过 lodash 对请求进行处理。
总结
学习了 useRequest
的使用及实现方式。 useRequest
通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能,而且提供了自定义的方法,如果已有的功能不能满足需求,只需要一个插件即可。