背景
在前后端分离的项目中,我们需要通过异步请求去获取数据,在这个异步请求的过程中需要做特别多的处理,例如:
- 展示loading,表示正在请求数据
- 每个异步操作都需要使用try-catch捕获错误
- 请求错误时,需要处理错误情况
团队中的每个人在处理接口的时候,都需要编写重复的代码。所以这篇文章以循序渐进的方式来讲解如何封装一个强大的hooks,来解决上面的痛点,让开发只需要关注业务逻辑。
目标和收益
useRequest能够提供以下功能:
- 手动请求/自动请求
- 条件请求/依赖请求
- 轮询/防抖/依赖请求
- 分页/加载更多
期望能够实现以下调用方式:
const { loading, run, data } = useRequest(() => {
// 这里写具体的异步请求
}, {
// 一些配置参数
});
复制代码
在上面代码中看到,useRequest可以传入两个参数,第一个参数是函数类型这里叫requestFn,用于异步请求,第二个参数是一些扩展的参数这里称为options,结果返回一个对象
参数 | 说明 | 类型 |
---|---|---|
loading | requestFn 是否正在执行 | boolean |
data | requestFn 返回的数据,默认为 undefined | any |
run | 执行 requestFn | Function |
error | requestFn 抛出的异常,默认为 undefined | undefined / Error |
第二个参数用于一些扩展功能,下面约定一下参数:
标题 | 说明 | 类型 | 默认值 |
---|---|---|---|
auto | 初始化自动执行requestFn | boolean | false |
onSuccess | requestFn resolve触发 | Function | - |
onError | requestFn reject触发 | Function | - |
cacheKey | 设置了这个值以后,会启动缓存机制 | string | - |
技术方案
在编写hooks的过程中,我们通过循序渐进的方式逐步对hooks进行扩展,实现一个功能强大的异步请求状态管理库
基本封装
不考虑第二个可选参数,只考虑传入requestFn的情况
实现基本框架
function useRequest<D, P extends any[]>(requestFn: RequestFn<D, P>, options: BaseOptions<D, P> = {}) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<D>();
const [err, setErr] = useState();
const run = useCallback(async (...params: P) => {
setLoading(true);
let res;
try {
res = await requestFn(...params);
setData(res);
} catch (error) {
setErr(error);
}
setLoading(false);
return res;
}, []);
return {
loading,
data,
err,
run,
};
}
复制代码
上述代码就能够实现基本的异步管理
- 当异步请求状态更改时,loading能够及时更新
- 当请求抛出错误的时候,err也能及时更新
- 能够决定什么时候执行异步函数
上面代码已经能够实现最基础的能力,在此之上,下面扩展自动执行的能力
还是使用useEffect监听auto值的变化
const { defaultParams } = options
useEffect(() => {
if (auto) {
run(...(defaultParams as P));
}
}, [auto]);
复制代码
当auto为true的时候,直接运行异步函数。实现上述功能以后,我们就可以像以下方式调用:
function generateName(): Promise<string> {
return new Promise(resolve => {
setTimeout(() => {
resolve('name');
}, 5000);
});
}
function Index() {
const { run, data, loading, err } = useRequest(async () => await generateName());
useEffect(() => {
run();
}, []);
console.log(data, loading);
console.log(err);
return (
<div style={{ fontSize: 14 }}>
<p>data: {data}</p>
<p>loading: {String(loading)}</p>
</div>
);
}
复制代码
当调用run的时候,异步接口被调用, useRequest返回的值都能够及时更新
缓存
如何对请求进行缓存这是面试中常提到的问题。我们期望实现这样的功能
-
首次请求后能够缓存结果
-
再次请求的时候,能够从缓存中获取
-
并且在读取缓存的时候,能够自动发起请求拉取最新的资源来更新缓存
首先我们需要思考缓存系统的设计,缓存系统需要满足以下特点
-
能够添加缓存
-
能够缓存过期时间
-
删除缓存
这里我们使用Map的方式来进行缓存,Map是一组键值对的结构,具有极快的查找速度,借助get、set API完成对数据的存储
class BaseCache {
protected value: Map<CachedKeyType, T>
constructor() {
this.value = new Map<CachedKeyType, T>();
}
public getValue = (key: CachedKeyType): T => {
return this.value.get(key )
}
public setValue = (key: CachedKeyType, data: T): void => {
this.setValue(key, data)
}
public remove = (key: CachedKeyType) => {
this.value.delete(key)
}
}
复制代码
在此基础上再加上过期时间的逻辑,setCache以后超过过期时间后,自动删除缓存中的结果,更改如下:
const setCache = (key: CachedKeyType, cacheTime: number, data: any) => {
const currentCache = cache.getValue(key);
if (currentCache?.timer) {
clearTimeout(currentCache.timer);
}
let timer: Timer | undefined = undefined;
if (cacheTime > -1) {
// 数据在不活跃 cacheTime 后,删除掉
timer = setTimeout(() => {
cache.remove(key);
}, cacheTime);
}
const value = {
data,
timer,
startTime: new Date().getTime(),
}
cache.setValue(key, value);
};
复制代码
上述代码中,当设置值的时候,首先判断定时器是否存在,如果存在则取消定时器,释放内存。如果当前设置缓存时间后,需要加个定时器,时间结束后及时删除当前缓存。最后存储在Cache里边有个两个部分
- data: 请求的相关信息
- timer: 定时器ID
如果要实现获取的时候直接从缓存中获取,并且在后台自动执行请求接口,那我们就需要缓存两个部分:
- 请求结果
- 请求相关参数
所以我们需要先构造一个请求对象,这个对象包含了以下功能:
- 提供主动调用异步函数的功能
- 保存请求结果
- 保存请求参数
- 能够提供一个钩子,用于保存值发生变化的时候触发
具体代码如下:
class Request<D, P extends any[]> {
that: any = this;
options: BaseOptions<D, P>;
requestFn: BaseRequestFnType<D, P>;
state: RequestsStateType<D, P> = {
loading: false,
run: this.run.bind(this.that),
data: undefined,
params: [] as any,
changeData: this.changeData.bind(this.that),
};
constructor(requestFn: BaseRequestFnType<D, P>, options: BaseOptions<D, P>, changeState: (data: RequestsStateType<D, P>) => void) {
this.options = options;
this.requestFn = requestFn;
this.changeState = this.changState;
}
async run(...params: P) {
this.setState({
loading: true,
params,
});
let res;
try {
res = await this.requestFn(...params);
this.setState({
data: res,
error: undefined,
loading: false,
});
if (this.options.onSuccess) {
this.options.onSuccess(res);
}
return res;
} catch (error) {
this.setState({
data: undefined,
error,
loading: false,
});
if (this.options.onError) {
this.options.onError(error);
}
return error;
}
}
setState(s = {} as Partial<BaseReturnValue<D, P>>) {
this.state = {
...this.state,
...s,
};
if (this.onChangeState) {
this.onChangeState(this.state);
}
}
changeData(data: D) {
this.setState({
data,
});
}
}
复制代码
这个对象保存了所有的请求相关的信息,所以在之前useRequest就需要更改,所有状态都需要从Request对象中获取,所以更改之前实现的useRequest。当我们调用接口的时候,我们先会尝试从cache中获取数据,如果cache存在,那么直接返回cache中的数据
const { cacheKey } = options
const cacheKeyRef = useRef(cacheKey)
cacheKeyRef.current = cacheKey
const [requests, setRequests] = useState<RequestsStateType<D, P> | null>(() => {
if (cacheKey && cacheKeyRef.current) {
return getCache(cacheKeyRef.current);
}
return null;
});
复制代码
初始值我们通过cacheKey直接从缓存中获取,如果存在,则返回缓存中的内容
此外我们也需要去设置缓存的值
useUpdateEffect(() => {
if (cacheKeyRef.current) {
setCache(cacheKeyRef.current, cacheTime, requests);
}
}, [requests]);
复制代码
监听requests值的变化,如果发生变化,那么就更新缓存中的值,对外提供的run函数也需要更改,在执行run的时候,我们需要区分缓存中是否存在值,如果存在使用缓存,如果不存在,就需要新建一个Request值,来保存相关数据,更改后的代码如下:
const run = useCallback(async (...params: P) => {
let currentRequest;
if (cacheKeyRef.current) {
currentRequest = getCache(cacheKeyRef.current);
}
if (!currentRequest) {
const requestState = new Request(requestFn, options, onChangeState).state;
setRequests(requestState);
return requestState.run(...params);
}
return currentRequest.run();
}, []);
复制代码
最后就是返回结果,如果有缓存内容,我们就需要使用缓存数据
if (requests) {
return requests;
} else {
return {
loading: auto,
data: initData,
error: undefined,
run,
};
}
复制代码
尝试一下调用useRequest的值,发现requests的值始终不对, 始终都是Request对象中的初始值,这里是因为当异步函数调用的时候,我们没有及时更改requests值的内容,所以在写一个函数用于更改requests的内容
const onChangeState = useCallback((data: RequestsStateType<D, P>) => {
setRequests(data);
}, []);
复制代码
把上述代码组合起来就能够对结果进行缓存了,下面写一个demo观察现象
function generateName(): Promise<number> {
return new Promise(resolve => {
setTimeout(() => {
resolve(Math.random());
}, 1000);
});
}
function Article() {
const { data } = useRequest(generateName, {
cacheKey: 'generateName',
auto: true,
});
return <p>123{data}</p>;
}
function Index() {
const { run, data, loading } = useRequest(generateName, {
cacheKey: 'generateName',
});
const [bool, setBool] = useState(false);
useEffect(() => {
run();
}, []);
return (
<div style={{ fontSize: 14 }}>
<p>data: {data}</p>
<p>loading: {String(loading)}</p>
<button onClick={() => setBool(!bool)} type="button">
repeat
</button>
{bool && <Article />}
</div>
);
}
复制代码
Article和Index组件都调用了相同的接口,并且cachekey也是相同的,当Article能够马上获取到run函数第一次执行的值
首次运行的值是0.92,然后Article接口调用的时候获取到的值也是0.92,并且能够在后台去更新这个值,当下次再渲染Article组件的时候,值就更新为最新的
加载更多
在此基础上我们期望useRequest能够扩展加载更多的功能,需要满足以下的功能
- 点击加载更多
- 下拉加载更多
由于实际业务中数据太多没办法完全覆盖,所以这里只讲请求需要满足以下条件的情况:
请求body
- pageSize: 请求每页树
- offset: 偏移量
返回结构
- list: 数组内容
- total:返回一共多少条
复制代码
下面讲讲具体的实现
const { initPageSize = 10, ref, threshold = 100, ...restOptions } = options;
const [list, setList] = useState<LoadMoreItemType<D>[]>([]);
const [loadingMore, setLoadingMore] = useState(false);
const result = useRequest(requestFn, {
...restOptions,
onSuccess: res => {
setLoadingMore(false);
console.log('onSuccess', res.list);
setList(prev => prev.concat(res.list));
if (options.onSuccess) {
options.onSuccess(res);
}
},
onError: (...params) => {
setLoadingMore(false);
if (options.onError) {
options.onError(...params);
}
},
});
const { data, run, loading } = result;
复制代码
用list变量保存已经加载的变量,loadingMore表示是否正在加载,在用之前提供的useRequest进行异步请求,需要注意的是需要对onSuccess和onError进行二次封装,用于更改当前的数据状态
当ref传入时,就代表需要在一个容器里边做滑动到底部然后加载更多的操作
useEffect(() => {
if (!ref || !ref.current) {
return noop;
}
const handleScroll = () => {
if (!ref || !ref.current) {
return;
}
if (ref.current.scrollHeight - ref.current.scrollTop <= ref.current.clientHeight + threshold) {
loadMore();
}
};
ref.current.addEventListener('scroll', handleScroll);
return () => {
if (ref && ref.current) {
ref.current.removeEventListener('scroll', handleScroll);
}
};
}, [ref && ref.current, loadMore]);
复制代码
loadMore的代码基本就是将当前list的长度作为offset传入到之前useRequest提供的run当中
const loadMore = useCallback(
(customObj = {}) => {
console.log(noMore, loading, loadingMore, 'customObj');
if (noMore || loading || loadingMore) {
return;
}
setLoadingMore(true);
run({
current,
pageSize: initPageSize,
offset: list.length,
...customObj,
});
},
[noMore, loading, loadingMore, current, run, data, list]
);
复制代码
这样加载更多,到底部的时候能够加载自动加载更多的功能就实现了,这里的示例就不在写了
总结
提供一个封装好的useRequest能够省去业务的大量重复代码,上面总体的实现借鉴于蚂蚁的开源hooks库,一些不够常用的功能没有一一的讲解,事实上我们还可以根据业务自己去封装一些通用逻辑,例如日志上报,通用的错误处理等等