useRequest 是什么?
大概介绍下 useRequest 是什么,已经熟悉了解的小伙伴可以略过直接看下文。 通常在开发 react 项目时,我们会引入一个状态管理库来处理项目中的各种状态数据。其中当然也会糅合很多来自服务端的数据。而这些服务端数据往往有很多伴生问题需要解决。如:
- 缓存
- 加载状态
- 失败状态
- 分页
- 轮询请求
- ...
这些问题一旦处理不好就会引发很多 Bug。所以 useRequest 就是为了统一解决处理这些问题的库/函数。
看一个最简单的例子: 在 react 里请求数据我们通常会这么做,
TypeScript
const [data, setData] = useState<unknown | null>(null)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<unknown | null>(null)
const [id, setId] = useState(1)
async function request() {
setLoading(true)
try{
const resp = await fetch(`http://localhost:3000/api/user?id=${id}`);
if(resp.ok) {
const data = await resp.json();
setData(data);
} else {
const err = await resp.json();
setError(err);
}
}
catch(err){
setError(err);
}
finally{
setLoading(false)
}
}
如果配合 useRequest 的话,
TypeScript
const {data, loading, error } = useRequest(request);
const [id, setId] = useState(1)
async function request(){
const resp = await fetch(`http://localhost:3000/api/user?id=${id}`);
if(resp.ok) {
return await resp.json();
} else {
throw new Error(await resp.json())
}
}
可以看到,useRequest 将我们最常用的状态进行了封装,不需要在手动进行维护了,大幅减少了所需要编写的代码量,我们都知道,越少的代码 = 越少的 Bug。 除此之外 useRequest 还有许许多多其他方面的能力,通过对 useRequest 源码的探究,我们将学习到其是运用了怎样的架构将这些组合起来的。
除了 useRequest 外,SWR 和 React Query 也是两个用的非常广泛的请求辅助库。
能获得什么?
useRequest 使用了较多的设计模式且践行了 SOLID 原则,了解其实现原理可以提升我们的架构能力,代码掌控力,以及如何运用一些基本软件开发原则和设计模式设计出健壮易用的软件产品。
整体架构图
整体架构还是较为清晰的,引入 useRequest 传入一个请求函数和可选的配置项、插件等。在实现内部,会根据入参配置,决定是否自动执行请求。请求会被分成多个阶段和对应的状态,请求前、请求中、成功、失败、取消、结束。每个阶段又会执行注册在此的插件,所以插件需要实现对应的阶段的函数。
输入/输出
输入,输出都很简单,直接传入一个请求不带其他参数的话,将会在组件加载完成后立即执行,执行完毕就能拿到返回值了,灰常简单。
除此外,useRequest 还接受一个 option 对象作为参数值,option 参数提供了丰富强大的配置。例如不需要组件加载完毕就执行请求,而是手动执行,那么只需要设置 { manual: true }
即可。另外还有一大部分参数 是和内置插件相关的,下文我们用一个插件作为例子来介绍。
useRequest 的实现
useRequest 作为入口文件,实现很简单。主要就是聚合了各个插件文件然后和用户资定义的插件合并,以及将各个参数传入到具体实现中。这么做的好处是如果后期有新的插件引入,直接修改插件聚合处即可,其他地方不用动或者很少动,尽量模块间相互独立。这里可以体现单一职责原则,即一个模块(类)只做一件事。
useRequestImplement 的实现
该函数则是正儿八经开始对传入参数进行处理的地方。
-
首先对入参简单处理后,用了一个
useLatest
对请求函数包装了一层const serviceRef = useLatest(service); // service 就是传入的请求函数
,主要目的就是保持在请求过程状态变更中一直拿到的是最新的service
实例。 -
接着声明了一个
const update = useUpdate();
这里用了一个useUpdate
hook,该 hook 可以返回一个方法,可以强制对组件进行更新,主要用在各个插件中。 -
接着就是重头戏了,
fetch
核心调用部分
TypeScript
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;
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
-
一行行拆解去看,
-
首先用了一个
useCreation
,该 hook 类似useMemo
但是useCreation
可以保证一定不会被重新计算,而useMemo
不能。 -
接着遍历了 plugins 数组,执行每个插件的 onInit 方法进行插件的初始化操作。
-
遍历 plugins 数组,执行插件本身,并将返回值 pluginsImpls 挂载到 fetchInstance 上,这里返回的就是对象数组,每个对象中都包含 onBefore, onRequest, onSuccess 等的函数,供后续请求执行过程中使用。
-
-
然后则是组件装载、卸载阶段了,装载时会判断是否需要手动触发执行,卸载时则会执行 fetch 上的取消方法。
-
最后就是返回各种状态和控制函数了。
Fetch 的实现
我们可以把 Fetch 分为几个阶段来看,
- 首先是请求前阶段,此时会直接执行插件中的 onBefore 方法,如果有某个插件返回了
stopNow
,则立即停止执行,并返回空Promise
,或者返回了returnNow
,则返回带有数据的Promise
。 - 接着就是执行阶段,此时会先调用一下
setState
设置一下状态,setState
方法会对组件进行更新。然后就是调用onRequest
方法,以及正式的发送请求。 - 请求结束后有两种状态,一种是成功,此时会触发
onSuccess
方法调用插件中对应的方法。一种是失败,此时会触发onError
方法调用插件中对应的方法。不管那种状态都会先调用setState
对状态进行更新,以及调用onFinally
方法。 - 在整个调用过程中,特别是请求结束时,插件函数的触发会决定整个流程是否再次执行,亦或是缓存。这样就将相关的控制逻辑,和主流程进行了分隔,保持各个模块的职责明确。
useRetry 的实现
我们以 useRetry
的实现作为范例来看看插件具体是怎么做的。
- 首先,所有插件函数的入参都是两个,一个是
fetch
实例本身,一个是插件所需要的参数对象。可以在Types.ts
文件中找到类型的定义export type Plugin...
- 而在函数体内部需要进行一些必要的初始化工作,如全局参数/方法的构建。这里共有三个参数,
timerRef
,countRef
,triggerByRetry
。并且对入参retryCount
进行了判空操作,若入参没有最大重试次数则说明不合法,立即返回。 - 接着直接返回了一个大对象,对象 key 就是
fetch
生命周期的各个阶段。- 在
onBefore
时会判断当前执行的请求是否是从retry
插件触发的,如果是则继续执行代码,否则将计数器置为0
,随后将triggerByRetry
置为false
。并判断是否有定时器并清空。 - 在
onSuccess
阶段,直接将计数器置为0
。这里其实隐含了retry
操作只会在onError
阶段生效。 - 在
onError
阶段,首先对计数器进行加 1 操作。然后把计数器和最大重试次数进行对比,超过的话则停止执行。否则重新构造一个定时器并把引用赋予timerRef
,定时器结束则会执行fetch.refresh()
重新发送请求。 - 在
onCancel
阶段,则直接将计数器置为0
,以及判断是否有定时器并清空。 至此,useRetry
插件就实现完毕了。
- 在
总结
回过来我们看整体流程可以发现,插件的触发过程其实运用了观察者模式
,当一个对象的状态发生变更时,所有依赖它的对象都得到通知 。另外插件的编写遵循了接口隔离原则
,无论实现怎样变化,接口是不变的不影响其和外部的交互 。最后每个模块相互独立又践行了单一职责原则
,每个模块都尽可能的做一件事,模块间通过参数进行通信。
这套架构思想,层层递进环环相套,又不失简洁,对我们工程架构能力提升有相当大的帮助。