React 19.3.1 源码深度剖析-第 9 章(上)Suspense 核心机制
本篇是《第 9 章 Suspense 与懒加载》的上半部分,聚焦 Suspense 的核心机制:动机、wakeable/thenable 抽象、「抛出一个 Promise」的完整链路、SuspenseComponent Fiber 结构、边界语义。
下半部分(第 09 章 B)覆盖
lazy()、retry 队列与 ping、commit 阶段处理、hydration 中的 Suspense、SuspenseList / Offscreen / Activity、React 19 新变化以及边界坑与最佳实践。阅读前置:第 3 章(Fiber)、第 8 章(并发特性)。
系列导航:第 1 章=总览、第 2 章=元素/JSX、第 3 章=Fiber 架构与双缓冲、第 4 章=Reconciliation 协调算法、第 5 章=Scheduler、第 6 章=ReactDOM 渲染器与 Commit、第 7 章=Hooks、第 8 章=并发特性、第 9 章=本章、第 10 章=事件系统、第 11 章=编译器、第 13 章=React19 新特性。
目录
- 9.0 本章导读
- 9.1 动机:为什么需要 Suspense
- 9.2 wakeable / thenable 抽象:React 为什么不直接用 Promise
- 9.3 “抛出一个 Promise”的完整链路
- 9.3.7 标志如何沿树上传:bubbleProperties 与 subtreeFlags
- 9.4 SuspenseComponent Fiber 的结构
- 9.4.5 completeWork 中的 SuspenseComponent:第二遍渲染与 Visibility 调度
- 9.5 边界语义:fallback 何时出现、何时退场
- 9.6 lazy() 的实现:惰性加载与 thenable 状态缓存
- 9.7 retry 队列与 ping:数据就绪后如何“复活”边界
- 9.8 Suspense 在 commit 阶段
- 9.9 hydration 中的 Suspense:选择性水合与分块水合
- 9.9.5 水合游标与优先级阶梯
- 9.9.6 服务端一侧:Fizz 如何切块(简述)
- 9.10 SuspenseList:批量协调多个边界
- 9.11 Offscreen / Activity:后台渲染与树隐藏
- 9.12 React 19 的变化:use()、suspensey commit、preinit/preload
- 9.13 边界与坑:闪烁、竞态、错误边界分工
- 9.13.8 key 与边界的交互
- 9.13.9 fallback 与 View Transition 的叠加
- 9.13.10 隐藏子树里的表单与受控输入
- 9.13.11 fallback 里的组件也会跑 effect
- 9.14 性能影响量化
- 9.15 用法与最佳实践
- 9.16 源码索引表
- 9.17 自测 FAQ
- 9.18 本章要点小结
9.0 本章导读
如果说第 8 章讲的并发特性(transition、lane、时间切片)是 React 的“发动机”,那么 Suspense 就是这台发动机上最精密的“变速箱”:它决定了当数据还没到位时,用户的屏幕上演哪一出——是显示旧的界面、显示一个骨架屏、还是整个挂起等待。
Suspense 在 React 源码中不是一个单独的模块,而是一张横跨多个文件的协作网络:
- 定义“什么算可等待”:
packages/react-reconciler/src/ReactFiberThenable.js中的 thenable 状态机; - 定义“谁来接住这次挂起”:
packages/react-reconciler/src/ReactFiberSuspenseContext.js中的suspenseHandlerStackCursor与shellBoundary; - 执行“接住”动作:
packages/react-reconciler/src/ReactFiberThrow.js中的throwException; - 定义边界 Fiber 长什么样:
packages/react-reconciler/src/ReactFiberBeginWork.js中的updateSuspenseComponent与一堆mountSuspense*/updateSuspense*辅助函数; - 定义“数据好了之后怎么办”:
packages/react-reconciler/src/ReactFiberWorkLoop.js中的attachPingListener、pingSuspendedRoot、retryTimedOutBoundary; - 定义“DOM 层怎么动”:
packages/react-reconciler/src/ReactFiberCommitWork.js中的 mutation/layout 遍历; - 定义“代码怎么懒加载”:
packages/react/src/ReactLazy.js。
本章会把这七条线全部打通。读完你应该能回答这样几个问题:
- 组件里
throw promise(或use(promise))之后,CPU 里究竟发生了什么,直到屏幕上出现骨架屏? - Promise resolve 之后,React 怎么知道该重新渲染哪个边界?为什么叫“ping”而不是“回调”?
- 为什么 React 18 之后,transition 中切换到新页面不再闪一下骨架屏?源码里是哪几行实现的?
<Suspense>在 Fiber 树上到底是几个节点?fallback 和主内容是什么关系?- SSR + hydration 场景下,一个边界可以“部分水合”是怎么回事?
所有结论都会落到 react-19.3.1 仓库的真实源码上。版本为 React 19.3.1。
9.1 动机:为什么需要 Suspense
9.1.1 命令式加载态的三个顽疾
在 Suspense 出现之前(以及今天很多没有使用 Suspense 的代码里),异步 UI 的标准写法是“命令式加载态”:
function ProfilePage({ id }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchProfile(id).then(
d => {
setData(d);
setLoading(false);
},
e => {
setError(e);
setLoading(false);
},
);
}, [id]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <Profile data={data} />;
}
这段代码有三个结构性问题,而且它们不是“写得不够优雅”的问题,是模式本身的问题:
顽疾一:加载态与数据耦合,导致“瀑布”与竞态。 每个组件各自管理 loading,意味着组件树的每一层都要等上一层把数据拿完、渲染完、子组件的 useEffect 触发,才能开始自己的请求。这就是经典的请求瀑布(request waterfall)。竞态则更隐蔽:快速切换 id 时,旧请求的 then 可能后于新请求到达,setData 把旧数据写进新状态——上面代码连 ignore 标志都没有,是一个真实的 bug。
顽疾二:“加载中”这个 UI 状态无法统一协调。 页面上有五个组件各自转圈,用户看到的是五个 spinner 乱闪;或者反过来,一个高层组件为了“统一加载态”,不得不把所有子组件的数据请求上移到自己身上,把 props 层层传下去——为了 UX 牺牲了组件的内聚性。
顽疾三:加载态会“闪”。 数据 50ms 就返回时,显示一个只出现 50ms 的 spinner,体验上比多等 200ms 显示完整内容更差(感知上闪烁比等待更难受)。但命令式代码里,组件作者要在每个组件里各自实现“延迟显示 loading”的防抖逻辑,几乎没人真的做。
9.1.2 Suspense 的核心思想:把“加载中”变成边界属性
Suspense 的思路是:加载态不该由数据消费方(子组件)拥有,而应该由边界(<Suspense>)拥有。
<Suspense fallback={<Spinner />}>
<ProfilePage id={id} />
</Suspense>
ProfilePage 内部只关心“有数据时怎么渲染”,没数据时它要做的唯一一件事情是:抛出一个 Promise(或通过 use() 读一个未就绪的 thenable)。谁接住这个 Promise?最近的那个 <Suspense> 边界。接住之后发生什么?边界切换成“显示 fallback”模式;数据就绪之后再切回来。
这个设计带来三个直接好处:
- 一个边界统一一片子树的加载态:子树里十个组件挂起,只显示一个 fallback,不会十个小圈乱闪;
- 消除请求瀑布:数据请求的发起方不再受“父组件先渲染”约束(配合 React 19 的 RSC 或 Relay 这类 Suspense 兼容库,请求可以在渲染前并行发起);
- 加载态的显示/隐藏时机可以被 React 统一节流:
FALLBACK_THROTTLE_MS(300ms,见ReactFiberWorkLoop.js:528)就是干这个的——后面会逐行分析。
9.1.3 历史演进:从 lazy 到数据挂起
Suspense 在 React 中的演进大致是四个阶段,理解这条线对理解今天源码里的各种“历史包袱”至关重要(源码里大量 disableLegacyMode、SuspendedOnDeprecatedThrowPromise 之类的分支都是这条历史的沉积层):
| 阶段 | 版本 | 能力 | 源码痕迹 |
|---|---|---|---|
| 代码分割 | React 16.6 (2018) | React.lazy() + <Suspense>,只支持“抛 thenable 触发 fallback” | ReactLazy.js、SuspendedOnDeprecatedThrowPromise |
| 并发模式试验 | React 16.13 ~ 17 | experimental 的 useTransition、Suspense “保持旧界面”行为 | RootSuspendedWithDelay、renderDidSuspendDelayIfPossible |
| 并发渲染正式可用 | React 18 (2022) | transition 中挂起不显示 fallback 而保留旧内容;选择性水合 | shellBoundary、SuspendedOnHydration、SelectiveHydrationException |
| use() 与资源挂起 | React 19 (2024) | use(promise) 正式 API;suspensey commit(图片/样式表挂起提交);<Activity> | ReactFiberThenable.js 的 SuspenseException、MaySuspendCommit、ActivityComponent |
注意一个关键事实:React 19.3.1 的源码里,Suspense 的“render 阶段挂起”和“commit 阶段挂起”是两套并行的机制。前者是“抛 thenable → 显示 fallback”,后者是“等图片/样式表加载完再 commit”(suspensey commit)。本章 9.12 节会详细拆解后者。
9.1.4 一个最小可复现的心智模型
先用一句话概括本章主角的行为:
<Suspense>是一个 try/catch:children 渲染时如果抛出 thenable,就改用 fallback 渲染;thenable resolve 时,重新渲染 children。
再用一张图概括它在 Fiber 世界里的位置(详见 9.4 节):
flowchart TB
A[""] --> B["SuspenseComponent Fiber<br/>memoizedState = SUSPENDED_MARKER 或 null"]
B --> C["primaryChildFragment<br/>Offscreen Fiber, mode=hidden/visible<br/>children 真正渲染在这里"]
B --> D["fallbackChildFragment<br/>普通 Fiber, 渲染 fallback"]
C --> E["用户组件树"]
D --> F["fallback UI"]
接下来的章节,就是把这句话和这张图逐行展开。
9.2 wakeable / thenable 抽象:React 为什么不直接用 Promise
9.2.1 “有 then 方法的对象”就是 wakeable
React 内部从来不要求你传入原生 Promise。它要求的只是一个** wakeable(可唤醒对象)**:任何拥有 then 方法的对象。这个判断散落在几处,措辞完全一致:
ReactFiberThrow.js:382:if (typeof value.then === 'function')—— “This is a wakeable. The component suspended.”ReactFiberWorkLoop.js:2356-2359:const isWakeable = thrownValue !== null && typeof thrownValue === 'object' && typeof thrownValue.then === 'function';ReactFiberHooks.js:1163:if (typeof usable.then === 'function')—— use() 的 thenable 分支。
为什么不用 Promise?三个原因:
第一,同步可检测性。 React 需要在渲染过程中同步地知道“这个 thenable 现在到底好了没有”,以便决定“立即重试”还是“先显示 fallback”。原生 Promise 不提供同步查询状态的 API——.then() 只能注册回调,无法问“你 resolved 了吗”。React 的解决方案是自己给 thenable 打补丁:在 thenable 上挂 status、value、reason 三个 expando 属性。这就是 ReactFiberThenable.js 里 trackUsedThenable 的核心工作。
第二,不依赖 thenable 的实现。 只要对象有 then(onFulfilled, onRejected),React 就能等它。这让 Relay 的自定义 thenable、React Server Components 的 Flight thenable、React.lazy 的 payload thenable 都能走同一套挂起机制。
第三,可控的缓存与去重。 React 需要知道“同一个组件这次渲染用的 thenable 和上次是不是同一个”,以此决定能否复用上次的结果。这同样需要自己维护状态,而不是依赖 Promise 的引用相等。
9.2.2 ThenableState:挂在组件 Fiber 上的 thenable 缓存
先看类型定义。ReactFiberThenable.js 顶部:
// packages/react-reconciler/src/ReactFiberThenable.js:36-53
opaque type ThenableStateDev = {
didWarnAboutUncachedPromise: boolean,
thenables: Array<Thenable<any>>,
};
opaque type ThenableStateProd = Array<Thenable<any>>;
export opaque type ThenableState = ThenableStateDev | ThenableStateProd;
function getThenablesFromState(state: ThenableState): Array<Thenable<any>> {
if (__DEV__) {
const devState: ThenableStateDev = state as any;
return devState.thenables;
} else {
const prodState = state as any;
return prodState;
}
}
DEV 下 ThenableState 是一个对象,生产环境就是一个裸数组——这是 React 常用的“DEV 额外记账”手法(第 3 章讲过 Fiber 上大量类似设计)。数组按索引存 thenable:第 0 个 use() 调用读的 thenable 放在 thenables[0],第 1 个放在 `thenables[1]``,以此类推。
这个状态存在哪?存在函数组件的 updateQueue 上(准确说是 FunctionComponentUpdateQueue 之外的另一个位置——ReactFiberHooks.js 的模块级变量 thenableState,在 renderWithHooks 时初始化和清理)。首次挂起时创建:
// packages/react-reconciler/src/ReactFiberThenable.js:95-106
export function createThenableState(): ThenableState {
// The ThenableState is created the first time a component suspends. If it
// suspends again, we'll reuse the same state.
if (__DEV__) {
return {
didWarnAboutUncachedPromise: false,
thenables: [],
};
} else {
return [];
}
}
注意注释:一个组件只会创建一个 ThenableState,之后复用。这意味着重渲染时同一个位置如果拿到不同的 thenable,会走“保留旧的、丢弃新的”逻辑——这正是 trackUsedThenable 里最精妙的一段。
9.2.3 trackUsedThenable 逐行拆解
trackUsedThenable 是 thenable 机制的心脏,所有入口(use()、lazy 解包、reconcile 时遇到 thenable 类型的 children)最终都汇到它。完整源码:
// packages/react-reconciler/src/ReactFiberThenable.js:125-341
export function trackUsedThenable<T>(
thenableState: ThenableState,
thenable: Thenable<T>,
index: number,
fiber: null | Fiber, // DEV-only
): T {
它的职责可以概括成四件事:记账(按索引缓存)、同步探测状态、给 thenable 打 status 补丁、决定返回值或抛出。逐段看。
第一段:act 环境标记。
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
ReactSharedInternals.didUsePromise = true;
}
在 act() 测试环境里记录“本组件用过 Promise”,act 结束时会检查是否有未 await 的 Promise 并给出警告。这是纯测试设施。
第二段:按索引记账 + “组件是幂等的”假设。
const trackedThenables = getThenablesFromState(thenableState);
const previous = trackedThenables[index];
if (previous === undefined) {
trackedThenables.push(thenable);
} else {
if (previous !== thenable) {
// Reuse the previous thenable, and drop the new one. We can assume
// they represent the same value, because components are idempotent.
...
thenable.then(noop, noop);
thenable = previous;
}
}
这是理解 React “重渲染复用”语义的关键。假设组件第一次渲染时 use(fetchData()) 挂起了(fetchData() 在渲染中现场创建了一个新 Promise,这是反模式,但确实会发生)。Promise resolve 后 React 重新渲染组件,组件又执行了 fetchData()——这是一个全新的 Promise 对象,与上次挂起的那个不是同一个引用。如果 React 傻乎乎地等这个新 Promise,就会无限循环:每次重渲染都创建新 Promise、都挂起、都重渲染……
React 的破解方式:按位置(index)覆盖写入,且旧值优先。第一次见到的 thenable 占据 trackedThenables[index],之后同位置再来新 thenable,直接丢弃新的、沿用旧的。理由写在注释里:“components are idempotent(组件是幂等的)”——同一个组件在同一个位置读的数据,被认为是同一个数据。
这里有两个细节值得注意:
thenable.then(noop, noop)——给被丢弃的 Promise 挂一个空回调,纯粹是为了避免 unhandled rejection 警告。如果不挂,一个 reject 的 Promise 被丢弃会在控制台报未处理拒绝。- DEV 下会对“现场创建未缓存 Promise”给出一次警告(
didWarnAboutUncachedPromise保证每个组件只警告一次):
console.error(
'A component was suspended by an uncached promise. Creating ' +
'promises inside a Client Component or hook is not yet ' +
'supported, except via a Suspense-compatible library or framework.',
);
这句话信息量很大:React 19 的立场是——客户端组件里现场创建 Promise 并 use() 它,不是受支持的用法。受支持的路径是 Suspense 兼容的库/框架(Relay、Next.js App Router 的 RSC、React.cache 等)在渲染前把 Promise 准备好并缓存。这也是“为什么我的 use(fetch(...)) 在客户端组件里行为很怪”的官方答案。
第三段:DEV 调试信息。(enableAsyncDebugInfo 分支,给 thenable 挂 _debugInfo,记录 I/O 起止时间,用于 DevTools 的 Suspense 调试视图。与主流程无关,略过。)
第四段:状态机。 这是核心。React 在 thenable 对象自身上维护一个三态状态机:
// We use an expando to track the status and result of a thenable so that we
// can synchronously unwrap the value. Think of this as an extension of the
// Promise API, or a custom interface that is a superset of Thenable.
//
// If the thenable doesn't have a status, set it to "pending" and attach
// a listener that will update its status and result when it resolves.
switch (thenable.status) {
case 'fulfilled': {
// This could be a bad instrumentation that doesn't set .value.
const fulfilledValue: T = thenable.value;
return fulfilledValue;
}
case 'rejected': {
const rejectedError = thenable.reason;
checkIfUseWrappedInAsyncCatch(rejectedError);
...
throw rejectedError;
}
default: {
if (typeof thenable.status === 'string') {
// Only instrument the thenable if the status if not defined. If
// it's defined, but an unknown value, assume it's been instrumented by
// a custom userspace implementation. We treat it as "pending".
// Attach a dummy listener, to ensure that any lazy initialization can
// happen. Flight lazily parses JSON when the value is actually awaited.
thenable.then(noop, noop);
} else {
// This is an uncached thenable that we haven't seen before.
// Detect infinite ping loops caused by uncached promises.
const root = getWorkInProgressRoot();
if (root !== null && root.shellSuspendCounter > 100) {
throw new Error(
'An unknown Component is an async Client Component. ' +
...
);
}
const pendingThenable: PendingThenable<T> = thenable as any;
pendingThenable.status = 'pending';
pendingThenable.then(
fulfilledValue => {
if (thenable.status === 'pending') {
const fulfilledThenable: FulfilledThenable<T> = thenable as any;
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = fulfilledValue;
}
},
(error: mixed) => {
if (thenable.status === 'pending') {
const rejectedThenable: RejectedThenable<T> = thenable as any;
rejectedThenable.status = 'rejected';
rejectedThenable.reason = error;
}
},
);
}
...
逐个分支解读:
'fulfilled':直接返回thenable.value。同步解包,零成本。注意注释里那句“This could be a bad instrumentation that doesn't set.value”——React 信任补丁的正确性,不防御性检查,因为这是热路径。'rejected':throw thenable.reason。rejected 的 thenable 会被转成真正的错误抛出,最终由错误边界(getDerivedStateFromError)处理——Suspense 不处理 rejection,这是 Suspense 与错误边界的分界线(9.13 节细说)。checkIfUseWrappedInAsyncCatch检查 reject 的原因是不是 React 内部的SuspenseException,防止内部异常泄漏到用户 Promise 链里造成更混乱的错误。- default(无 status):这是“第一次见到这个 thenable”的情况。做三件事:
- 无限 ping 循环检测:
root.shellSuspendCounter > 100时抛错。shellSuspendCounter在renderRootSync末尾递增(ReactFiberWorkLoop.js:2739-2741),记录“同步渲染在 shell 里挂起且什么都没提交”的次数。超过 100 次说明组件每次重渲染都挂起在新 Promise 上——经典的 async Client Component 误用。这是一个熔断机制,不是常规逻辑。 - 打补丁:
pendingThenable.status = 'pending',然后挂then监听。监听回调里的if (thenable.status === 'pending')是幂等守卫——防止 thenable 被多个来源重复 resolve 时状态机错乱。 - 打完补丁后,再检查一次 status:
- 无限 ping 循环检测:
// Check one more time in case the thenable resolved synchronously.
switch ((thenable as Thenable<T>).status) {
case 'fulfilled': { ... return fulfilledThenable.value; }
case 'rejected': { ... throw rejectedError; }
}
// Suspend.
//
// Throwing here is an implementation detail that allows us to unwind the
// call stack. But we shouldn't allow it to leak into userspace. Throw an
// opaque placeholder value instead of the actual thenable. ...
suspendedThenable = thenable;
...
throw SuspenseException;
这个“再检查一次”处理的是同步 resolve 的 thenable(比如 Promise.resolve(x) 在某些微任务时序下的边界情况,或自定义 thenable 在 then 注册时同步触发回调)。如果补丁期间 thenable 已经 fulfilled,直接返回值,根本不挂起。
最后,真正挂起时抛出的不是 thenable 本身,而是 SuspenseException:
// packages/react-reconciler/src/ReactFiberThenable.js:57-65
export const SuspenseException: mixed = new Error(
"Suspense Exception: This is not a real error! It's an implementation " +
'detail of `use` to interrupt the current render. You must either ' +
'rethrow it immediately, or move the `use` call outside of the ' +
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`.",
);
这是一个极具 React 风格的设计:抛出不透明占位符,真实 thenable 走模块级变量传递。
// packages/react-reconciler/src/ReactFiberThenable.js:376-395
let suspendedThenable: Thenable<any> | null = null;
let needsToResetSuspendedThenableDEV = false;
export function getSuspendedThenable(): Thenable<mixed> {
// This is called right after `use` suspends by throwing an exception. `use`
// throws an opaque value instead of the thenable itself so that it can't be
// caught in userspace. Then the work loop accesses the actual thenable using
// this function.
...
}
为什么不直接抛 thenable?因为用户代码可能写 try { use(p) } catch (e) { /* 吞掉 */ }——如果抛的是 thenable,用户的 catch 会吞掉挂起信号,React 的栈展开就断了。抛一个不透明的 SuspenseException,用户 catch 了也无法利用它做任何事,而且 checkIfUseWrappedInTryCatch 会在之后检测“SuspenseException 被用户代码捕获了”并打警告。工作循环则在 handleThrow 里通过 getSuspendedThenable() 取回真正的 thenable。真实 thenable 存在模块级变量里,说明同一时刻只有一个 thenable 处于“已抛出未取回”状态——这成立是因为 JS 单线程 + 同步栈展开。
9.2.4 三个特殊 thenable:noop、commit 挂起与 lazy
ReactFiberThenable.js 里还定义了三个特殊角色:
// packages/react-reconciler/src/ReactFiberThenable.js:67-93
export const SuspenseyCommitException: mixed = new Error(
'Suspense Exception: This is not a real error, and should not leak into ' +
"userspace. If you're seeing this, it's likely a bug in React.",
);
export const SuspenseActionException: mixed = new Error(...);
// This is a noop thenable that we use to trigger a fallback in throwException.
export const noopSuspenseyCommitThenable = {
then() {
if (__DEV__) {
console.error(
'Internal React error: A listener was unexpectedly attached to a ' +
'"noop" thenable. This is a bug in React. Please file an issue.',
);
}
},
};
SuspenseException:render 阶段挂起信号(use/ lazy)。SuspenseyCommitException:commit 阶段挂起信号。suspendCommit()抛出它,表示“当前 commit 要暂停,等宿主资源就绪”(9.12 节的 suspensey commit)。SuspenseActionException:useActionState的挂起信号。noopSuspenseyCommitThenable:一个冒牌 thenable,用于“我想让 throwException 走挂起分支,但并没有真的 thenable 要等”的场景(比如 CPU Suspense、Offscreen 的 ScheduleRetry)。任何人试图then它都会报内部错误——它本就不该被等待。
这四种异常/占位符在 handleThrow 里被分流,构成下一节的主线。
9.2.5 Wakeable 与 Thenable 的类型契约
把类型定义也钉住,后面读源码时不会再对 status?: void 之类的写法感到困惑。shared/ReactTypes.js:
// packages/shared/ReactTypes.js:103-151(节选)
// The subset of a Thenable required by things thrown by Suspense.
// This doesn't require a value to be passed to either handler.
export interface Wakeable {
then(onFulfill: () => mixed, onReject: () => mixed): void | Wakeable;
}
// The subset of a Promise that React APIs rely on. This resolves a value.
interface ThenableImpl<T> {
then(
onFulfill: (value: T) => mixed,
onReject: (error: mixed) => mixed,
): void | Wakeable;
displayName?: string;
}
interface UntrackedThenable<T> extends ThenableImpl<T> {
status?: void;
_debugInfo?: null | ReactDebugInfo;
}
export interface PendingThenable<T> extends ThenableImpl<T> {
status: 'pending';
...
}
export interface FulfilledThenable<T> extends ThenableImpl<T> {
status: 'fulfilled';
value: T;
...
}
export interface RejectedThenable<T> extends ThenableImpl<T> {
status: 'rejected';
reason: mixed;
...
}
export type Thenable<T> =
| UntrackedThenable<T>
| PendingThenable<T>
| WeakPendingThenable<T>
| FulfilledThenable<T>
| RejectedThenable<T>;
三个知识点:
Wakeable与Thenable是两个类型。Wakeable只要求then存在且回调不传值——throwException/attachPingListener面向Wakeable(它们不关心值,只关心“好了没”);Thenable要求值语义——trackUsedThenable/use()面向Thenable(要解包出value)。挂起链路上Wakeable是“门禁”,Thenable是“载荷”。UntrackedThenable.status?: void就是“尚未被 React 打补丁”的状态类型——对应trackUsedThenable里typeof thenable.status === 'string'为假的分支(9.2.3 节)。被打补丁后类型“升级”为PendingThenable,运行时靠 Flow 的as any断言完成,JS 侧无任何开销。WeakPendingThenable(status: 'pending_weak') 是 Flight(RSC)专用的“弱挂起”态:它表示 thenable 对应的是“可以延迟解析的大对象”,trackUsedThenable对未知 status 字符串的处理(挂noop监听当作 pending)正是为它留的门。displayName则纯粹服务于 DevTools 的 Suspense 可视化。
9.2.6 Context 与挂起的交互:预播机制
挂起组件的子树从未被渲染,那么子树里的 context consumer 怎么办?resetSuspendedComponent 调用的 propagateParentContextChangesToDeferredTree(ReactFiberNewContext.js:397-409)是答案:
export function propagateParentContextChangesToDeferredTree(
current: Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
const forcePropagateEntireTree = true;
propagateParentContextChanges(
current,
workInProgress,
renderLanes,
forcePropagateEntireTree,
);
}
它复用常规的 context 传播(第 4 章),但强制遍历整棵被丢弃的子树,把“父级 provider 变了”的消息写进子树 fiber 的 lanes。效果:retry 重建这棵子树时,即使 consumer fiber 是全新的,也能因为 lanes 里记着“依赖的 context 变了”而读到新值。没有这一步,<Suspense> 内消费 context 的组件会在 retry 后读到过期 context——一个极难复现的 bug,源码用一次强制遍历消掉了它。这也是为什么 updateSuspenseComponent 的更新路径里随处可见 lazilyPropagateParentContextChanges 与 hasContextChanged 的检查(ReactFiberBeginWork.js:3042-3053、4065-4074):边界的每次重试都是一次 context 传播的补课机会。
9.3 “抛出一个 Promise”的完整链路
这是本章的核心。我们从一次 use(promise) 出发,跟踪它从抛出到屏幕上出现 fallback、再到数据就绪后恢复的完整生命周期。全程对照源码。
9.3.1 全景调用链
先给全链路的 mermaid 时序图,后面每一段都会展开:
sequenceDiagram
participant U as 用户组件
participant H as ReactFiberHooks.use
participant T as trackUsedThenable
participant WL as WorkLoop.handleThrow
participant TU as throwAndUnwindWorkLoop
participant TE as throwException
participant SH as SuspenseHandlerStack
participant B as SuspenseComponent Fiber
participant P as Promise(thenable)
participant R as retryTimedOutBoundary
U->>H: use(promise)
H->>T: useThenable(thenable)
T->>T: 按 index 记账 / 打 status 补丁
alt thenable 已 fulfilled
T-->>U: 直接返回值,不挂起
else 未就绪
T-->>U: throw SuspenseException(真实 thenable 存入模块变量)
U->>WL: 异常沿 JS 调用栈抛出
WL->>WL: getSuspendedThenable() 取回 thenable<br/>SuspendedReason = SuspendedOnImmediate
WL->>TU: throwAndUnwindWorkLoop(root, fiber, thenable, reason)
TU->>TE: throwException(root, returnFiber, sourceFiber, thenable, lanes)
TE->>TE: value.then 是函数 → wakeable 分支
TE->>SH: getSuspenseHandler() 找最近边界
SH-->>TE: suspenseBoundary Fiber
TE->>B: markSuspenseBoundaryShouldCapture()<br/>flags |= ShouldCapture
TE->>B: updateQueue = new Set([wakeable])
TE->>P: attachPingListener(root, wakeable, lanes)
Note over WL: unwindUnitOfWork 向上抛<br/>边界 beginWork 看到 ShouldCapture
Note over B: updateSuspenseComponent: showFallback=true<br/>mount/updateSuspenseFallbackChildren
Note over WL: commit fallback
P-->>P: resolve
P->>WL: ping → pingSuspendedRoot
WL->>R: ensureRootIsScheduled → 新渲染
R->>B: retryTimedOutBoundary(boundary, retryLane)
Note over B: 重渲染 primary children<br/>数据已就绪 → 提交真实内容
end
注意图里两个容易忽略的细节,后文会验证:
- 挂起时
use()抛出的是SuspenseException,不是 thenable;thenable 通过模块级变量“走私”给工作循环。 - “找边界”不是沿着
return指针往上爬,而是查一个栈suspenseHandlerStackCursor——这是渲染阶段同步维护的“最近的 catch 块”。
9.3.2 第一站:use() 与 thenable 入口
use() 的公共 API 极简:
// packages/react/src/ReactHooks.js:207-210
export function use<T>(usable: Usable<T>): T {
const dispatcher = resolveDispatcher();
return dispatcher.use(usable);
}
reconciler 侧的分派实现按类型三分——thenable / context / 其它:
// packages/react-reconciler/src/ReactFiberHooks.js:1159-1179
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
if (typeof usable.then === 'function') {
// This is a thenable.
const thenable: Thenable<T> = usable as any;
return useThenable(thenable);
} else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
// Fiber is the final renderer, so there is no downstream host that
// needs to recover this subtree. Continue rendering through it.
return undefined as any;
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
const context: ReactContext<T> = usable as any;
return readContext(context);
}
}
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}
useThenable 在调 trackUsedThenable 之前先做了一件 Hook 特有的事——维护 thenable 的位置计数器:
// packages/react-reconciler/src/ReactFiberHooks.js:1098-1110
function useThenable<T>(thenable: Thenable<T>): T {
// Track the position of the thenable within this fiber.
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
const result = trackUsedThenable(
thenableState,
thenable,
index,
__DEV__ ? currentlyRenderingFiber : null,
);
thenableIndexCounter 是模块级计数器,每次组件渲染开始时归零(在 renderWithHooks 里)。所以“第几个 use()” = “数组第几个槽位”,与 Hook 的顺序依赖是同一套思想(第 7 章讲过 Hook 的位置依赖,这里 thenable 缓存复用了该约束)。
useThenable 剩下的一大段(1112-1155 行)是在处理一个微妙问题:use() 挂起后重渲染时,dispatcher 要从 “re-render 专用 dispatcher” 切回正常的 mount/update dispatcher。原因是挂起时组件只渲染到 use() 那一行就中断了,后面的 Hook 一个都没执行;数据就绪后重渲染,要能把中断处之后的 Hook 补齐,就必须把 dispatcher 换回来。这段注释写得很清楚,不再赘述。
另一个入口是 reconcile 阶段的 thenable children——如果组件直接 return promise(把 Promise 当 React 节点渲染,曾经的实验性用法):
// packages/react-reconciler/src/ReactChildFiber.js:282-289
function unwrapThenable<T>(thenable: Thenable<T>): T {
const index = thenableIndexCounter;
thenableIndexCounter += 1;
if (thenableState === null) {
thenableState = createThenableState();
}
return trackUsedThenable(thenableState, thenable, index, null);
}
与 useThenable 的区别仅在于不传 fiber(DEV 警告信息少一点)。这印证了 9.2 节的论断:所有入口最终汇到 trackUsedThenable。
9.3.3 第二站:handleThrow —— 异常的分拣中心
trackUsedThenable 抛出 SuspenseException 后,异常沿着 performUnitOfWork → beginWork → renderWithHooks → 用户组件的 JS 调用栈一路向上,最终被 renderRootSync / renderRootConcurrent 的 try/catch 接住:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:2728-2730(renderRootSync 内)
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
handleThrow 是“异常分拣中心”,它做的第一件事是保持工作循环现场(注释原话:“Until we decide whether we're going to unwind or replay, we should preserve the current state of the work loop without resetting anything.”),然后把异常归类为 SuspendedReason:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:2302-2370(节选)
function handleThrow(root: FiberRoot, thrownValue: any): void {
resetHooksAfterThrow();
if (__DEV__) {
resetCurrentFiber();
}
if (
thrownValue === SuspenseException ||
thrownValue === SuspenseActionException
) {
// This is a special type of exception used for Suspense. For historical
// reasons, the rest of the Suspense implementation expects the thrown value
// to be a thenable, because before `use` existed that was the (unstable)
// API for suspending. This implementation detail can change later, once we
// deprecate the old API in favor of `use`.
thrownValue = getSuspendedThenable();
// Don't suspend work loop, except to check if the data has
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
// nearest Suspense fallback.
workInProgressSuspendedReason = SuspendedOnImmediate;
} else if (thrownValue === SuspenseyCommitException) {
thrownValue = getSuspendedThenable();
workInProgressSuspendedReason = SuspendedOnInstance;
} else if (thrownValue === SelectiveHydrationException) {
workInProgressSuspendedReason = SuspendedOnHydration;
} else {
// This is a regular error.
const isWakeable =
thrownValue !== null &&
typeof thrownValue === 'object' &&
typeof thrownValue.then === 'function';
workInProgressSuspendedReason = isWakeable
? // A wakeable object was thrown by a legacy Suspense implementation.
// This has slightly different behavior than suspending with `use`.
SuspendedOnDeprecatedThrowPromise
: // This is a regular error.
SuspendedOnError;
}
workInProgressThrownValue = thrownValue;
...
SuspendedReason 全集(ReactFiberWorkLoop.js:445-455):
| 枚举值 | 含义 | 触发场景 |
|---|---|---|
NotSuspended | 没挂起 | 初始值 |
SuspendedOnError | 真错误 | 组件抛 Error |
SuspendedOnData | use() 挂起且工作循环决定“等数据” | 并发渲染中 Suspense 边界外挂起 |
SuspendedOnImmediate | use()/lazy 挂起,先检查是否微任务已 resolve | use()、React.lazy |
SuspendedOnInstance | 宿主实例(图片等)未就绪 | suspensey commit |
SuspendedOnInstanceAndReadyToContinue | 同上,已让出主线程后再查一次 | suspensey commit 恢复 |
SuspendedOnDeprecatedThrowPromise | 老的 throw promise 用法 | 旧代码/库直接抛 thenable |
SuspendedAndReadyToContinue | 已让出过主线程,准备继续 | SuspendedOnImmediate/Data 之后 |
SuspendedOnHydration | 选择性水合要打断当前渲染 | SelectiveHydrationException |
SuspendedOnAction | useActionState 挂起 | Form Actions |
注意 SuspendedOnDeprecatedThrowPromise 的注释:“This has slightly different behavior than suspending with use.”——旧式 throw promise 会被强制 unwind(不能 replay),因为 replay 语义对旧用法可能造成无限 ping 循环(renderRootConcurrent 里 2976-2989 行专门处理它)。
9.3.4 第三站:throwAndUnwindWorkLoop —— 从“记录”到“展开”
handleThrow 只负责记录状态。真正决定“展开还是原地重试”的是 renderRootSync / renderRootConcurrent 顶部的 resumeOrUnwind 逻辑。两条路径策略不同:
同步渲染路径(renderRootSync,ReactFiberWorkLoop.js:2662-2724):不让出主线程,除了 SuspendedOnHydration(重置栈)外,其余一律立即 throwAndUnwindWorkLoop——同步渲染里没有“等一等”的选项。
并发渲染路径(renderRootConcurrent,ReactFiberWorkLoop.js:2820-3023):这是最精细的一段,逐个 reason 决策:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:2877-2888
case SuspendedOnImmediate: {
// If this fiber just suspended, it's possible the data is already
// cached. Yield to the main thread to give it a chance to ping. If
// it does, we can retry immediately without unwinding the stack.
workInProgressSuspendedReason = SuspendedAndReadyToContinue;
break outer;
}
use() 挂起时走的就是这里:先把状态改成 SuspendedAndReadyToContinue,然后 break outer 退出工作循环,把主线程让出去。为什么要让出去?因为 thenable 可能在微任务里已经 resolve 了(比如缓存命中、或 Promise.resolve() 链),或者用户代码紧接着就会 ping 它。让出一帧,ensureRootIsScheduled 会安排一个回调重新进入工作循环;再次进入时 SuspendedAndReadyToContinue 分支会重新检查:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:2889-2908
case SuspendedAndReadyToContinue: {
const thenable: Thenable<mixed> = thrownValue as any;
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
} else {
// Otherwise, unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(
root,
unitOfWork,
thrownValue,
SuspendedAndReadyToContinue,
);
}
break;
}
resolved → replaySuspendedUnitOfWork(原地重放组件,不展开栈、不显示 fallback);未 resolved → throwAndUnwindWorkLoop(展开栈、走 throwException、显示 fallback)。isThenableResolved 就是读补丁状态:
// packages/react-reconciler/src/ReactFiberThenable.js:108-111
export function isThenableResolved(thenable: Thenable<mixed>): boolean {
const status = thenable.status;
return status === 'fulfilled' || status === 'rejected';
}
一次属性读取,同步完成。这就是 9.2 节“同步可检测性”的用武之地——React 能在不 await 的情况下知道数据好了没有。
还有一类更“激进”的挂起:SuspendedOnData。当渲染在边界外挂起(没有 Suspense 边界能接,或边界在 shell 里)时,并发渲染会把整个工作循环挂起,直到数据 resolve:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:2843-2876
case SuspendedOnData:
case SuspendedOnAction: {
const thenable: Thenable<mixed> = thrownValue as any;
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
...
replaySuspendedUnitOfWork(unitOfWork);
break;
}
// The work loop is suspended on data. We should wait for it to
// resolve before continuing to render.
const onResolution = () => {
// Check if the root is still suspended on this promise.
if (
(workInProgressSuspendedReason === SuspendedOnData ||
workInProgressSuspendedReason === SuspendedOnAction) &&
workInProgressRoot === root
) {
// Mark the root as ready to continue rendering.
workInProgressSuspendedReason = SuspendedAndReadyToContinue;
}
// Ensure the root is scheduled. We should do this even if we're
// currently working on a different root, so that we resume
// rendering later.
ensureRootIsScheduled(root);
};
thenable.then(onResolution, onResolution);
break outer;
}
即“整个渲染暂停,等数据”,这是 React 18+ 无边界挂起的标准行为(配合 renderDidSuspendDelayIfPossible,见 9.5 节)。
throwAndUnwindWorkLoop 本体(ReactFiberWorkLoop.js:3234-3340)逻辑分两段:先同步调用 throwException 找边界、打标记;再根据 unitOfWork.flags & Incomplete 决定是否 unwindUnitOfWork:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3234-3276(节选)
function throwAndUnwindWorkLoop(
root: FiberRoot,
unitOfWork: Fiber,
thrownValue: mixed,
suspendedReason: SuspendedReason,
) {
// This is a fork of performUnitOfWork specifcally for unwinding a fiber
// that threw an exception.
//
// Return to the normal work loop. This will unwind the stack, and potentially
// result in showing a fallback.
resetSuspendedWorkLoopOnUnwind(unitOfWork);
const returnFiber = unitOfWork.return;
try {
// Find and mark the nearest Suspense or error boundary that can handle
// this "exception".
const didFatal = throwException(
root,
returnFiber,
unitOfWork,
thrownValue,
workInProgressRootRenderLanes,
);
if (didFatal) {
panicOnRootError(root, thrownValue);
return;
}
} catch (error) {
...
}
注意 resetSuspendedWorkLoopOnUnwind 先重置了 Hook 链表和 child reconciler 的模块级状态——挂起组件的 Hook 链表处于“半成品”状态,必须先拆掉防止污染下一次渲染。
9.3.5 第四站:throwException 的 wakeable 分支(逐行)
throwException(ReactFiberThrow.js:364-705)是整条链路的枢纽。它面对一个 value,要回答三个问题:这是挂起还是错误?谁能处理?处理完之后工作循环该干什么?
// packages/react-reconciler/src/ReactFiberThrow.js:364-380
function throwException(
root: FiberRoot,
returnFiber: Fiber | null,
sourceFiber: Fiber,
value: mixed,
rootRenderLanes: Lanes,
): boolean {
// The source fiber did not complete.
sourceFiber.flags |= Incomplete;
首先给抛出异常的 Fiber 打上 Incomplete——它没渲染完。这个标志会让 completeUnitOfWork 见到它时转向 unwind 而不是 complete(ReactFiberWorkLoop.js:3373-3383):
if ((completedWork.flags & Incomplete) !== NoFlags) {
// This fiber did not complete, because one of its children did not
// complete. Switch to unwinding the stack instead of completing it.
const skipSiblings = workInProgressRootDidSkipSuspendedSiblings;
unwindUnitOfWork(completedWork, skipSiblings);
return;
}
然后判断 wakeable:
// packages/react-reconciler/src/ReactFiberThrow.js:381-397
if (value !== null && typeof value === 'object') {
if (typeof value.then === 'function') {
// This is a wakeable. The component suspended.
const wakeable: Wakeable = value as any;
resetSuspendedComponent(sourceFiber, rootRenderLanes);
resetSuspendedComponent(ReactFiberThrow.js:203-239)做两件重要的事:
- 把上下文变更传播进被丢弃的子树。挂起组件的 children 从未被访问,但父级的 context 可能变了——正常渲染时 context 传播靠“遍历到 consumer fiber”,而现在 consumer fiber 还没建出来,所以必须手动把变更“预播”到 current 对应的子树上,否则 retry 时旧子树读不到新 context:
// packages/react-reconciler/src/ReactFiberThrow.js:203-217
function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
const currentSourceFiber = sourceFiber.alternate;
if (currentSourceFiber !== null) {
// Since we never visited the children of the suspended component, we
// need to propagate the context change now, to ensure that we visit
// them during the retry.
//
// We don't have to do this for errors because we retry errors without
// committing in between. So this is specific to Suspense.
propagateParentContextChangesToDeferredTree(
currentSourceFiber,
sourceFiber,
rootRenderLanes,
);
}
注释里那句对比很精准:错误重试不 commit,所以不需要预播;Suspense 的 retry 之间隔着一次 commit,所以必须预播。
- legacy 模式下把挂起组件的 memoizedState/updateQueue 回滚(历史包袱,略过)。
接下来就是全章最关键的“找边界”:
// packages/react-reconciler/src/ReactFiberThrow.js:396-446
// Mark the nearest Suspense boundary to switch to rendering a fallback.
const suspenseBoundary = getSuspenseHandler();
if (suspenseBoundary !== null) {
switch (suspenseBoundary.tag) {
case ActivityComponent:
case SuspenseComponent:
case SuspenseListComponent: {
getSuspenseHandler() 查的是一张栈,不是 Fiber 树的 return 链:
// packages/react-reconciler/src/ReactFiberSuspenseContext.js:22-23, 150-152
const suspenseHandlerStackCursor: StackCursor<Fiber | null> =
createCursor(null);
...
export function getSuspenseHandler(): Fiber | null {
return suspenseHandlerStackCursor.current;
}
这张栈在 beginWork 进入 Suspense/Offscreen/Activity/SuspenseList 时 push,离开时 pop:
// packages/react-reconciler/src/ReactFiberSuspenseContext.js:43-102(节选)
export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void {
...
push(
suspenseStackCursor,
setDefaultShallowSuspenseListContext(suspenseStackCursor.current),
handler,
);
...
push(suspenseHandlerStackCursor, handler, handler);
if (shellBoundary === null) {
if (current === null || isCurrentTreeHidden()) {
// This boundary is not visible in the current UI.
shellBoundary = handler;
} else {
const prevState: SuspenseState = current.memoizedState;
if (prevState !== null) {
// This boundary is showing a fallback in the current UI.
shellBoundary = handler;
}
}
}
}
// packages/react-reconciler/src/ReactFiberSuspenseContext.js:104-109
export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
// We're about to render the fallback. If something in the fallback suspends,
// it's akin to throwing inside of a `catch` block. This boundary should not
// capture. Reuse the existing handler on the stack.
reuseSuspenseHandlerOnStack(fiber);
}
两个设计要点:
- “渲染 fallback 时复用外层 handler”:fallback 内部再挂起,不能自己接自己(否则无限递归),要抛给更外层——就像
try/catch的 catch 块里再抛异常不会被同一个 catch 接住。pushFallbackTreeSuspenseHandler的注释把这个类比写得明明白白。 - 两张栈并行:
suspenseHandlerStackCursor(谁能接)和suspenseStackCursor(SuspenseList 的 ForceSuspenseFallback 位掩码),push/pop 必须成对——这就是为什么 Offscreen/Activity 那些分支里即使“不需要”也要 push 一个占位(源码注释多次出现 “A SuspenseList context is only pushed here to avoid a push/pop mismatch”)。
pushPrimaryTreeSuspenseHandler 里还埋了一个重要变量:shellBoundary。它是“当前树中不可见的最外层边界”——注释解释:
// packages/react-reconciler/src/ReactFiberSuspenseContext.js:25-37
// Represents the outermost boundary that is not visible in the current tree.
// Everything above this is the "shell". When this is null, it means we're
// rendering in the shell of the app. If it's non-null, it means we're rendering
// deeper than the shell, inside a new tree that wasn't already visible.
//
// The main way we use this concept is to determine whether showing a fallback
// would result in a desirable or undesirable loading state. Activing a fallback
// in the shell is considered an undersirable loading state, because it would
// mean hiding visible (albeit stale) content in the current tree — we prefer to
// show the stale content, rather than switch to a fallback. But showing a
// fallback in a new tree is fine, because there's no stale content to
// prefer instead.
翻译成 UX 语言:shell 里显示 fallback = 用加载态替换掉用户正在看的(哪怕是旧的)内容 = 不好的加载态。这正是 React 18 之后“transition 中挂起保留旧内容”这一行为的判定依据,9.5 节会看到它如何被消费。
回到 throwException。找到边界后,先做节流相关的记录:
// packages/react-reconciler/src/ReactFiberThrow.js:413-436
if (disableLegacyMode || sourceFiber.mode & ConcurrentMode) {
if (getShellBoundary() === null) {
// Suspended in the "shell" of the app. This is an undesirable
// loading state. We should avoid committing this tree.
renderDidSuspendDelayIfPossible();
} else {
// If we suspended deeper than the shell, we don't need to delay
// the commmit. However, we still call renderDidSuspend if this is
// a new boundary, to tell the work loop that a new fallback has
// appeared during this render.
const current = suspenseBoundary.alternate;
if (current === null) {
renderDidSuspend();
}
}
}
getShellBoundary() === null:挂起发生在 shell →renderDidSuspendDelayIfPossible()→ 把退出状态设为RootSuspendedWithDelay。这是“不要提交这棵树”的信号:既然要显示的是“替换可见内容”的坏 fallback,那不如什么都不提交,等数据好了直接出新内容(如果是 transition),或者等一个节流超时。- 挂起在 shell 深处(新出现的边界):只
renderDidSuspend()(RootSuspended)——正常提交 fallback 没问题,但要告诉工作循环“这次渲染出现了新 fallback”,供节流和 SuspenseList 使用。
然后是两个不可省略的动作:
// packages/react-reconciler/src/ReactFiberThrow.js:438-445
suspenseBoundary.flags &= ~ForceClientRender;
markSuspenseBoundaryShouldCapture(
suspenseBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes,
);
markSuspenseBoundaryShouldCapture(ReactFiberThrow.js:241-362)是“给边界盖章”的函数。它先处理 legacy 模式(直接 DidCapture + 假装渲染了 null 的一堆 hack,modern 构建里 disableLegacyMode 为 false 时这段仍然保留,但我们以并发路径为主),并发路径极其简洁:
// packages/react-reconciler/src/ReactFiberThrow.js:357-361
suspenseBoundary.flags |= ShouldCapture;
// TODO: I think we can remove this, since we now use `DidCapture` in
// the begin phase to prevent an early bailout.
suspenseBoundary.lanes = rootRenderLanes;
return suspenseBoundary;
注意函数上方 319-356 行那段长篇注释,是整个 Suspense 启发式策略的“设计文档”,值得完整引用(这是理解 React “什么时候重来一次、什么时候干等”的总纲):
// packages/react-reconciler/src/ReactFiberThrow.js:316-356
// Confirmed that the boundary is in a concurrent mode tree. Continue
// with the normal suspend path.
//
// After this we'll use a set of heuristics to determine whether this
// render pass will run to completion or restart or "suspend" the commit.
// The actual logic for this is spread out in different places.
//
// This first principle is that if we're going to suspend when we complete
// a root, then we should also restart if we get an update or ping that
// might unsuspend it, and vice versa. The only reason to suspend is
// because you think you might want to restart before committing. However,
// it doesn't make sense to restart only while in the period we're suspended.
//
// Restarting too aggressively is also not good because it starves out any
// intermediate loading state. So we use heuristics to determine when.
// Suspense Heuristics
//
// If nothing threw a Promise or all the same fallbacks are already showing,
// then don't suspend/restart.
//
// If this is an initial render of a new tree of Suspense boundaries and
// those trigger a fallback, then don't suspend/restart. We want to ensure
// that we can show the initial loading state as quickly as possible.
//
// If we hit a "Delayed" case, such as when we'd switch from content back into
// a fallback, then we should always suspend/restart. Transitions apply
// to this case. If none is defined, JND is used instead.
//
// If we're already showing a fallback and it gets "retried", allowing us to show
// another level, but there's still an inner boundary that would show a fallback,
// then we suspend/restart for 500ms since the last time we showed a fallback
// anywhere in the tree. This effectively throttles progressive loading into
// a consistent train of commits. ...
“JND”是 Just Noticeable Difference(最小可觉差)——人眼对 ~100ms 以下的变化不敏感,React 用节流窗口把连续的加载态“攒成一批”提交。注意这段注释说 500ms,而实际常量是 300ms(ReactFiberWorkLoop.js:528)——以源码为准,注释是过时的(规范允许我们指出这一点)。
ShouldCapture 与 DidCapture 的区别是理解 Suspense 状态机的钥匙:
ShouldCapture:throwException 阶段打上,“这次渲染这个边界要捕获”,此时还没决定渲染哪棵树;DidCapture:unwind 到边界、updateSuspenseComponent真正读取并消费它,切换为 fallback 渲染后立即清除(ReactFiberBeginWork.js:2406workInProgress.flags &= ~DidCapture;)。
为什么需要两个标志?因为从“盖章”到“边界被渲染”之间,unwind 要经过若干层父 Fiber;ShouldCapture 会沿着 return 链被 bubbleProperties 收拢,而边界真正 beginWork 时用 DidCapture 做“早退防止”(beginWork 的 SuspenseComponent 分支在 attemptEarlyBailoutIfNoScheduledUpdate 之前检查 DidCapture,防止带伤跳过)。throwException 的注释也承认了这一点:“I think we can remove this, since we now use DidCapture in the begin phase to prevent an early bailout.”
盖章之后是挂 wakeable 到边界 + 挂 ping 监听:
// packages/react-reconciler/src/ReactFiberThrow.js:446-484
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
// listener. This one schedules an update on the Suspense boundary to
// turn the fallback state off.
//
// Stash the wakeable on the boundary fiber so we can access it in the
// commit phase.
//
// When the wakeable resolves, we'll attempt to render the boundary
// again ("retry").
// Check if this is a Suspensey resource. We do not attach retry
// listeners to these, because we don't actually need them for
// rendering. Only for committing. Instead, if a fallback commits
// and the only thing that suspended was a Suspensey resource, we
// retry immediately.
const isSuspenseyResource =
wakeable === noopSuspenseyCommitThenable;
if (isSuspenseyResource) {
suspenseBoundary.flags |= ScheduleRetry;
} else {
const retryQueue: RetryQueue | null =
suspenseBoundary.updateQueue as any;
if (retryQueue === null) {
suspenseBoundary.updateQueue = new Set([wakeable]);
} else {
retryQueue.add(wakeable);
}
// We only attach ping listeners in concurrent mode. Legacy
// Suspense always commits fallbacks synchronously, so there are
// no pings.
if (disableLegacyMode || suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
}
return false;
这段是“双 listener”架构的第一半:
suspenseBoundary.updateQueue = new Set([wakeable])——把 wakeable 存到边界 Fiber 的 updateQueue 上(类型是RetryQueue = Set<Wakeable>,ReactFiberSuspenseComponent.js:61)。注意这是 SuspenseComponent 特有的复用:类组件的 updateQueue 存 state 更新,Suspense 边界的 updateQueue 存“我在等哪些 thenable”。多个兄弟组件挂起时,retryQueue.add(wakeable)累积成一个集合——一次 retry 全部检查。attachPingListener(root, wakeable, rootRenderLanes)——立刻给 thenable 挂监听。为什么要“立刻”而不是等 fallback commit?注释在attachPingListener里回答了:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4970-4986(节选)
export function attachPingListener(
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
// Attach a ping listener
//
// The data might resolve before we have a chance to commit the fallback. Or,
// in the case of a refresh, we'll never commit a fallback. So we need to
// attach a listener now. When it resolves ("pings"), we can decide whether to
// try rendering the tree again.
//
// Only attach a listener if one does not already exist for the lanes
// we're currently rendering (which acts like a "thread ID" here).
数据可能在 fallback 提交前就 resolve;refresh 场景(startTransition 刷新)根本不显示 fallback。所以 ping 监听必须在渲染阶段就挂上,不能拖到 commit。而且用 root.pingCache(Map<Wakeable, Set<lanes>>)去重——同一个 thenable + 同一组 lanes 只挂一个监听,lanes 在这里充当“线程 ID”。多个组件等同一个 Promise 时,这个去重避免 N 个监听炸开。
isSuspenseyResource分支:如果 wakeable 是noopSuspenseyCommitThenable(没有任何真实数据要等,只是想触发 fallback/重试,比如 CPU Suspense),就不挂监听,而是给边界打ScheduleRetry标志——commit 后立即安排一次 retry。
Offscreen 作为 handler 时逻辑类似但队列结构不同(OffscreenQueue.retryQueue,ReactFiberThrow.js:487-517),因为它还要共存 transitions 和 markerInstances。
最后,没有边界的情况:
// packages/react-reconciler/src/ReactFiberThrow.js:523-547
} else {
// No boundary was found. Unless this is a sync update, this is OK.
// We can suspend and wait for more data to arrive.
if (disableLegacyMode || root.tag === ConcurrentRoot) {
// In a concurrent root, suspending without a Suspense boundary is
// allowed. It will suspend indefinitely without committing.
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
return false;
} else {
// In a legacy root, suspending without a boundary is always an error.
const uncaughtSuspenseError = new Error(
'A component suspended while responding to synchronous input. This ' +
'will cause the UI to be replaced with a loading indicator. To ' +
'fix, updates that suspend should be wrapped ' +
'with startTransition.',
);
value = uncaughtSuspenseError;
}
}
并发根下无边界挂起是合法的:整个根挂起,不提交,等数据。legacy 根下则降级成错误(会卸载整棵树换成 loading——这条错误消息在 React 17 时代广为流传)。
而 throwException 剩下的 551 行以后,是错误路径:找错误边界(ClassComponent + getDerivedStateFromError/componentDidCatch)、HostRoot 兜底、renderDidError()。Suspense 与错误边界在这里分家:wakeable 走上面所有逻辑,Error 走下面。这个 if/else 结构就是“Suspense 不处理 rejection”的源码级证据。
9.3.6 第五站:unwind 与 fallback 渲染
throwException 返回后,throwAndUnwindWorkLoop 根据 Incomplete 标志向上 unwind:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3276-3327(节选)
if (unitOfWork.flags & Incomplete) {
// Unwind the stack until we reach the nearest boundary.
let skipSiblings;
if (
// The current algorithm for both hydration and error handling assumes
// that the tree is rendered sequentially. So we always skip the siblings.
getIsHydrating() ||
suspendedReason === SuspendedOnError
) {
skipSiblings = true;
} else if (
// Check whether this is a prerender
!workInProgressRootIsPrerendering &&
// Offscreen rendering is also a form of speculative rendering
!includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
) {
// This is not a prerender. Skip the siblings during this render. A
// separate prerender will be scheduled for later.
skipSiblings = true;
workInProgressRootDidSkipSuspendedSiblings = true;
// Because we're skipping the siblings, schedule an immediate retry of
// this boundary.
if (
suspendedReason === SuspendedOnData ||
suspendedReason === SuspendedOnAction ||
suspendedReason === SuspendedOnImmediate ||
suspendedReason === SuspendedOnDeprecatedThrowPromise
) {
const boundary = getSuspenseHandler();
if (boundary !== null && boundary.tag === SuspenseComponent) {
boundary.flags |= ScheduleRetry;
}
}
} else {
// This is a prerender. Don't skip the siblings.
skipSiblings = false;
}
unwindUnitOfWork(unitOfWork, skipSiblings);
} else {
// Although the fiber suspended, we're intentionally going to commit it in
// an inconsistent state. ...
completeUnitOfWork(unitOfWork);
}
要点:默认跳过兄弟节点(skipSiblings = true)——一个组件挂起,它右边的兄弟这次都不渲染了(它们反正会被 fallback 遮住),并给边界打 ScheduleRetry,让一次并行的 prerender 去预热这些被跳过的兄弟。这是 React 18 “sibling prerendering” 优化的一部分(workInProgressRootDidSkipSuspendedSiblings / workInProgressRootIsPrerendering 两个标志就是为它服务的)。
unwind 沿 return 链向上,每层只做“拆栈”(pop 各种 context cursor、清理 effect 链表),直到抵达被 ShouldCapture 盖章的边界。这里有一个容易望文生义的细节:unwind 并不“渲染”边界,它只是把工作循环的指针挪回边界,并让边界走第二次 beginWork。第二次进入的入口就是 updateSuspenseComponent——那是 9.4 节的故事。
9.3.7 标志如何沿树上传:bubbleProperties 与 subtreeFlags
ShouldCapture 打在边界上,但外层组件(比如边界外的某个 <div> 的 Fiber)怎么知道“我下面有东西挂起了”?答案是 completeWork 阶段的 bubbleProperties(ReactFiberCompleteWork.js:793-900+):
// packages/react-reconciler/src/ReactFiberCompleteWork.js:794-817(节选)
function bubbleProperties(completedWork: Fiber) {
const didBailout =
completedWork.alternate !== null &&
completedWork.alternate.child === completedWork.child;
let newChildLanes: Lanes = NoLanes;
let subtreeFlags: Flags = NoFlags;
if (!didBailout) {
// Bubble up the earliest expiration time.
...
let child = completedWork.child;
while (child !== null) {
newChildLanes = mergeLanes(
newChildLanes,
mergeLanes(child.lanes, child.childLanes),
);
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
...
每个 fiber 完成时把 children 的 flags/subtreeFlags/lanes/childLanes 全部“ OR ”进自己的 subtreeFlags。于是边界上的 DidCapture/ShouldCapture 会沿着 complete 链一路上浮到根。commitRoot 只需要看根的 subtreeFlags 就能知道“这棵树里有没有 Suspense/Offscreen 相关的工作”(finishedWork.subtreeFlags & passiveSubtreeMask,ReactFiberWorkLoop.js:3795),无需全树扫描——这是第 3 章讲过的“subtreeFlags 冒泡”在 Suspense 上的具体应用。
与之配套的是 unwindUnitOfWork(ReactFiberWorkLoop.js 中 unwindWork 的调用方):unwind 每经过一层 Offscreen/Activity/SuspenseList,都要把 beginWork 时 push 的 handler 弹掉(popSuspenseHandler),保证“栈顶永远是当前正在渲染的层的 handler”。这就是为什么 ReactFiberSuspenseContext.js 里每个 push 函数都成对存在,且源码在 Offscreen/Activity 分支里反复注释 “push/pop mismatch”——栈的平衡是这套机制的正确性前提,错一次,后续所有 getSuspenseHandler() 都会返回错误的边界。
至此,“抛 thenable → 捕获”的前半段闭环。后半段“ping → retry”在 9.7 节。
9.4 SuspenseComponent Fiber 的结构
9.4.1 一个 <Suspense> = 三个 Fiber
用户写的:
<Suspense fallback={<Spinner />}>
<RealContent />
</Suspense>
在 Fiber 树上展开为:
flowchart TB
subgraph TREE["Fiber 树(显示 fallback 时)"]
S["SuspenseComponent<br/>tag=SuspenseComponent<br/>memoizedState=SUSPENDED_MARKER<br/>updateQueue=Set"]
P["primaryChildFragment<br/>tag=OffscreenComponent<br/>mode='hidden'<br/>memoizedState=OffscreenState{baseLanes,cachePool}<br/>childLanes=剩余工作"]
F["fallbackChildFragment<br/>普通 Fragment Fiber<br/>渲染 "]
S -->|child| P
P -->|sibling| F
P -.->|"内部子树被隐藏(display:none)但保留"| RC["RealContent 子树"]
F --> SP["Spinner"]
end
style S fill:#f9f,stroke:#333
style P fill:#bbf,stroke:#333
style F fill:#bfb,stroke:#333
三个节点各司其职:
| Fiber | tag | 职责 |
|---|---|---|
| 边界本体 | SuspenseComponent | 状态机载体(memoizedState)、retry 队列(updateQueue)、被 ShouldCapture/DidCapture 盖章的对象 |
| 主内容 fragment | OffscreenComponent(mode 为 hidden/visible) | 包住真实 children。显示 fallback 时它处于 hidden——DOM 被 display:none 隐藏但保留,state 不丢 |
| fallback fragment | 普通 Fragment | 渲染 fallback prop |
“主内容用 Offscreen fiber 包装”这个设计是 React 18 的骨架级改动:Suspense 的“隐藏内容”复用了 Offscreen 的隐藏机制,所以 Suspense 天然获得了“保留 state、延迟 effect、可预热”的能力。源码里 mountSuspensePrimaryChildren 直接调用 mountWorkInProgressOffscreenFiber 就是证据:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2649-2667
function mountSuspensePrimaryChildren(
workInProgress: Fiber,
primaryChildren: $FlowFixMe,
renderLanes: Lanes,
) {
const mode = workInProgress.mode;
const primaryChildProps: OffscreenProps = {
mode: 'visible',
children: primaryChildren,
};
const primaryChildFragment = mountWorkInProgressOffscreenFiber(
primaryChildProps,
mode,
renderLanes,
);
primaryChildFragment.return = workInProgress;
workInProgress.child = primaryChildFragment;
return primaryChildFragment;
}
9.4.2 边界的状态机:memoizedState 的三种取值
SuspenseComponent 的 memoizedState 是它的“档位”:
// packages/react-reconciler/src/ReactFiberSuspenseComponent.js:24-43
// A null SuspenseState represents an unsuspended normal Suspense boundary.
// A non-null SuspenseState means that it is blocked for one reason or another.
// - A non-null dehydrated field means it's blocked pending hydration.
// - A non-null dehydrated field can use isSuspenseInstancePending or
// isSuspenseInstanceFallback to query the reason for being dehydrated.
// - A null dehydrated field means it's blocked by something suspending and
// we're currently showing a fallback instead.
export type SuspenseState = {
// If this boundary is still dehydrated, we store the SuspenseInstance
// here to indicate that it is dehydrated (flag) and for quick access
// to check things like isSuspenseInstancePending.
dehydrated: null | SuspenseInstance,
treeContext: null | TreeContext,
// Represents the lane we should attempt to hydrate a dehydrated boundary at.
// OffscreenLane is the default for dehydrated boundaries.
// NoLane is the default for normal boundaries, which turns into "normal" pri.
retryLane: Lane,
// Stashed Errors that happened while attempting to hydrate this boundary.
hydrationErrors: Array<CapturedValue<mixed>> | null,
};
三个档位:
memoizedState === null:正常态,显示主内容。memoizedState = SUSPENDED_MARKER:因挂起而显示 fallback。这是一个共享的冻结常量:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2287-2292
const SUSPENDED_MARKER: SuspenseState = {
dehydrated: null,
treeContext: null,
retryLane: NoLane,
hydrationErrors: null,
};
用常量而不是 {} 的好处:省一次对象分配,且 === 比较即可判断“处于 fallback”(commit 阶段大量使用这个比较,如 ReactFiberCommitWork.js:2533)。
memoizedState = {dehydrated: SuspenseInstance, ...}:SSR 脱水边界,等待水合(9.9 节)。
primaryChildFragment 上的 OffscreenState 则是另一套:
// packages/react-reconciler/src/ReactFiberOffscreenComponent.js:37-43
// We use the existence of the state object as an indicator that the component
// is hidden.
export type OffscreenState = {
// TODO: This doesn't do anything, yet. It's always NoLanes. But eventually it
// will represent the pending work that must be included in the render in
// order to unhide the component.
baseLanes: Lanes,
cachePool: SpawnedCachePool | null,
};
cachePool 是关键:Offscreen 隐藏时把 Cache(React.unstable_Cache,19 中对应 use + cache 体系)的 pool “收拢”起来,重新显示时恢复——这样隐藏期间 cache 里的数据不会泄漏也不会丢失。mountSuspenseOffscreenState / updateSuspenseOffscreenState 就是干这个的:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2294-2332
function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState {
return {
baseLanes: renderLanes,
cachePool: getSuspendedCache(),
};
}
function updateSuspenseOffscreenState(
prevOffscreenState: OffscreenState,
renderLanes: Lanes,
): OffscreenState {
let cachePool: SpawnedCachePool | null = null;
const prevCachePool: SpawnedCachePool | null = prevOffscreenState.cachePool;
if (prevCachePool !== null) {
const parentCache = isPrimaryRenderer
? CacheContext._currentValue
: CacheContext._currentValue2;
if (prevCachePool.parent !== parentCache) {
// Detected a refresh in the parent. This overrides any previously
// suspended cache.
cachePool = {
parent: parentCache,
pool: parentCache,
};
} else {
// We can reuse the cache from last time. The only thing that would have
// overridden it is a parent refresh, which we checked for above.
cachePool = prevCachePool;
}
} else {
// If there's no previous cache pool, grab the current one.
cachePool = getSuspendedCache();
}
return {
baseLanes: mergeLanes(prevOffscreenState.baseLanes, renderLanes),
cachePool,
};
}
updateSuspenseOffscreenState 的 baseLanes 是合并而非覆盖的:边界每次挂起/重试,把所有见过的 lanes 累积起来,childLanes 则记录“主内容里还剩多少没干完的活”(见下面的 getRemainingWorkInPrimaryTree)。这两个字段合起来回答了一个重要问题:“retry 的时候,到底要不要重新渲染主内容?”——beginWork 的 SuspenseComponent 分支正是用 primaryChildFragment.childLanes 与当前 renderLanes 做交集判断的(ReactFiberBeginWork.js:4070-4075):
const primaryChildFragment: Fiber = workInProgress.child as any;
const primaryChildLanes = primaryChildFragment.childLanes;
if (
contextChanged ||
includesSomeLane(renderLanes, primaryChildLanes)
) {
// The primary children have pending work. Use the normal path
// to attempt to render the primary children again.
return updateSuspenseComponent(current, workInProgress, renderLanes);
} else {
// The primary child fragment does not have pending work marked
// on it
pushPrimaryTreeSuspenseHandler(workInProgress);
// The primary children do not have pending work with sufficient
// priority. Bailout.
...
即“主内容还有这个优先级的活”才值得进 updateSuspenseComponent 重试主内容,否则直接 bailout。这是 Suspense 与 lane 系统(第 8 章)的接缝。
9.4.3 updateSuspenseComponent 逐段拆解
这是 Suspense 渲染逻辑的总入口(ReactFiberBeginWork.js:2383-2647)。我们按“判定 → 挂载 → 更新”三段读。
第一段:决定 showFallback。
// packages/react-reconciler/src/ReactFiberBeginWork.js:2388-2412
const nextProps: SuspenseProps = workInProgress.pendingProps;
// This is used by DevTools to force a boundary to suspend.
if (__DEV__) {
if (shouldSuspend(workInProgress)) {
workInProgress.flags |= DidCapture;
}
}
let showFallback = false;
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
if (
didSuspend ||
shouldRemainOnFallback(current, workInProgress, renderLanes)
) {
// Something in this boundary's subtree already suspended. Switch to
// rendering the fallback children.
showFallback = true;
workInProgress.flags &= ~DidCapture;
}
// Check if the primary children spawned a deferred task (useDeferredValue)
// during the first pass.
const didPrimaryChildrenDefer = (workInProgress.flags & DidDefer) !== NoFlags;
workInProgress.flags &= ~DidDefer;
showFallback = true 的两个来源:
didSuspend——刚刚被 throwException 盖了DidCapture(消费后立刻清除,防重复触发);shouldRemainOnFallback——这次渲染并不是因为挂起,但边界当前处于 fallback,且 SuspenseList 要求它继续留在 fallback:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2335-2363
function shouldRemainOnFallback(
current: null | Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// If we're already showing a fallback, there are cases where we need to
// remain on that fallback regardless of whether the content has resolved.
// For example, SuspenseList coordinates when nested content appears.
if (current !== null) {
const suspenseState: SuspenseState = current.memoizedState;
if (suspenseState === null) {
// Currently showing content. Don't hide it, even if ForceSuspenseFallback
// is true. More precise name might be "ForceRemainSuspenseFallback".
// Note: This is a factoring smell. Can't remain on a fallback if there's
// no fallback to remain on.
return false;
}
}
// Not currently showing content. Consult the Suspense context.
const suspenseContext: SuspenseContext = suspenseStackCursor.current;
return hasSuspenseListContext(
suspenseContext,
ForceSuspenseFallback as SuspenseContext,
);
}
两个条件叠加:当前就在 fallback + 上下文里带着 ForceSuspenseFallback 位(只有 SuspenseList 会设置这个位)。这保证了“已经在显示内容的边界,绝不会因为 SuspenseList 的批量策略被强行按回 fallback”——内容不闪是底线。
didPrimaryChildrenDefer 记录主内容渲染时是否有 useDeferredValue 派生了 deferred 任务(第 8 章),有的话 retry 时要按 deferred 优先级补上:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2365-2381
function getRemainingWorkInPrimaryTree(
current: Fiber | null,
primaryTreeDidDefer: boolean,
renderLanes: Lanes,
) {
let remainingLanes =
current !== null ? removeLanes(current.childLanes, renderLanes) : NoLanes;
if (primaryTreeDidDefer) {
// A useDeferredValue hook spawned a deferred task inside the primary tree.
// Ensure that we retry this component at the deferred priority.
remainingLanes = mergeLanes(remainingLanes, peekDeferredLane());
}
return remainingLanes;
}
第二段:挂载路径(current === null)。 三个分支:hydration(9.9 节)、showFallback、CPU Suspense、正常渲染。看 showFallback 分支:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2462-2500
if (showFallback) {
pushFallbackTreeSuspenseHandler(workInProgress);
mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = workInProgress.child as any;
primaryChildFragment.memoizedState =
mountSuspenseOffscreenState(renderLanes);
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
current,
didPrimaryChildrenDefer,
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
...
return bailoutOffscreenComponent(null, primaryChildFragment);
} else if (enableCPUSuspense && nextProps.defer === true) {
// This is a CPU-bound tree. Skip this tree and show a placeholder to
// unblock the surrounding content. Then immediately retry after the
// initial commit.
...
workInProgress.lanes = SomeRetryLane;
return bailoutOffscreenComponent(null, primaryChildFragment);
} else {
pushPrimaryTreeSuspenseHandler(workInProgress);
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
renderLanes,
);
}
- 显示 fallback 时:主内容被构建成
mode: 'hidden'的 Offscreen fiber,但不渲染其 children——bailoutOffscreenComponent(null, primaryChildFragment)直接跳过主内容的整棵树(它的 childLanes 记住“这里还有活”),只渲染 fallback fragment。这就是“fallback 出现时主内容一行都没渲染”的源码证据:不是渲染了再藏,是根本没渲染。 - CPU Suspense(
<Suspense defer>,实验特性):与数据无关,纯粹因为“这棵树 CPU 开销大,先让周围内容出图,再用 RetryLane 立刻重试”。workInProgress.lanes = SomeRetryLane是“自己给自己安排重试”的手法。 - 正常路径:
pushPrimaryTreeSuspenseHandler把本边界压上 handler 栈(之后的子组件挂起都归它管),然后只构建主内容 fragment。
第三段:更新路径(current !== null)。 非 hydration 时核心是两个函数。先看“切到 fallback”:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2563-2621(节选)
if (showFallback) {
pushFallbackTreeSuspenseHandler(workInProgress);
const nextFallbackChildren = nextProps.fallback;
const nextPrimaryChildren = nextProps.children;
updateSuspenseFallbackChildren(
current,
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = workInProgress.child as any;
const prevOffscreenState: OffscreenState | null = (current.child as any)
.memoizedState;
primaryChildFragment.memoizedState =
prevOffscreenState === null
? mountSuspenseOffscreenState(renderLanes)
: updateSuspenseOffscreenState(prevOffscreenState, renderLanes);
...
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(...);
workInProgress.memoizedState = SUSPENDED_MARKER;
return bailoutOffscreenComponent(current.child, primaryChildFragment);
} else {
...
pushPrimaryTreeSuspenseHandler(workInProgress);
const nextPrimaryChildren = nextProps.children;
const primaryChildFragment = updateSuspensePrimaryChildren(
current,
workInProgress,
nextPrimaryChildren,
renderLanes,
);
workInProgress.memoizedState = null;
return primaryChildFragment;
}
注意 update 路径里 memoizedState 的赋值:fallback 分支设 SUSPENDED_MARKER,主内容分支设 null。这就是“档位切换”的字面发生地。而“显示 fallback 时旧的主内容怎么办”由 updateSuspenseFallbackChildren 回答:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2790-2878(节选)
function updateSuspenseFallbackChildren(
current: Fiber,
workInProgress: Fiber,
primaryChildren: $FlowFixMe,
fallbackChildren: $FlowFixMe,
renderLanes: Lanes,
) {
const mode = workInProgress.mode;
const currentPrimaryChildFragment: Fiber = current.child as any;
const currentFallbackChildFragment: Fiber | null =
currentPrimaryChildFragment.sibling;
const primaryChildProps: OffscreenProps = {
mode: 'hidden',
children: primaryChildren,
};
let primaryChildFragment;
if (
!disableLegacyMode &&
(mode & ConcurrentMode) === NoMode &&
workInProgress.child !== currentPrimaryChildFragment
) {
... // legacy 分支,复用第一遍渲染的半成品
} else {
primaryChildFragment = updateWorkInProgressOffscreenFiber(
currentPrimaryChildFragment,
primaryChildProps,
);
// Since we're reusing a current tree, we need to reuse the flags, too.
// (We don't do this in legacy mode, because in legacy mode we don't re-use
// the current tree; see previous branch.)
primaryChildFragment.subtreeFlags =
currentPrimaryChildFragment.subtreeFlags & StaticMask;
}
let fallbackChildFragment;
if (currentFallbackChildFragment !== null) {
fallbackChildFragment = createWorkInProgress(
currentFallbackChildFragment,
fallbackChildren,
);
} else {
fallbackChildFragment = createFiberFromFragment(
fallbackChildren,
mode,
renderLanes,
null,
);
// Needs a placement effect because the parent (the Suspense boundary) already
// mounted but this is a new fiber.
fallbackChildFragment.flags |= Placement;
}
fallbackChildFragment.return = workInProgress;
primaryChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
workInProgress.child = primaryChildFragment;
return bailoutOffscreenComponent(null, primaryChildFragment);
}
并发路径下,旧主内容 fiber 被 updateWorkInProgressOffscreenFiber(即 createWorkInProgress,双缓冲的克隆)原样接管,只是 props 的 mode 从 visible 改成 hidden。主内容的子树(DOM、state、effect 链表)原封不动,只是不再被遍历(bailoutOffscreenComponent)。这就是 React 18 起“切到 fallback 后旧内容还在 DOM 里(display:none)”的实现。
subtreeFlags 的保留也很讲究:StaticMask 只保留静态类 flag(如 Snapshot、Callback 之外的持久位),把渲染中产生的动态 flag 清掉——旧子树里“未完成的活”作废,但结构信息保留。
反过来,“从 fallback 切回主内容”由 updateSuspensePrimaryChildren 处理:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2753-2788
function updateSuspensePrimaryChildren(
current: Fiber,
workInProgress: Fiber,
primaryChildren: $FlowFixMe,
renderLanes: Lanes,
) {
const currentPrimaryChildFragment: Fiber = current.child as any;
const currentFallbackChildFragment: Fiber | null =
currentPrimaryChildFragment.sibling;
const primaryChildFragment = updateWorkInProgressOffscreenFiber(
currentPrimaryChildFragment,
{
mode: 'visible',
children: primaryChildren,
},
);
...
primaryChildFragment.return = workInProgress;
primaryChildFragment.sibling = null;
if (currentFallbackChildFragment !== null) {
// Delete the fallback child fragment
const deletions = workInProgress.deletions;
if (deletions === null) {
workInProgress.deletions = [currentFallbackChildFragment];
workInProgress.flags |= ChildDeletion;
} else {
deletions.push(currentFallbackChildFragment);
}
}
workInProgress.child = primaryChildFragment;
return primaryChildFragment;
}
旧 fallback 被塞进 deletions 列表——ChildDeletion 会在 commit 阶段把它从 DOM 卸载。一进一出,非常对称:显示 fallback = 旧主内容转 hidden;显示主内容 = 旧 fallback 删除。
9.4.4 beginWork 入口处的“早退”决策
最后把视角拉回 beginWork 的 dispatch(ReactFiberBeginWork.js:4375):
// packages/react-reconciler/src/ReactFiberBeginWork.js:4375-4377
case SuspenseComponent: {
return updateSuspenseComponent(current, workInProgress, renderLanes);
}
而在这之前,attemptEarlyBailoutIfNoScheduledUpdate 里还有一段 SuspenseComponent 特判(ReactFiberBeginWork.js:4039-4100,9.4.2 节引过半段),处理“边界处于 fallback 时,这次更新到底该不该重试主内容”以及“dehydrated 边界不许渲染 children”。它与 updateSuspenseComponent 的分工是:
attemptEarlyBailoutIfNoScheduledUpdate:廉价的预筛(看 childLanes、context),没活就 bailout;updateSuspenseComponent:真正的两棵树二选一。
至此,SuspenseComponent 的结构与渲染决策全部讲完。下一节看这些结构在“时间维度”上的表现——fallback 何时出现、何时退场。
9.4.5 completeWork 中的 SuspenseComponent:第二遍渲染与 Visibility 调度
updateSuspenseComponent 只是“声明渲染哪棵树”,边界的收尾工作在 completeWork(ReactFiberCompleteWork.js:1549-1665)。这里藏着 Suspense “两遍渲染”的最后一环。
第二遍渲染的触发点:
// packages/react-reconciler/src/ReactFiberCompleteWork.js:1585-1598
popSuspenseHandler(workInProgress);
if ((workInProgress.flags & DidCapture) !== NoFlags) {
// Something suspended. Re-render with the fallback children.
workInProgress.lanes = renderLanes;
if (
enableProfilerTimer &&
(workInProgress.mode & ProfileMode) !== NoMode
) {
transferActualDuration(workInProgress);
}
// Don't bubble properties in this case.
return workInProgress;
}
如果边界完成时仍带着 DidCapture(子树挂起了),completeWork 把边界自己作为下一个工作单元返回(return workInProgress)——于是 completeUnitOfWork 把 workInProgress 指向边界,performUnitOfWork 再次 beginWork(边界),这次 updateSuspenseComponent 读到 DidCapture,走 fallback 分支。这就是“第一遍渲染主内容、挂起后第二遍渲染 fallback”的循环机制:不是递归函数,而是工作循环里“把自己重新入队”。注意 workInProgress.lanes = renderLanes——强制让边界保持“有活”,否则 attemptEarlyBailoutIfNoScheduledUpdate 可能把它跳过。
Visibility 效果的精确调度:
// packages/react-reconciler/src/ReactFiberCompleteWork.js:1628-1651
// If the suspended state of the boundary changes, we need to schedule
// a passive effect, which is when we process the transitions
if (nextDidTimeout !== prevDidTimeout) {
if (enableTransitionTracing) {
const offscreenFiber: Fiber = workInProgress.child as any;
offscreenFiber.flags |= Passive;
}
// If the suspended state of the boundary changes, we need to schedule
// an effect to toggle the subtree's visibility. When we switch from
// fallback -> primary, the inner Offscreen fiber schedules this effect
// as part of its normal complete phase. But when we switch from
// primary -> fallback, the inner Offscreen fiber does not have a complete
// phase. So we need to schedule its effect here.
//
// We also use this flag to connect/disconnect the effects, but the same
// logic applies: when re-connecting, the Offscreen fiber's complete
// phase will handle scheduling the effect. It's only when the fallback
// is active that we have to do anything special.
if (nextDidTimeout) {
const offscreenFiber: Fiber = workInProgress.child as any;
offscreenFiber.flags |= Visibility;
}
}
const retryQueue: RetryQueue | null = workInProgress.updateQueue as any;
scheduleRetryEffect(workInProgress, retryQueue);
两个非对称细节,都是“主内容在 fallback 期间不参与渲染”的直接后果:
- fallback → primary 时,主内容 Offscreen fiber 有正常的 complete 阶段,自己会调度
Visibility(ReactFiberCommitWork.js的 Offscreen complete 分支比较prevIsHidden !== nextIsHidden,见ReactFiberCompleteWork.js:1977-1989); - primary → fallback 时,主内容 fiber 被 bailout、没有 complete 阶段,所以必须由边界在 completeWork 里代替它打
Visibility标志。
漏掉这个标志的后果是:主内容不会从 display:none 恢复,用户看到 fallback 永远不消失。这种“代替被跳过的子树打标记”的模式在 Suspense 源码里反复出现(ReactFiberCommitWork.js:2519-2526 的 TODO 也在吐槽这种脆弱性:“this offscreen fiber might not be part of the work-in-progress tree!”)。
cache 的保留与释放:
// packages/react-reconciler/src/ReactFiberCompleteWork.js:1605-1626
if (nextDidTimeout) {
const offscreenFiber: Fiber = workInProgress.child as any;
let previousCache: Cache | null = null;
if (
offscreenFiber.alternate !== null &&
offscreenFiber.alternate.memoizedState !== null &&
offscreenFiber.alternate.memoizedState.cachePool !== null
) {
previousCache = offscreenFiber.alternate.memoizedState.cachePool.pool;
}
let cache: Cache | null = null;
if (
offscreenFiber.memoizedState !== null &&
offscreenFiber.memoizedState.cachePool !== null
) {
cache = offscreenFiber.memoizedState.cachePool.pool;
}
if (cache !== previousCache) {
// Run passive effects to retain/release the cache.
offscreenFiber.flags |= Passive;
}
}
cachePool 换pool时给 Offscreen 打 Passive——passive 阶段负责 retain/release cache 引用(commitOffscreenPassiveMountEffects 等)。这是“隐藏子树里的 cache 数据在 fallback 期间不泄漏、恢复时不丢失”的执行点。
脱水边界的提前返回:completeWork 的 SuspenseComponent 分支开头(ReactFiberCompleteWork.js:1549-1583)先判 dehydrated:completeDehydratedSuspenseBoundary 处理水合收尾,没收完且带 ForceClientRender 时 return workInProgress 让边界立刻重渲染(转 client render),否则 return null 留着下次。水合与普通挂起在 completeWork 层面正式分家。
9.5 边界语义:fallback 何时出现、何时退场
9.5.1 四类挂起场景与退出状态
throwException 里根据 shellBoundary 分出两种记录(renderDidSuspend / renderDidSuspendDelayIfPossible),对应到工作循环的退出状态(ReactFiberWorkLoop.js:427-434):
| 退出状态 | 含义 | 后续行为 |
|---|---|---|
RootInProgress | 还在渲染 | 继续 |
RootCompleted | 正常完成 | commit |
RootSuspended | 挂起了,但可以提交 fallback | commit fallback |
RootSuspendedWithDelay | 挂起了,且不应该提交(shell 挂起或节流中) | 不 commit,等数据/ping/超时 |
RootSuspendedAtTheShell | 选择性水合打断 | 切到 hydration lane 重来 |
RootErrored | 错误 | 走错误恢复 |
renderDidSuspendDelayIfPossible 全文(ReactFiberWorkLoop.js:2552-2596):
export function renderDidSuspendDelayIfPossible(): void {
workInProgressRootExitStatus = RootSuspendedWithDelay;
if (
!workInProgressRootDidSkipSuspendedSiblings &&
// Check if the root will be blocked from committing.
(includesOnlyTransitions(workInProgressRootRenderLanes) ||
getSuspenseHandler() === null)
) {
// This render may not have originally been scheduled as a prerender, but
// something suspended inside the visible part of the tree, which means we
// won't be able to commit a fallback anyway. Let's proceed as if this were
// a prerender so that we can warm up the siblings without scheduling a
// separate pass.
workInProgressRootIsPrerendering = true;
}
// Check if there are updates that we skipped tree that might have unblocked
// this render.
if (
(includesNonIdleWork(workInProgressRootSkippedLanes) ||
includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)) &&
workInProgressRoot !== null
) {
// Mark the current render as suspended so that we switch to working on
// the updates that were skipped. ...
const didAttemptEntireTree = false;
markRootSuspended(
workInProgressRoot,
workInProgressRootRenderLanes,
workInProgressDeferredLane,
didAttemptEntireTree,
);
}
}
三个动作:① 设 RootSuspendedWithDelay;② 符合条件的渲染升级为“prerender 模式”(顺带预热被跳过的兄弟);③ 如果有被跳过的非 idle 更新,立刻 markRootSuspended 让出——转去干更紧急的活,这是 lane 系统与 Suspense 的联动。
9.5.2 “完成之后的决策”:finishConcurrentRender
渲染跑完后(或被 suspend 后),finishConcurrentRender(ReactFiberWorkLoop.js:1420-1576)根据退出状态做最终决策:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:1435-1465
case RootSuspendedWithDelay: {
if (!includesOnlyTransitions(lanes) && !includesOnlyRetries(lanes)) {
// Commit the placeholder.
break;
}
}
// Fallthrough
case RootSuspendedAtTheShell: {
// This is a transition, so we should exit without committing a
// placeholder and without scheduling a timeout. Delay indefinitely
// until we receive more data.
...
const didAttemptEntireTree = !workInProgressRootDidSkipSuspendedSiblings;
markRootSuspended(
root,
lanes,
workInProgressDeferredLane,
didAttemptEntireTree,
);
return;
}
这是 React 18 最著名的行为改进的源码:
RootSuspendedWithDelay且 lanes 是纯 transition/retry:不提交。这就是“startTransition中导航到新页面,数据没就绪时保持旧界面”的机制——旧界面赖着不走,直到数据就绪后一次性呈现新界面。RootSuspendedWithDelay但不是 transition(比如默认优先级的更新在 shell 里挂起):穿透到 commit,显示 fallback(“Commit the placeholder”)——普通更新不能无限挂起,必须给用户一个加载态。- retry 的节流:
RootSuspended+ 纯 retry lanes 时,检查距上次 fallback 是否超过FALLBACK_THROTTLE_MS(300ms),没超就把 commit 挂一个定时器推迟:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:1503-1557(节选)
if (
includesOnlyRetries(lanes) &&
(alwaysThrottleRetries || exitStatus === RootSuspended)
) {
// This render only included retries, no updates. Throttle committing
// retries so that we don't show too many loading states too quickly.
const msUntilTimeout =
globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
// Don't bother with a very short suspense time.
if (msUntilTimeout > 10) {
...
// The render is suspended, it hasn't timed out, and there's no
// lower priority work to do. Instead of committing the fallback
// immediately, wait for more data to arrive.
pendingEffectsLanes = lanes;
root.timeoutHandle = scheduleTimeout(
completeRootWhenReady.bind(null, root, finishedWork, ...),
msUntilTimeout,
);
return;
}
}
即:retry 渲染成功后不急着 commit,攒一攒。globalMostRecentFallbackTime 由 markCommitTimeOfFallback() 更新(ReactFiberWorkLoop.js:2535-2537),记录点是 commit 阶段 fallback 出现/消失时(ReactFiberCommitWork.js:2531-2551)。300ms 窗口 + “是否 transition”两个旋钮,共同决定加载态出现的节奏。alwaysThrottleRetries 在 19.3.1 中恒为 true(shared/ReactFeatureFlags.js:135),意味着所有纯 retry 提交都会被节流。
9.5.3 嵌套边界:由内向外 vs 由外向内
嵌套边界的行为完全由“谁先被盖章”决定,而盖章顺序就是渲染顺序(深度优先)。考虑:
<Suspense fallback={<Outer />}> {/* 边界 A */}
<Suspense fallback={<Inner />}> {/* 边界 B */}
<Slow />
</Suspense>
</Suspense>
Slow 挂起时,getSuspenseHandler() 返回的是栈顶——B(后 push 的)。所以 B 被盖章 ShouldCapture,unwind 到 B 就停:显示 <Inner />,A 及其余内容保持不动。这就是“最近的边界接住”的语义。
但有一个重要例外——A 已经在显示 fallback 的情况。此时 B 的 children 从未真正渲染过(第一次挂起时 B 直接 bailout 主内容只渲染 fallback)。那么“B 的主内容里”再挂起呢?答案:pushFallbackTreeSuspenseHandler 在渲染 B 的 fallback 时把 handler 栈恢复成了 A(“catch 块里再抛,同一个 catch 接不住”),所以 B 的 fallback 内部挂起会穿透到 A。行为上就是:外层 fallback 替换内层 fallback。这防止了“fallback 套 fallback”的无限递归。
还有一种情况:B 显示主内容、A 显示 fallback(A 的子树里 B 已经 resolve 了,但 A 的其它 children 没 resolve)。此时 A 的 update 让 B 重新渲染,B 的数据又挂了 → B 盖章,显示 <Inner />,而 A 的 fallback 还活着——嵌套边界各自独立升降,互不牵连(除非 A 根本没 fallback 可退)。
9.5.4 “内容不闪”的三道防线
React 18/19 的“避免加载态闪烁”不是单一机制,而是三套互补的策略,按生效范围从小到大:
- 微任务检查(
SuspendedOnImmediate→SuspendedAndReadyToContinue):数据在让出的一帧内 resolve,原地 replay,界面完全无感。 - transition 保留旧内容(
RootSuspendedWithDelay+ 纯 transition lanes 不 commit):startTransition包裹的更新挂起,旧界面原地不动。 - 节流(
FALLBACK_THROTTLE_MS):连续的 retry 攒批提交,避免 fallback “闪一下就没了”。
反过来,如果用户没有用 startTransition,shell 里挂起会穿透节流直接显示 fallback(!includesOnlyTransitions && !includesOnlyRetries → break 到 commit)——这是刻意的设计:非 transition 的更新用户期望“有响应”,宁可给 loading 也不能干等。
9.5.5 unstable_avoidThisFallback:源码里的第五道防线
ReactFiberSuspenseContext.js 里还有一个实验开关,值得一看,因为它展示了“不理想 fallback”的完整判定逻辑:
// packages/react-reconciler/src/ReactFiberSuspenseContext.js:57-83
// Experimental feature: Some Suspense boundaries are marked as having an
// undesirable fallback state. These have special behavior where we only
// activate the fallback if there's no other boundary on the stack that we can
// use instead.
if (
enableSuspenseAvoidThisFallback &&
props.unstable_avoidThisFallback === true &&
// If an avoided boundary is already visible, it behaves identically to
// a regular Suspense boundary.
(current === null || isCurrentTreeHidden())
) {
if (shellBoundary === null) {
// We're rendering in the shell. There's no parent Suspense boundary that
// can provide a desirable fallback state. We'll use this boundary.
push(suspenseHandlerStackCursor, handler, handler);
// However, because this is not a desirable fallback, the children are
// still considered part of the shell. So we intentionally don't assign
// to `shellBoundary`.
} else {
// There's already a parent Suspense boundary that can provide a desirable
// fallback state. Prefer that one.
const handlerOnStack = suspenseHandlerStackCursor.current;
push(suspenseHandlerStackCursor, handlerOnStack, handler);
}
return;
}
被标记 unstable_avoidThisFallback 的边界,其 children 挂起时跳过它,用外层更“理想”的边界兜底;只有当它在 shell 里(没有更外层边界可用)才亲自出马。19.3.1 中该 flag 默认关闭(shared/ReactFeatureFlags.js:123 enableSuspenseAvoidThisFallback = false),但代码完整保留,是理解"理想/不理想 fallback"概念的最好样本。
上篇源码索引表(覆盖 9.0 ~ 9.5)
路径相对 packages/react-reconciler/src/。
| 主题 | 文件 | 关键位置 |
|---|---|---|
| thenable 追踪 | ReactFiberThenable.js | 全文 479 行;trackUsedThenable、getSuspendedThenable |
| 抛出分派 | ReactFiberThrow.js | throwException(:364)、markSuspenseBoundaryShouldCapture(:241) |
| ping 监听注册 | ReactFiberWorkLoop.js | attachPingListener(:4970) |
use 原语 | ReactFiberHooks.js | use(:1159)、useThenable(:1098) |
| Suspense 边界 fiber | ReactFiberSuspenseComponent.js | 全文 107 行;SuspenseState |
| 边界上下文栈 | ReactFiberSuspenseContext.js | 全文 234 行;suspenseHandlerStackCursor、suspenseStackCursor |
| begin work 中的边界处理 | ReactFiberBeginWork.js | updateSuspenseComponent(:2383)、mountSuspenseOffscreenState(:2294)、updateSuspenseFallbackChildren(:2790) |
| complete work 中的标记 | ReactFiberCompleteWork.js | 边界完成时的 flag 处理 |
| commit 阶段的边界回调 | ReactFiberCommitWork.js | commitSuspenseCallback、hydration callbacks |
| 挂起与恢复主循环 | ReactFiberWorkLoop.js | handleThrow、renderRootConcurrent 的 catch 分支 |
上篇自测 FAQ(覆盖 9.0 ~ 9.5)
Q1:React 为什么不直接用原生 Promise,而要造 thenable 抽象?
因为 React 需要"可重复读取"的语义:Promise 的 .then 只能消费一次结果,而渲染可能被打断并重跑多次。React 的 thenable 要求实现 status(pending/fulfilled/rejected)可被同步读取,这样重跑时不必重新发起请求。
Q2:抛出 Promise 之后,React 怎么知道该挂起哪个边界?
靠 suspenseHandlerStackCursor 这条栈。渲染进入 <Suspense> 时压栈,throwException 弹出最近一个能接住的处理者(还要检查 shouldCapture 与"是否是首次挂载"等条件)。
Q3:markSuspenseBoundaryShouldCapture 标记了就一定显示 fallback 吗?
不一定。它只是标记"这个边界应当捕获"。真正显示 fallback 还要经过 RootSuspended/RootSuspendedWithDelay 的判定——如果是 transition 更新且允许延迟,React 会先不提交 fallback。
Q4:Offscreen fiber 在这里起什么作用?
它是边界的"隐藏容器"。挂起时主内容被包进 Offscreen 并以 mode: 'hidden' 渲染,DOM 保留但 display: none;恢复时只需改回 visible。这避免了销毁重建的开销。
Q5:为什么 fallback 退场需要 SuspenseOffscreen 的 DidCapture 标志?
因为 React 要区分"这是第一次挂载所以显示 fallback"和"这是恢复所以直接显示内容"。DidCapture 清除后,重渲染才会走正常分支把 Offscreen 从 hidden 切回 visible。
Q6:ping 是什么?
thenable settle 后调用当初注册的回调(attachPingListener 挂上去的),把对应 lane 重新标脏并触发一次新渲染。这是"挂起 → 等待 → 自动恢复"闭环的最后一环。
Q7:use() 和"在组件里 await"有什么区别?
use() 不阻塞渲染流程——它抛出 thenable 让 React 挂起子树,组件其余部分不执行;而 await 会要求整个组件是 async 函数,React 19 之前无法直接渲染。use() 让"读异步资源"保持同步写法。
Q8:多个并列的 Suspense 边界,一个挂起会不会连累另一个?
不会,前提是它们各自独立。throwException 只找最近的祖先边界;兄弟边界互不影响。除非它们共享同一个祖先边界,此时祖先会统一挂起。
Q9:ReactFiberSuspenseContext.js 里为什么有两条栈?
suspenseHandlerStackCursor 回答"谁能接住这次抛出"(写入侧),suspenseStackCursor 回答"当前渲染处于哪个边界内"(读取侧)。两者用途不同,因此分开维护。
Q10:上篇和下篇怎么分工?
上篇(本篇)讲核心机制:thenable 抽象、抛出链路、边界 fiber 结构、fallback 语义。下篇《第09章B_lazy、retry、hydration与实战》讲具体消费者:lazy 如何复用这套机制、retry 与错误边界、hydration 中的挂起,以及真实业务里的用法。