React 18 四:Suspense 与异步渲染

Suspense 的 API 只有一个 fallback 属性,难的是什么组件才真的能触发它。这一篇讲组件抛出 thenable 之后 React 走的那条路 —— 从 throwException 到 retryTimedOutBoundary,以及为什么重试是重新渲染而不是接着渲染。

位置
第 04 篇 / 共 6 篇
预计
6 分钟
系列
React 18

<Suspense> 只有一个属性,看上去没什么可讲的:

Suspense 的全部 API
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>

难的地方在另一头:什么样的 <Comments /> 才会真的让 fallback 显示出来。「照着文档写了,但 fallback 从来不出现」和「一进页面就疯狂发请求」这两个问题,根都在这里 —— React 18 里能触发 Suspense 的数据源,比大多数人以为的少得多。

触发 Suspense 的不是 async,是抛出去的那个 thenable

组件不是通过返回 Promise 来「暂停」的。它是在渲染期间 throw 一个 thenable(任何带 .then 方法的对象),React 在渲染流程里接住这个异常,往上找最近的 <Suspense> 边界,改渲染它的 fallback

这条规则有几个直接推论:

  • 组件函数本身不能是 async。React 18 的客户端渲染不认识异步组件函数。
  • useEffect 里发请求、用 useState 存结果,永远不会触发 Suspense。effect 是 commit 之后才跑的,那时渲染早就结束了,没有异常可抛。
  • 在事件处理器里取数据同样不会触发。

第二条是最常见的误会。React 18 的文档把能用的数据源列得很短:React.lazy 的代码分割,以及 Relay、Next.js 这类自己接好了 Suspense 的框架。文档同时写明,不依赖这类框架去自己实现一个 Suspense 数据源,在 18 里还不受支持 —— 要求既不稳定也没有文档。

所以本篇的实际结论要先说在前面:在 React 18 上,能放心用的 Suspense 场景是代码分割;数据请求那一半要么交给框架,要么接受自己走在没有文档的路上。

React.lazy 是唯一开箱即用的那个

lazy 必须写在组件外面
import { lazy, Suspense, useState } from 'react';
// 写在模块顶层:整个应用生命周期里只创建一次
const Chart = lazy(() => import('./Chart'));
export default function Panel() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>打开图表</button>
{open && (
<Suspense fallback={<div>图表加载中…</div>}>
<Chart />
</Suspense>
)}
</>
);
}

打开 Network 面板点那个按钮:Chart 所在的 chunk 是点击之后才开始下载的,下载期间「图表加载中…」出现在按钮下面。这是 Suspense 在 React 18 上最可靠的一次亮相。

lazy() 做的事很薄:它返回一个带状态的对象,第一次渲染到它时调用你给的那个函数,把拿到的 Promise 存下来并抛出去;Promise 兑现之后把模块记在自己身上,再渲染就直接返回真正的组件。

那行 const Chart = lazy(...) 挪进组件里,整件事就会坏掉。 每次渲染都会造出一个新的 lazy 对象,React 眼里那是一个全新的组件类型,于是卸载旧的、挂载新的、重新下载、重新抛出 —— 页面卡在 fallback 上反复闪。

fallback 出现之后,React 靠什么把你叫回来

抛出的 thenable 被 throwException 接住,它在 packages/react-reconciler/src/ReactFiberThrow.new.js。这个函数同时处理两种抛出物:thenable 交给 Suspense,真正的 Error 交给错误边界。

接住之后要解决一个问题:Promise 兑现的时候,谁来通知 React 再试一次。答案是两个函数,分别对应两个时机。

时机 函数 在哪个文件 挂的回调
渲染期就发现挂起 attachPingListener ReactFiberThrow.new.js pingSuspendedRoot
fallback 已经 commit 上屏 attachSuspenseRetryListeners ReactFiberCommitWork.new.js resolveRetryWakeable

两个都是朴素的 wakeable.then(fn, fn) —— 成功失败都要叫醒,因为失败也得让渲染继续走下去,好让错误边界有机会接住。

第二条路的下游值得跟完:resolveRetryWakeable 把这个 wakeable 从边界的 retryCache 里删掉,然后调 retryTimedOutBoundary;后者申请一条 retry lane,markRootUpdated 标记根节点,最后落到 ensureRootIsScheduled —— 也就是第三篇要讲的那个调度入口。

这里有个容易记反的地方:重试不是「接着上次渲染继续」,是重新调度一次渲染。 它从头走 beginWorkupdateSuspenseComponent 重新判断这个边界该显示内容还是 fallback。挂起时那次渲染的中间结果全部作废。

这个结论有实际后果。组件函数会被完整地重跑一遍,所以数据源必须按 key 缓存:第一次调用发请求并抛出 Promise,第二次调用要能直接返回已经拿到的数据。少了这层缓存,重跑时又发一次请求、又抛一次 Promise,就是开头说的那个「一进页面疯狂发请求」。 这不是 React 的 bug,是自己实现数据源时最容易踩空的那一脚 —— 也是官方说「要求不稳定」的具体所指之一。

React Query、SWR 这类库提供的 suspense 开关,内部做的正是「抛 thenable + 按 key 缓存」这两件事。用它们比自己写靠谱,但要清楚这条路在 18 上没有官方保证。

抛错走的是另一条分支

同一个 throwException,抛出物是 Error 时找的是错误边界,不是 Suspense 边界。两者的嵌套顺序因此是有讲究的:

错误边界在外,Suspense 在内
<ErrorBoundary fallback={<p>图表加载失败</p>}>
<Suspense fallback={<div>图表加载中…</div>}>
<Chart />
</Suspense>
</ErrorBoundary>

Chart 的 chunk 下载失败时,lazy 抛出的是一个 Error 而不是 thenable。它会穿过 <Suspense>(Suspense 对 Error 不感兴趣),落到外层的错误边界上。反过来包,网络一断就是白屏。

这里的 ErrorBoundary 不是 React 提供的组件 —— React 没有导出过这个名字。它的最小实现在第二篇里写过:一个实现了 getDerivedStateFromError 的类组件,或者直接用 react-error-boundary

代价

<Suspense> 换来的东西是有账单的:

  • fallback 会闪。 数据在 80ms 内回来时,骨架屏出现一下又消失,比一直空着更晃眼。边界画得越细,闪的次数越多。
  • 重试意味着重跑。 组件函数会被执行多次,渲染期间任何有副作用的写法(改模块级变量、发埋点)都会重复。
  • 它不缩短等待。 Suspense 只决定等待期间屏幕上显示什么,一个请求该花多久还是多久。
  • 自己实现数据源没有官方保证。 这一条在 18 上是硬约束,不是风格问题。

反过来,什么时候不该包:拿得很快的内容不必包,让它跟着父级一起渲染更省事;首屏最重要的那块内容也不该包,那里需要的是尽早稳定,而不是先给一个骨架再换掉。

服务端那一侧,<Suspense> 边界还有第二重身份:它同时是流式 SSR 的分段单位和注水的切口。同一个标签,在浏览器里决定「先显示什么」,在服务端决定「先发送什么」—— 后面那一半是 Selective Hydration 的事。

这篇是 React 18 的第 4 篇。前一篇是 React 18 三:Concurrent Mode,后一篇是 React 18 五:Selective Hydration