React 18: 异步组件

之前的文章提到,React 18 通过约定 throw Promise 的方式支持组件执行异步函数时进行暂停(Suspense)。这个操作我的评价是:当理解了它的原理之后会觉得十分巧妙,但带来了额外的理解成本。

在 React 组件里用 Async/Await 会发生什么

但这个事情为什么要这样做,为什么不直接使用 async/await?

当年 React 团队在 React Server Componet 的 RFC 中是这么说的

Why don’t use just use async/await?

We’d still need a layer on top, for example, to deduplicate fetches between components within a single request. This is why there are wrappers around async APIs. You will be able to write your own. We also want to avoid delays in the case that data is synchronously available — note that async/await uses Promises and incurs an extra tick in these cases.

听着就有点扯淡啊!你在讲什么 1 tick 的 overhead?? 做 deduplicate 也不是不用 async/await 的理由吧!

但其实确实存在一个问题:

众所周知,在 JavaScript 中遇到需要 await 的操作,当前执行会被挂起,直至 Promise resolve (准确的说应该是 settle ,要么 resolve,要么 reject)之后,回到当前上下文继续执行。

Async/Await 和 throw 的区别在于前者是暂停,后者是中断。异步操作完成之后,Await 之后的语句还会被继续执行,而假如使用 throw 则不会。在这个情景下,假如上下文发生变化,那我们自然应该期望整个组件的 Render 重新执行。

在 React 的角度,如果用 Async/Await,遇到组件发生异步操作,就得等异步操作结束之后再舍弃掉这次的结果,再渲染一次。这样做的话第一次渲染时 await 语句后面的逻辑也会被继续执行,这里会造成额外的开销,并且约束组件内逻辑不能有副作用(尽管 React 组件确实本来就不应该有副作用)。要解决这个问题似乎可以从 complier 层面做,在 await 语句之后加入检查点,引入 AbortSignal 之类的机制,听起来就很麻烦。

此外,遇到异步操作后需要等待异步操作完成进行再次渲染(React 称之为 Replay),但只要是 await 必定会发生 暂停 – 下一个 microtask 继续的流程,这里 React 至少要等一个 microtask,第二次渲染没法做到同步,这里有一个额外开销。

总结一下,用 throw 比 Async/Await 至少存在以下两个优势:

  1. 不用再执行 await 语句后的逻辑。
  2. 如有同步的缓存机制,则 Replay 可以同步完成。

还有别的吗?让我们看看 React 官方是怎么说的

(有点长,不引用了,链接戳戳)

由于上面说的 await 必然暂停的问题,重渲染组件时也会发生问题。React 组件在实际使用中经常发生重渲染,而很多重渲染都是不应该再次触发异步操作的,例如无关 props 发生变化。但如果使用 await 就必然会进入异步流程,用 throw 配合同步的缓存机制则不需要,这意味着每次重渲染至少浪费一个 microtask tick!这就比较致命了。而且这个问题还无解,JavaScript 的语法就规定了 await 必然是异步返回的,这也是为什么 React 团队说假如将来支持了在组件中直接用 await 也是会使用编译器编译到特殊的 generator 代码而非直接用 await。

但在 RSC 里用也不是不行

哎你可能又要说了,那我 Server Component 没有这些问题呀,毕竟 Server Componet 只渲染一次,也没有状态。你说得对,所以在新的 RFC 里,React 将会支持在 React Server Componet 中直接使用 Async/Await。

之前,React 团队主要是考虑到服务端和客户端的语法一致性,所以没有支持直接在 RSC 使用 Async/Await 语法,但后续反馈和使用让他们意识到语法一致性的收益并没有那么大。在 RSC 直接用 Async/Await 更加简明直白、容易理解。此外他们还说,两边用不一样的语法可以更好区分代码环境……(呃这个就有点牵强

简化异步组件,做人民群众喜欢的 React

所以我们刚刚讨论了,目前来说在 Client Componet 中,如果有异步操作,还是得用 throw – Suspense 这一套操作。

简单来说就是遇到异步操作时候通过 throw 语法中断运行,React 调度等待异步操作完成后重新渲染(Replay)当前组件。在之前的 React 版本中,我们可以直接把 Promise 扔出去,再把组件用 Suspense 包一包,就能完成这个事情。

听起来并不复杂,但其实【如何把一个 Promise 正确 throw 出去】是一件实现起来容易踩坑的事情。在之前文章中一开头,我写了一个简单的例子演示如何扔这个 Promise,但是这个实现是有问题的:

首先我们需要记录这个 Promise 的状态,所以我引入了一个 isLoaded 标志,这个标志必须在组件外,因为 Suspense 不会保存任何组件状态,包括 Hooks 状态。这样似乎解决了问题,但这个变量单纯放在组件外依然是有问题的,因为会导致组件的 Tearing(例如两个组件用了同一个 Promise,但在两个组件渲染的中间时间 Promise 状态变化)。

哎你肯定要说了,我第三方的封装不就好了。你说得对,所以 React 搞了一个封装,这个新的 Hooks 叫 use 。

(真想吐槽这个命名啊)

use 做的事情就是将 Promise 解包出结果,粗略等价于使用 await,区别就在于它的内部实现是使用了 throw。在内部实现中,新的 use 也并非 throw 一个 Promise,而是 throw 了一个特制的异常,而状态/结果字段则是直接扩展在了 Promise 本身上。

它的表现和之前的 throw Promise 基本上一致,这里就不过多做介绍。

重点看下在组件重渲染的时候, use 如何做到尽量减少不必要的异步操作。

  • 如果传给 use 的 Promise 实例没有发生变化(比如用 useMemo 做了缓存),那么直接通过挂在 Promise 上的信息返回之前的结果。这个过程是同步的。
  • 如果组件 Props/State 发生变化,但变化的是无关入参,例如:
async function fetchTodo(id) {
  const data = await fetchDataFromCache(`/api/todos/${id}`);
  return {contents: data.contents};
}

function Todo({id, isSelected}) {
  const todo = use(fetchTodo(id));
  return (
    <div className={isSelected ? 'selected-todo' : 'normal-todo'}>
      {todo.contents}
    </div>
  );
}

这里的 Promise 应该根据不同 id 返回不同结果。但如果是 isSelected 发生了变化,Promise 应该会返回相同的值。这里最理想的方案自然是将 Promise 包装一层实现一个同步缓存,或者用 useMemo 确保 Promise 不发生变化。但如果缓存只做在了 Promise 内部,那么 React 会等一个 micotask tick,看看 Promise 是否立即返回了,如果立即返回则组件不会进入 Suspend 状态。

这是不是听起来有点耳熟?这就是用 Async/Await 会遇到的问题,只是,现在我们至少可以有方法去避免。

  • 最后,如果真的是 Promise 的入参变了,那么自然组件进入 Suspend 状态,等待 Promise 返回。

缓存,还是得缓存

有没有发现之前的讨论中几乎每句话都要讨论缓存。在现在的 React 中,涉及到异步操作,缓存是绕不开的点。实际上在这样的设计模式中,给 React 编程提出了一些新的要求:

  • 组件幂等。尽管 React 的组件,按道理来说就是应该幂等的,即 f(State) = UI 的概念,对于不变的 State 必然应该渲染出不变的结果。但实际上在之前的时代里,你非要写个不幂等的组件,也,也不是不行,只要别把自己搞成死循环… 但现在,由于组件 Suspend 之后需要 Replay,你要是写个不幂等的玩意,我都不敢想会发生啥。
  • 同步缓存。如果在组件里做了异步的数据获取,由于 React 组件时常重新渲染,最理想的缓存实现应该是对于已经有结果的调用能够做到同步返回,这样可以最大程度减少重渲染的开销。对此,React 后续会推出一个 cache 方法帮助开发者做这个或许可能会有一些 tricky 的事情。React 团队也表示 cacheuse 在正式推出的时候将会捆绑在一起。它可能会长这样:
// A cached function returns the same response for a given set of inputs —
// id, in this example. The `cache` proposal will also have a mechanism to
// invalidate the cache, and to scope it to a particular part of the UI.
const fetchNote = cache(async (id) => {
  const response = await fetch(`/api/notes/${id}`);
  return await response.json();
});

function Note({id}) {
  // The `fetchNote` call returns the same promise every time until a new id
  // is passed, or until the cache is refreshed.
  const note = use(fetchNote(id));
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
    </div>
  );
}

实际上 swr、react-query 等第三方库之前已经都有做了比较完善的缓存和去重,接到 React 这套新东西里应该改动也不大。

碎碎念

  • 最近的一些文章都是在 Notion 里完成的,Notion 的编辑器确实挺舒服的。
  • 这篇文章至少简单探讨了一下去年10月 React 团队搞出来的这篇新的 RFC(我真能拖),如果要详细了解,大家应该看一下 RFC 的原文、爬一下 Issue 的楼。链接在这:https://github.com/reactjs/rfcs/pull/229
  • React 真是越来越复杂了。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注