React 18 四:Suspense 与异步渲染
Suspense 的 API 只有一个 fallback 属性,难的是什么组件才真的能触发它。这一篇讲组件抛出 thenable 之后 React 走的那条路 —— 从 throwException 到 retryTimedOutBoundary,以及为什么重试是重新渲染而不是接着渲染。
<Suspense> 只有一个属性,看上去没什么可讲的:
<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 是唯一开箱即用的那个
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 —— 也就是第三篇要讲的那个调度入口。
这里有个容易记反的地方:重试不是「接着上次渲染继续」,是重新调度一次渲染。 它从头走 beginWork,updateSuspenseComponent 重新判断这个边界该显示内容还是 fallback。挂起时那次渲染的中间结果全部作废。
这个结论有实际后果。组件函数会被完整地重跑一遍,所以数据源必须按 key 缓存:第一次调用发请求并抛出 Promise,第二次调用要能直接返回已经拿到的数据。少了这层缓存,重跑时又发一次请求、又抛一次 Promise,就是开头说的那个「一进页面疯狂发请求」。 这不是 React 的 bug,是自己实现数据源时最容易踩空的那一脚 —— 也是官方说「要求不稳定」的具体所指之一。
React Query、SWR 这类库提供的 suspense 开关,内部做的正是「抛 thenable + 按 key 缓存」这两件事。用它们比自己写靠谱,但要清楚这条路在 18 上没有官方保证。
抛错走的是另一条分支
同一个 throwException,抛出物是 Error 时找的是错误边界,不是 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。