React 异步场景解惑

2,726

背景

相信大家知道 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 };
}

源码链接:codepen.io/liaoliao666…

上述代码运行效果如下:

在上述的场景中,我们常常会碰到的一个问题,当我们频繁修改参数时,查询结果显示抖动,且由于 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 };
}

源码链接:codepen.io/liaoliao666…

现在,我们已经可以做到 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,
  };
}

源码链接:codepen.io/liaoliao666…

我们要明确一点,如果我们想要发起另一个请求,只允许改变传入的 queryKey 到 useQuery 中,这样我们才能感知到输入的变化。

下面我们可以看看上述代码的实际运行效果:

  1. 去重: 当参数一致时,去除重复查询,可以看到点击下面重试按钮,参数相同的组件只有一个请求。
  2. 缓存: 当参数一致时,所有具有相同参数的组件都会都会复用一份缓存。
  3. 无副作用: 不管我们点击修改参数多么频繁,实际查询的结果都是当前参数的结果。

现在我们在 react 中可以做到查询之间互不影响且没有交集,只要参数(queryKey)一致结果(query)就一致的纯函数了:

请求库选型

当然,上面只是一个简单的实现,如果你要在开发时避免上述的这些问题,在这里给大家推荐 ReactQuerySWR 这两个 React 请求库,可以简化我们从服务器获取、缓存和同步数据方式。

上述示例代码,其实就是我们对 ReactQuery、SWR 的底层逻辑的实现。这两个库除了解决这些问题之外还考虑到了几乎所有的场景,而且做得非常好,甚至可以消除你对全局状态管理解决方案的需求。

题外话

在 React RFC RFC: First class support for promises and async/await 中介绍了一个新的 api use,主要是着手解决之前说的异步组件问题 async fn(state) = UI,感兴趣的话了解一下。