React 异步场景解惑
背景
相信大家知道 React 提出的一个核心理念: fn(state) = UI,只要输入是确定的,输出就是确定的。React 把函数式编程的思想带进了前端中。它解决了当时前端开发复杂化的痛点,这毫无疑问是革命性的,React 也因此而名声大噪。
但在 fn(state) = UI 中的组件(即 fn) 只能执行同步的代码,没有异步的概念,React 中不存在这样的公式: async fn(state) = UI ,这就给异步编程带来些许不便。
异步时序问题
在 React 中,我们请求数据最基本的方法是在组件第一次装载时使用 useEffect 和浏览器的 fetch api,如下:
function useRequest(queryFn, deps) {
const [data, setData] = React.useState(); // 定义一个state用于存储数据
const [isLoading, setIsLoading] = React.useState(false); // 定义一个state用于表示是否正在加载中
const run = () => {
// ...
queryFn(...args)
.then((data) => {
setData(data);
})
.catch(() => {
setData(undefined);
})
.finally(() => {
setIsLoading(false);
});
};
React.useEffect(() => {
run();
}, deps);
return { data, isLoading, run };
}
上述代码运行效果如下:
在上述的场景中,我们常常会碰到的一个问题,当我们频繁修改参数时,查询结果显示抖动,且由于 run 方法 是一个异步函数,它执行结束的时间是不可控的。
如果 useEffect 依赖的 deps 在短时间内频繁变化时或频繁手动调用 run 方法时,很容易出现请求的竞态问题,即我们不能保证 data 是哪个 deps 请求的结果。
我们常常用到的库例如 redux、useRequest 都存在着这个竞态问题。
这在一些前端计算场景,尤其是涉及到和钱相关的,这一点特别需要注意。 我们大多数项目都存在这个场景,特别是一些涉及表单筛选查询场景尤为明显 😂。
让我们试着解决
有的小伙伴可能就想到了,当依赖(deps) 变化时取消之前的请求,只当最后一个的请求才生效不就好了么?我们将上面代码改动如下:
// 将请求函数包装为可取消的Promise对象
const makeCancelable = (promise) => {...}
function useRequest(queryFn, deps) {
// ...
const run = () => {
const wrappedPromise = makeCancelable(queryFn()) // 使得Promise可以被取消
wrappedPromise.then().catch().finally()
return wrappedPromise.cancel
}
React.useEffect(() => {
return run()
}, deps)
return { data, isLoading, run }
}
但还存在着一个问题,那就是当我们直接调用 run 方法时还是会导致上述的时序问题。
所以我们需要将 run 方法替换成纯的、无副作用的 refetch(不带参数且不会造成竞态) 方法。
但此时 useRequest 只剩下查询能力不涉及增删改等包含突变的能力,所以我们将 useRequest 重命名为更符合语义的名字 useQuery :
function useQuery(queryFn, deps) {
// ...
// 将run方法替换为不带参数且不会造成竞态的refetch方法
const refetch = () => {
if (!isLoading) {
run();
}
};
return { data, isLoading, refetch };
}
现在,我们已经可以做到 setData 只会被一个 run 函数调用。流程图如下:
上述代码运行效果如下:
在一些简单场景,这样做是可行的,但是当需要缓存数据和去除重复请求时就变得越来越困难。
例如当参数一致时,当我们点击重试按钮,所有具有相同参数的组件都应当重新查询,而且目前多个组件之间数据是不共享的。
下面我们来讲一下如何进行缓存和去重。
缓存和去重
经过我们修改,已经可以通过 deps(由于缓存需要将 deps 作为 key,下面已将 deps 重命名为 queryKey) 感知到 queryFn 查询函数的参数变化。
接下来利用我们前面提到的函数式编程思想,当输入一致时,返回之前的缓存。
我们可以利用传入的 参数(queryKey) 作为缓存 key,如果重复传入相同的 queryKey 时可以从缓存中取出之前的数据,相关实现如下,让我们先来看看代码吧:
const queries = {}; // 存储所有的查询实例
function getQuery(queryKey, queryFn) {
const hash = computeHash(queryKey);
// 当 queryKey 一致时,返回已经生成过的查询实例
if (queries[hash]) return queries[hash];
queries[hash] = {
isLoading: false,
data: undefined,
// 存放着当前queryKey所有注册过的监听函数。
listeners: new Set(),
refetch() {
// ...
},
};
return queries[hash];
}
function useQuery(queryKey, queryFn) {
const [, forceUpdate] = React.useReducer((num) => num + 1, 0);
// 调用 getQuery 根据 queryKey 获取当前查询实例
const currentQuery = getQuery(queryKey, queryFn);
React.useEffect(() => {
currentQuery.refetch();
currentQuery.listeners.add(forceUpdate); // 注册监听函数
return () => currentQuery.listeners.delete(forceUpdate); // 删除监听函数
}, [currentQuery]);
return {
data: currentQuery.data,
isLoading: currentQuery.isLoading,
refetch: currentQuery.refetch,
};
}
我们要明确一点,如果我们想要发起另一个请求,只允许改变传入的 queryKey 到 useQuery 中,这样我们才能感知到输入的变化。
下面我们可以看看上述代码的实际运行效果:
- 去重: 当参数一致时,去除重复查询,可以看到点击下面重试按钮,参数相同的组件只有一个请求。
- 缓存: 当参数一致时,所有具有相同参数的组件都会都会复用一份缓存。
- 无副作用: 不管我们点击修改参数多么频繁,实际查询的结果都是当前参数的结果。
现在我们在 react 中可以做到查询之间互不影响且没有交集,只要参数(queryKey)一致结果(query)就一致的纯函数了:
请求库选型
当然,上面只是一个简单的实现,如果你要在开发时避免上述的这些问题,在这里给大家推荐 ReactQuery 和 SWR 这两个 React 请求库,可以简化我们从服务器获取、缓存和同步数据方式。
上述示例代码,其实就是我们对 ReactQuery、SWR 的底层逻辑的实现。这两个库除了解决这些问题之外还考虑到了几乎所有的场景,而且做得非常好,甚至可以消除你对全局状态管理解决方案的需求。
题外话
在 React RFC RFC: First class support for promises and async/await 中介绍了一个新的 api use,主要是着手解决之前说的异步组件问题 async fn(state) = UI,感兴趣的话了解一下。
转载自:https://juejin.cn/post/7225885023822463031