React 19.3.1 源码深度剖析-第 9 章(下)lazy、retry、hydration 与实战
本篇是《第 9 章 Suspense 与懒加载》的下半部分。上半部分(第 09 章 A)已讲透 wakeable/thenable 抽象、「抛出一个 Promise」的完整链路、SuspenseComponent Fiber 结构与边界语义。
本篇继续:
lazy()的实现、retry 队列与 ping 机制、Suspense 在 commit 阶段的处理、hydration 中的选择性水合、SuspenseList / Offscreen / Activity、React 19 的新变化,以及边界坑、性能量化与最佳实践。阅读前置:第 09 章 A、第 3 章(Fiber)、第 8 章(并发特性)。
9.6 lazy() 的实现:惰性加载与 thenable 状态缓存
9.6.1 lazy() 到底返回什么
React.lazy 是 Suspense 的第一个用户,实现只有两百行,但把"thenable 状态缓存"用到了极致。
js
// packages/react/src/ReactLazy.js:226-263
export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: Uninitialized,
_result: ctor,
};
const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer,
};
...
return lazyType;
}
返回的 lazyType 只有三个字段:$$typeof(REACT_LAZY_TYPE,供 reconciler 识别)、_payload(载荷)、_init(初始化函数)。注意 _payload 是闭包外共享的同一个对象 ------模块作用域里 const LazyComp = lazy(() => import('./X')) 只创建一次,所以 _payload 在组件的每次渲染、每个实例间共享。这就是"lazy 只加载一次"的根基:状态不在 Fiber 上,在 payload 上。
9.6.2 Payload 的四态状态机
js
// packages/react/src/ReactLazy.js:25-58
const Uninitialized = -1;
const Pending = 0;
const Resolved = 1;
const Rejected = 2;
type UninitializedPayload<T> = {
_status: -1,
_result: () => Thenable<{default: T, ...}>,
_ioInfo?: ReactIOInfo, // DEV-only
};
type PendingPayload = {
_status: 0,
_result: Wakeable,
_ioInfo?: ReactIOInfo, // DEV-only
};
type ResolvedPayload<T> = {
_status: 1,
_result: {default: T, ...},
_ioInfo?: ReactIOInfo, // DEV-only
};
type RejectedPayload = {
_status: 2,
_result: mixed,
_ioInfo?: ReactIOInfo, // DEV-only
};
_result 字段被复用了四次,含义随 _status 迁移:
_status |
_result 含义 |
|---|---|
-1 Uninitialized |
用户传入的 ctor(() => import(...)) |
0 Pending |
ctor() 返回的 thenable |
1 Resolved |
模块对象 {default: Component} |
2 Rejected |
错误对象 |
9.6.3 lazyInitializer 逐行
js
// packages/react/src/ReactLazy.js:70-224
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
...
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
// This might throw either because it's missing or throws. If so, we treat it
// as still uninitialized and try again next time. ...
thenable.then(
moduleObject => {
if (
(payload as Payload<T>)._status === Pending ||
payload._status === Uninitialized
) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = payload as any;
resolved._status = Resolved;
resolved._result = moduleObject;
...
// Make the thenable introspectable
if (thenable.status === undefined) {
const fulfilledThenable: FulfilledThenable<{default: T, ...}> =
thenable as any;
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = moduleObject;
}
}
},
error => {
if (
(payload as Payload<T>)._status === Pending ||
payload._status === Uninitialized
) {
// Transition to the next state.
const rejected: RejectedPayload = payload as any;
rejected._status = Rejected;
rejected._result = error;
...
if (thenable.status === undefined) {
const rejectedThenable: RejectedThenable<{default: T, ...}> =
thenable as any;
rejectedThenable.status = 'rejected';
rejectedThenable.reason = error;
}
}
},
);
if (payload._status === Uninitialized) {
// In case, we're still uninitialized, then we're waiting for the thenable
// to resolve. Set it as pending in the meantime.
const pending: PendingPayload = payload as any;
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
... // DEV 校验 default export
return moduleObject.default;
} else {
throw payload._result;
}
}
四个关键设计:
then注册与_status迁移的顺序 :先then,再把状态置为Pending。如果 thenable 在then注册时同步 resolve (某些打包器/预取场景),回调里看到的还是Uninitialized,条件判断里特意包含了Uninitialized------直接迁移到Resolved,连 Pending 都不经过。随后if (payload._status === Uninitialized)为假,不会覆盖。这套双条件守卫保证了同步 resolve 不出错。- thenable 补丁与 payload 补丁双轨 :
_status/_result是 payload 的状态机(lazy 自己用),thenable.status/value是 thenable 的 expando(React 的trackUsedThenable用)。两者在 resolve 回调里被同时写入------注释 "Make the thenable introspectable" 说的就是这件事。这让 lazy 的 thenable 也能被isThenableResolved同步探测。 - 错误也是状态,不是每次都抛 :Rejected 后
_result存错误,之后每次lazyInitializer都throw payload._result------同一个错误对象反复抛,错误边界每次都能拿到同一个引用(不会因为"新错误"触发重复日志)。 - DEV 校验 :Resolved 后检查
moduleObject是否有default,专门抓lazy(() => import('./X').then(m => m))这类忘记 default 的写法,错误消息直接给出正确写法示例。
9.6.4 lazy 与 Suspense 的接缝:mountLazyComponent
reconciler 如何消费 lazy:
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:2113-2153(节选)
function mountLazyComponent(
_current: null | Fiber,
workInProgress: Fiber,
elementType: any,
renderLanes: Lanes,
) {
resetSuspendedCurrentOnMountInLegacyMode(_current, workInProgress);
const props = workInProgress.pendingProps;
const lazyComponent: LazyComponentType<any, any> = elementType;
let Component = resolveLazy(lazyComponent);
...
// Store the unwrapped component in the type.
workInProgress.type = Component;
...
resolveLazy(ReactFiberThenable.js:352-371)是 lazy 挂起的入口:
js
export function resolveLazy<T>(lazyType: LazyComponentType<T, any>): T {
try {
if (__DEV__) {
return callLazyInitInDEV(lazyType);
}
const payload = lazyType._payload;
const init = lazyType._init;
return init(payload);
} catch (x) {
if (x !== null && typeof x === 'object' && typeof x.then === 'function') {
// This lazy Suspended. Treat this as if we called use() to unwrap it.
suspendedThenable = x;
if (__DEV__) {
needsToResetSuspendedThenableDEV = true;
}
throw SuspenseException;
}
throw x;
}
}
漂亮的一笔:lazyInitializer 在 Pending 状态会 throw payload._result(thenable)。resolveLazy 捕获这个 throw,发现抛的是 thenable,就转译成标准的 Suspense 挂起协议 (suspendedThenable + SuspenseException)。于是 lazy 挂起和 use() 挂起在下游完全同构------handleThrow 之后走的是同一条 throwException 链路。这就是 9.3 节说的"所有入口汇到同一协议"的第二个证据。
另一个细节:workInProgress.type = Component ------lazy fiber 第一次解析成功后,直接把 type 覆写成真实组件,tag 也改成 FunctionComponent/ClassComponent 等。之后这个 fiber 不再走 lazy 路径,与普通组件无异(解析结果被固化在 Fiber 上,这是"按 Fiber 缓存解析结果"与"payload 全局缓存"的双层结构)。
还有 legacy 模式的一个特殊路径值得知道:markSuspenseBoundaryShouldCapture 里那段"lazy 是 Suspense 边界直接子节点"的特判(ReactFiberThrow.js:260-276):
js
if (suspenseBoundary === returnFiber) {
// Special case where we suspended while reconciling the children of
// a Suspense boundary's inner Offscreen wrapper fiber. This happens
// when a React.lazy component is a direct child of a
// Suspense boundary.
//
// Suspense boundaries are implemented as multiple fibers, but they
// are a single conceptual unit. The legacy mode behavior where we
// pretend the suspended fiber committed as `null` won't work,
// because in this case the "suspended" fiber is the inner
// Offscreen wrapper.
即 <Suspense><Lazy /></Suspense> 时,挂起点是 Offscreen wrapper 的 reconcile 阶段,returnFiber 就是边界自己------legacy 语义下要特批改用并发路径的 ShouldCapture。
9.6.5 preload 与 lazy 的配合
lazy 的"惰性"意味着只有渲染到它时才发起 import()。用户体验上的问题是:点击导航 → 渲染 lazy → 开始下载 → 挂起 → 显示 fallback → 下载完 → 显示内容,下载时间全花在 fallback 上。React 19 提供了两条提前发车的路:
路线一:preload API(9.12 节详述) 。ReactDOM.preload/preinit 提前把 chunk 拉下来:
js
// packages/react-dom/src/shared/ReactDOMFloat.js:89-152(节选)
export function preload(href: string, options: PreloadOptions) {
...
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */
.L(/* preload */ href, as, {
crossOrigin,
integrity: ...,
...
});
}
路线二:先把 lazy 组件渲染进一棵隐藏树预热 (Offscreen/Activity 的经典用途,9.11 节)。<Activity mode="hidden"><LazyPage /></Activity> 会在后台渲染 lazy 组件、触发 import(),但 DOM 不可见;用户真的导航到时 chunk 已就绪,直接显示。
路线二:先把 lazy 组件渲染进一棵隐藏树预热 (Offscreen/Activity 的经典用途,9.11 节)。<Activity mode="hidden"><LazyPage /></Activity> 会在后台渲染 lazy 组件、触发 import(),但 DOM 不可见;用户真的导航到时 chunk 已就绪,直接显示。
注意 :React 没有公开的 "lazy.preload()" API。想让 import() 提前发生,标准做法是自己在模块里发起 Promise 并缓存,或使用框架(Next.js 的 router prefetch)的能力。
9.6.6 chunk 加载失败与热更新
lazy 的 rejected 分支把错误存在 payload._result 里反复抛(9.6.3 节),这意味着:
- 错误边界只触发一次恢复 :第一次 reject 时错误边界渲染错误 UI;之后如果用户重试(比如重置 key 重挂载边界),新的 lazy 挂载会重新
throw同一个错误对象------错误边界可能直接再次进入错误态,而不是"再试一次加载"。要真正重试,只能让 lazy 组件重新创建(新的 payload 从Uninitialized开始)。这是生产环境"刷新 chunk 失败后白屏"的常见根因,框架通常用window.location.reload()兜底。 - Fast Refresh 的影响 :DEV 下
mountLazyComponent会调resolveTypeForHotReloading(ReactFiberBeginWork.js:2124-2126)处理"模块替换后 lazy 组件指向了新模块"的情况;ReactFiberHotReloading.js的markFailedErrorBoundaryForHotReloading则保证热更新时错误边界不会吞掉新错误。改 lazy 组件源码时如果页面卡在错误态,保存一次通常就能恢复------这是设计使然。 lazyInitializer的容错注释 值得再读一次(ReactLazy.js:91-94):"This might throw either because it's missing or throws. If so, we treat it as still uninitialized and try again next time."------ctor()本身抛错(比如模块系统故障)不会把 payload 打成 Rejected,而是保持 Uninitialized,下次渲染再试。只有 thenable reject 才进入终态。
9.7 retry 队列与 ping:数据就绪后如何"复活"边界
9.7.1 为什么叫 ping 而不是 callback
"ping"这个词在 React 源码里专指:数据源通知 React"我 resolve 了,你们看着办" 。注意 ping 本身不携带任何数据 ------React 收到 ping 后要做的是"重新渲染试试",数据从哪来?从组件重新执行时读取(thenable 已经 fulfilled,trackUsedThenable 同步返回 value)。
这个设计的深意:React 不信任"数据变了"的推送,只信任"重新渲染的结果"。推送数据意味着 React 要维护一份数据到 UI 的映射;ping + rerender 意味着数据源可以是任何东西(Promise、cache、外部 store),React 的契约只有一个------"你好了就叫我一声"。
9.7.2 attachPingListener:渲染阶段的监听
9.3.5 节已经引过 attachPingListener 的主体,这里补齐它的去重机制:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4987-5013(节选)
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set<mixed>();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
}
}
if (!threadIDs.has(lanes)) {
workInProgressRootDidAttachPingListener = true;
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
...
wakeable.then(ping, ping);
}
root.pingCache:WeakMap<Wakeable, Set<Lanes>>(无 WeakMap 时降级 Map)。key 是 thenable,value 是"已经在这些 lanes 上挂过监听"的集合 。lanes 在这里被当作"线程 ID"------同一次渲染尝试(同一组 lanes)对同一个 thenable 只挂一个then。wakeable.then(ping, ping):resolve 和 reject 都触发同一个 ping。reject 时 ping 的意义是"别等了,赶紧重渲染,让 rejected thenable 把错误抛出来走错误边界"。- ping 之后
pingCache.delete(wakeable)发生在pingSuspendedRoot里(resolve 后不会再抛,无需再记忆)。
9.7.3 pingSuspendedRoot:收到 ping 之后的决策
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:5016-5096(节选)
function pingSuspendedRoot(
root: FiberRoot,
wakeable: Wakeable,
pingedLanes: Lanes,
) {
const pingCache = root.pingCache;
if (pingCache !== null) {
// The wakeable resolved, so we no longer need to memoize, because it will
// never be thrown again.
pingCache.delete(wakeable);
}
markRootPinged(root, pingedLanes);
...
if (
workInProgressRoot === root &&
isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes)
) {
// Received a ping at the same priority level at which we're currently
// rendering. We might want to restart this render. ...
// If we're suspended with delay, or if it's a retry, we'll always suspend
// so we can always restart.
if (
workInProgressRootExitStatus === RootSuspendedWithDelay ||
(workInProgressRootExitStatus === RootSuspended &&
includesOnlyRetries(workInProgressRootRenderLanes) &&
now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
) {
// Force a restart from the root by unwinding the stack. ...
if ((executionContext & RenderContext) === NoContext) {
prepareFreshStack(root, NoLanes);
} else {
// TODO: If this does happen during the render phase, ...
workInProgressRootPingedLanes = mergeLanes(
workInProgressRootPingedLanes,
pingedLanes,
);
}
} else {
// Even though we can't restart right now, we might get an
// opportunity later. So we mark this render as having a ping.
workInProgressRootPingedLanes = mergeLanes(
workInProgressRootPingedLanes,
pingedLanes,
);
}
// If something pings the work-in-progress render, any work that suspended
// up to this point may now be unblocked; in other words, no
// longer suspended.
if (workInProgressSuspendedRetryLanes === workInProgressRootRenderLanes) {
workInProgressSuspendedRetryLanes = NoLanes;
}
}
ensureRootIsScheduled(root);
}
三种情况:
- ping 到达时正在渲染同优先级的内容,且当前渲染已
RootSuspendedWithDelay(或节流窗口内的 retry):立刻prepareFreshStack从头重启渲染 。这是"数据在 fallback 显示前就好了,干脆整个重来"的优化------省掉一次 fallback 的提交。注意 render phase 中不能重启(会崩),降级为记录workInProgressRootPingedLanes。 - 当前渲染不能重启 :记录 pinged lanes。
markRootSuspended时看到这些 lanes 被 ping 过,就不把它们标记为 suspended------给 retry 留门。 - 无论如何
ensureRootIsScheduled(root):把根重新排进调度。ping 只是"敲门",真正干活的是后续的调度回调。
9.7.4 retryTimedOutBoundary:边界级重试
fallback 已经提交的情况下,ping 走的是另一条路------不是重启整个根,而是只重试那个边界。入口在 commit 阶段挂的 listener:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1978-2008(节选)
function attachSuspenseRetryListeners(
finishedWork: Fiber,
wakeables: RetryQueue,
) {
// If this boundary just timed out, then it will have a set of wakeables.
// For each wakeable, attach a listener so that when it resolves, React
// attempts to re-render the boundary in the primary (pre-timeout) state.
const retryCache = getRetryCache(finishedWork);
wakeables.forEach(wakeable => {
// Memoize using the boundary fiber to prevent redundant listeners.
if (!retryCache.has(wakeable)) {
retryCache.add(wakeable);
...
const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
wakeable.then(retry, retry);
}
});
}
与 attachPingListener 的分工现在完整了:
| 挂载时机 | 去重缓存 | resolve 后动作 | |
|---|---|---|---|
attachPingListener |
render 阶段(throwException) | root.pingCache(按 root+lanes) |
pingSuspendedRoot:可能重启整个渲染 |
attachSuspenseRetryListeners |
commit 阶段(mutation) | getRetryCache(finishedWork)(按 boundary fiber) |
resolveRetryWakeable → retryTimedOutBoundary:只重试该边界 |
retryCache 也存在边界 fiber 的 stateNode 上(Suspense/Activity/SuspenseList 直接放 fiber.stateNode,Offscreen 放 instance._retryCache,见 getRetryCache,ReactFiberCommitWork.js:1947-1976)。
resolveRetryWakeable → retryTimedOutBoundary:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:5137-5153
function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
// The boundary fiber (a Suspense component or SuspenseList component)
// previously was rendered in its fallback state. One of the promises that
// suspended it has resolved, which means at least part of the tree was
// likely unblocked. Try rendering again, at a new lanes.
if (retryLane === NoLane) {
retryLane = requestRetryLane(boundaryFiber);
}
// TODO: Special case idle priority?
const root = enqueueConcurrentRenderForLane(boundaryFiber, retryLane);
if (root !== null) {
markRootUpdated(root, retryLane);
ensureRootIsScheduled(root);
}
}
9.7.5 RetryLane:为什么 retry 要保持时间切片
requestRetryLane(ReactFiberWorkLoop.js:870-882):
js
function requestRetryLane(fiber: Fiber) {
// This is a fork of `requestUpdateLane` designed specifically for Suspense
// "retries" --- a special update that attempts to flip a Suspense boundary
// from its placeholder state to its primary/resolved state.
// Special cases
const mode = fiber.mode;
if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
return SyncLane as Lane;
}
return claimNextRetryLane();
}
并发模式下走 claimNextRetryLane()------在 4 个 RetryLane 之间轮询(ReactFiberLane.js:747-754):
js
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000;
...
export function SomeRetryLane: Lane = RetryLane1;
export function claimNextRetryLane(): Lane {
const lane = nextRetryLane;
nextRetryLane <<= 1;
if ((nextRetryLane & RetryLanes) === NoLanes) {
nextRetryLane = RetryLane1;
}
return lane;
}
为什么 retry 不能直接用 SyncLane,而要有独立的 RetryLane 组?三个理由,都能在源码里找到对应:
- 保持时间切片 :RetryLane 的优先级低于 InputContinuous/Default 但可被打断。retry 渲染的是"已经挂起过的子树",里面可能还有别的挂起点,也可能很大;用 SyncLane 会阻塞用户输入。
workLoopConcurrent里甚至专门说:"We yield every other 'frame' when rendering Transition or Retries. Those are blocking revealing new content."(ReactFiberWorkLoop.js:3056-3059)------retry 渲染被显式降速,每隔一帧让出一次,给输入留缝。 - 节流可识别 :
includesOnlyRetries(lanes)是节流逻辑的前提(finishConcurrentRender的includesOnlyRetries检查)。如果 retry 混用 DefaultLane,就无法区分"这是重试渲染"和"这是新更新",300ms 节流就失效了。 - 失败可重派 :retry 渲染又挂起时,需要能再次安排 retry(可能换一个 lane)。
markRootSuspended的spawnedLane参数就是干这个的------retry 期间新派生的 lane 被单独追踪(workInProgressSuspendedRetryLanes)。
RetryLane 还有 4 个这么多,是为了多个边界并发 retry 时互不污染:边界 A 的 retry 渲染被边界 B 的更新打断时,A 的 lanes 不会被 B 的 lanes "吸收",各自的状态机独立。
9.7.6 一条完整的时间线
把散落的片段串成一次标准生命周期(初始挂起 → fallback → 数据就绪 → 恢复):
图里两条分支对应 9.7.3/9.7.4 的两套 listener,是 React 对"数据到达时机"这个不确定性的双保险。
9.8 Suspense 在 commit 阶段
fallback 的"出现"和"消失"最终都是 DOM 操作,这一节看它们如何被编排进第 6 章讲过的 commit 三阶段(before mutation / mutation / layout)。
9.8.1 mutation 阶段的 SuspenseComponent 分支
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2515-2566(节选)
case SuspenseComponent: {
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork, lanes);
const offscreenFiber: Fiber = finishedWork.child as any;
if (offscreenFiber.flags & Visibility) {
// Throttle the appearance and disappearance of Suspense fallbacks.
const isShowingFallback =
(finishedWork.memoizedState as SuspenseState | null) !== null;
const wasShowingFallback =
current !== null &&
(current.memoizedState as SuspenseState | null) !== null;
if (alwaysThrottleRetries) {
if (isShowingFallback !== wasShowingFallback) {
// A fallback is either appearing or disappearing.
markCommitTimeOfFallback();
}
} else {
if (isShowingFallback && !wasShowingFallback) {
// Old behavior. Only mark when a fallback appears, not when
// it disappears.
markCommitTimeOfFallback();
}
}
}
if (flags & Update) {
try {
commitSuspenseCallback(finishedWork);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
const retryQueue: RetryQueue | null = finishedWork.updateQueue as any;
if (retryQueue !== null) {
finishedWork.updateQueue = null;
attachSuspenseRetryListeners(finishedWork, retryQueue);
}
}
break;
}
三件事:
- Visibility 变化时打时间戳 :
markCommitTimeOfFallback()更新globalMostRecentFallbackTime------9.5.2 节的 300ms 节流窗口以此为起点。alwaysThrottleRetries恒 true,所以 fallback 出现和消失都记。 commitSuspenseCallback:调用实验性的suspenseCallbackprop(源码注释:"TODO: Delete this feature. It's not properly covered by DEV features."------一个半成品 API,了解即可)。- 把 render 阶段存的 retryQueue 转成 commit 阶段的 listener :
finishedWork.updateQueue = null先清空(防重复),再attachSuspenseRetryListeners。这是"双 listener"架构的交接点:render 阶段的 ping 监听防"数据比 fallback 快",commit 阶段的 retry 监听管"fallback 之后的恢复"。
9.8.2 Offscreen 隐藏/显示:hideOrUnhideAllChildren
主内容的显隐由 primaryChildFragment(Offscreen fiber)驱动:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2567-2706(节选)
case OffscreenComponent: {
const newState: OffscreenState | null = finishedWork.memoizedState;
const isHidden = newState !== null;
const wasHidden = current !== null && current.memoizedState !== null;
if (disableLegacyMode || finishedWork.mode & ConcurrentMode) {
// Before committing the children, track on the stack whether this
// offscreen subtree was already hidden, so that we don't unmount the
// effects again.
const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
const prevOffscreenDirectParentIsHidden = offscreenDirectParentIsHidden;
offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden || isHidden;
offscreenDirectParentIsHidden =
prevOffscreenDirectParentIsHidden || isHidden;
offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
...
}
commitReconciliationEffects(finishedWork, lanes);
if (flags & Visibility) {
const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
// Track the current state on the Offscreen instance so we can
// read it during an event
if (isHidden) {
offscreenInstance._visibility &= ~OffscreenVisible;
} else {
offscreenInstance._visibility |= OffscreenVisible;
}
const isUpdate = current !== null;
if (isHidden) {
// Only trigger disappear layout effects if:
// - This is an update, not first mount.
// - This Offscreen was not hidden before.
// - Ancestor Offscreen was not hidden in previous commit or in this commit
if (
isUpdate &&
!wasHidden &&
!offscreenSubtreeIsHidden &&
!offscreenSubtreeWasHidden
) {
...
recursivelyTraverseDisappearLayoutEffects(
finishedWork,
layoutEffectTraversalFlags,
);
...
}
}
// $FlowFixMe[constant-condition]
if (supportsMutation) {
// If it's trying to unhide but the parent is still hidden, then we should not unhide.
if (isHidden || !offscreenDirectParentIsHidden) {
hideOrUnhideAllChildren(finishedWork, isHidden);
}
}
}
...
hideOrUnhideAllChildren(ReactFiberCommitWork.js:1202,内部走 hideOrUnhideAllChildrenOnFiber)是一个 O(子树主机节点数) 的递归遍历:把每个 host 实例的 style.display 设为 'none' 或 ''(supportsMutation 渲染器;持久化渲染器另有实现)。它不是 DOM 增删,而是样式改写------这就是"Suspense 保留旧内容"的 DOM 层真相:节点还在文档树里,只是不可见。
三个栈变量(offscreenSubtreeIsHidden / offscreenSubtreeWasHidden / offscreenDirectParentIsHidden)精确控制 effect 语义:
offscreenSubtreeIsHidden:当前提交中祖先已隐藏 → 本层不需要再触发 disappear(祖先触发时会遍历整棵子树);offscreenSubtreeWasHidden:上次提交时祖先已隐藏 → 本层的 layout effect 上次已经"消失"过,不能重复 unmount;offscreenDirectParentIsHidden:父还藏着 → 自己想显示也不行(防止半隐藏状态泄漏到 UI)。
9.8.3 disappear / reappear layout 遍历
Offscreen 的 layout effect 语义是 Suspense 状态保持的关键,值得单独看。隐藏时 (disappearLayoutEffects,ReactFiberCommitWork.js:3101-3287)对整棵子树做"layout unmount":类组件调 componentWillUnmount、函数组件跑 layout cleanup、detach ref------但不跑 passive cleanup、不删 DOM:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:3109-3124
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
// TODO (Offscreen) Check: flags & LayoutStatic
commitHookLayoutUnmountEffects(
finishedWork,
finishedWork.return,
HookLayout,
);
recursivelyTraverseDisappearLayoutEffects(
finishedWork,
layoutEffectTraversalFlags,
);
break;
}
case ClassComponent: {
// TODO (Offscreen) Check: flags & RefStatic
safelyDetachRef(finishedWork, finishedWork.return);
const instance = finishedWork.stateNode;
if (typeof instance.componentWillUnmount === 'function') {
safelyCallComponentWillUnmount(
finishedWork,
finishedWork.return,
instance,
);
}
...
重新显示时 (reappearLayoutEffects,ReactFiberCommitWork.js:3304 起)把 layout effect 重新挂上:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:3304-3317(节选)
function reappearLayoutEffects(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
) {
// Turn on layout effects in a tree that previously disappeared. This is
// used by Activity and Offscreen and Suspense when a fallback is replaced
// by the content.
这套 disappear/reappear 机制的语义等价于"卸载 layout、保留 passive 与 DOM"。它解释了两个常见现象:
- Suspense 主内容里
useLayoutEffect的 cleanup 会在 fallback 显示时执行 (但useEffect不会)------所以"清理定时器/取消订阅"必须放 layout effect 或靠显式的 AbortController,不能指望useEffectcleanup 兜底; - 主内容里类组件的
componentWillUnmount在 fallback 显示时会被调用(但没有真正卸载)------老的 class 代码库切 Suspense 时这里有真实的兼容性坑。
Suspense 边界自己作为"重现根"时还有一段专门处理(ReactFiberCommitWork.js:3513-3525 的 commitSuspenseHydrationCallbacks 与 reappear 联动、以及 layout 阶段 754-780 行的 SuspenseComponent 分支),核心是边界从 hidden 转 visible 时让 primary fragment 走 reappear 而非 mount。
9.8.4 commitSuspenseHydrationCallbacks 与焦点抢救
两个边角但重要的 commit 细节:
焦点抢救 (ReactFiberCommitWork.js:490-500):如果 fallback 出现时焦点正在被隐藏的主内容里,先把焦点挪出,否则焦点随 display:none 丢失,键盘用户直接"失焦":
js
// Check to see if the focused element was inside of a hidden (Suspense) subtree.
...
finishedWork.tag === SuspenseComponent &&
isSuspenseBoundaryBeingHidden(current, finishedWork) &&
水合完成回调 (commitSuspenseHydrationCallbacks,ReactFiberCommitWork.js:1910-1945):边界从 dehydrated 转为 hydrated 时调用宿主配置的 commitHostHydratedSuspense 和 root 的 hydrationCallbacks.onHydrated------Fizz 用它删除服务器 HTML 里的占位注释、触发 onHydrated 钩子。
isSuspenseBoundaryBeingHidden 的判定则再次体现了"SuspenseState 非空即 fallback":
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2013-2025
function isSuspenseBoundaryBeingHidden(
current: Fiber | null,
finishedWork: Fiber,
): boolean {
if (current !== null) {
const oldState: SuspenseState | null = current.memoizedState;
if (oldState === null || oldState.dehydrated !== null) {
const newState: SuspenseState | null = finishedWork.memoizedState;
return newState !== null && newState.dehydrated === null;
}
}
return false;
}
原来不在 fallback(或还在 dehydrated)→ 现在在 fallback 且不是 dehydrated → "正在被隐藏"。dehydrated !== null 的排除很关键:脱水边界不涉及 DOM 隐藏,不能误判。
9.9 hydration 中的 Suspense:选择性水合与分块水合
SSR 场景下 Suspense 的行为完全不同------服务器已经把一部分 HTML 流式发给了浏览器,客户端的任务从"渲染"变成了"把已有 DOM 认领(claim)成 Fiber 树"。Fizz(服务端)在 <Suspense> 处把 HTML 切成块:已完成的块直接流出,未完成的先发 fallback 的 HTML + 一个占位注释,等数据好了再把真实内容作为一段 <template> + 内联脚本追加到文档里。客户端的对应机制就是这一节的主题。
9.9.1 dehydrated SuspenseState
hydration 中的边界,memoizedState 是带 dehydrated 的 SuspenseState:
js
// packages/react-reconciler/src/ReactFiberSuspenseComponent.js:24-43(回顾)
// - A non-null dehydrated field means it's blocked pending hydration.
dehydrated 存的是 SuspenseInstance------宿主层对象(DOM 里就是那几个注释节点),通过它能查询状态:
isSuspenseInstancePending(dehydrated):服务器还没把这部分内容发完(只有 fallback HTML);isSuspenseInstanceFallback(dehydrated):服务器放弃了,永久 fallback(出错或超时)。
retryLane 字段此时也有意义:"应该用什么 lane 重试水合这个边界",默认 OffscreenLane(源码注释:OffscreenLane is the default for dehydrated boundaries)。
9.9.2 挂载时的脱水边界:mountDehydratedSuspenseComponent
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:2949-2970
function mountDehydratedSuspenseComponent(
workInProgress: Fiber,
suspenseInstance: SuspenseInstance,
renderLanes: Lanes,
): null | Fiber {
// During the first pass, we'll bail out and not drill into the children.
// Instead, we'll leave the content in place and try to hydrate it later.
if (isSuspenseInstanceFallback(suspenseInstance)) {
// This is a client-only boundary. Since we won't get any content from the server
// for this, we need to schedule that at a higher priority based on when it would
// have timed out. In theory we could render it in this pass but it would have the
// wrong priority associated with it and will prevent hydration of parent path.
// Instead, we'll leave work left on it to render it in a separate commit.
// Schedule a normal pri update to render this content.
workInProgress.lanes = laneToLanes(DefaultLane);
} else {
// We'll continue hydrating the rest at offscreen priority since we'll already
// be showing the right content coming from the server, it is no rush.
workInProgress.lanes = laneToLanes(OffscreenLane);
}
return null;
}
第一次遇到脱水边界时:不渲染 children,直接返回 null,只在 fiber 上留一个 lane。然后:
- 服务器内容已在途(pending):挂
OffscreenLane------反正用户看到的是服务器的 HTML,水合不着急,用最低优先级; - 服务器已放弃(permanent fallback):挂
DefaultLane------没有服务器内容了,得让客户端尽快渲染。
"不在第一遍硬啃"的理由注释写得很清楚:错误的优先级会阻塞父路径的水合。
9.9.3 更新时的脱水边界:updateDehydratedSuspenseComponent
这是水合逻辑最密集的函数(ReactFiberBeginWork.js:2972-3217),分支众多,挑主干:
分支一:pending 实例(服务器内容还没到):
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:3125-3139
} else if (isSuspenseInstancePending(suspenseInstance)) {
// This component is still pending more data from the server, so we can't hydrate its
// content. We treat it as if this component suspended itself. ...
// Mark it as having captured (i.e. suspended).
// Also Mark it as requiring retry.
workInProgress.flags |= DidCapture | Callback;
// Leave the child in place. I.e. the dehydrated fragment.
workInProgress.child = current.child;
return null;
}
pending 边界"假装自己挂起了":保留 dehydrated fragment 子节点,等 Fizz 的后续 chunk 到达时由 registerSuspenseInstanceRetry(ReactFiberCommitWork.js:754-779)安排的 retry 唤醒。
分支二:可以水合了:
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:3140-3162
} else {
// This is the first attempt.
reenterHydrationStateFromDehydratedSuspenseInstance(
workInProgress,
suspenseInstance,
suspenseState.treeContext,
);
const primaryChildren = nextProps.children;
const primaryChildFragment = mountSuspensePrimaryChildren(
workInProgress,
primaryChildren,
renderLanes,
);
// Mark the children as hydrating. ...
primaryChildFragment.flags |= Hydrating | PlacementDEV;
return primaryChildFragment;
}
进入 dehydrated 实例内部的"水合游标"(hydration cursor),用 mountSuspensePrimaryChildren 构建主内容 fiber------注意这里没有 fallback fragment :服务器内容已经在 DOM 里,不需要 fallback。Hydrating 标志让子树以"认领已有 DOM"模式渲染。
分支三:收到了更新但还没水合完------这是**选择性水合(selective hydration)**的核心:
js
// packages/react-reconciler/src/ReactFiberBeginWork.js:3054-3095(节选)
if (didReceiveUpdate || hasContextChanged) {
// This boundary has changed since the first render. This means that we are now unable to
// hydrate it. We might still be able to hydrate it using a higher priority lane.
if (isCurrentTreeHidden()) {
...
return retrySuspenseComponentWithoutHydrating(...);
}
const root = getWorkInProgressRoot();
if (root !== null) {
const attemptHydrationAtLane = getBumpedLaneForHydration(
root,
renderLanes,
);
if (
attemptHydrationAtLane !== NoLane &&
attemptHydrationAtLane !== suspenseState.retryLane
) {
// Intentionally mutating since this render will get interrupted. This
// is one of the very rare times where we mutate the current tree
// during the render phase.
suspenseState.retryLane = attemptHydrationAtLane;
enqueueConcurrentRenderForLane(current, attemptHydrationAtLane);
scheduleUpdateOnFiber(root, current, attemptHydrationAtLane);
// Throw a special object that signals to the work loop that it should
// interrupt the current render.
...
throw SelectiveHydrationException;
}
}
...
场景:用户在水合完成前点了某个按钮(交互发生在还没 hydrate 的边界里)。事件的 lane 比当前水合 lane 高。React 的应对是主动打断当前渲染 (throw SelectiveHydrationException → SuspendedOnHydration → resetWorkInProgressStack,见 9.3.4 节),把该边界升级到更高优先级 lane 先水合,水合完立即重放事件。这就是"点击哪里,哪里优先水合"的选择性水合。
getBumpedLaneForHydration 的 lane 阶梯:事件 lane → InputContinuousHydrationLane → DefaultHydrationLane → TransitionHydrationLane → IdleHydrationLane,逐级降格。suspenseState.retryLane 防止同一级别反复重试(如果已经在这个 lane 试过且失败,就放弃升级,走 client render)。
分支四:水合失败/出错 :retrySuspenseComponentWithoutHydrating 把脱水 fragment 删掉,客户端重新渲染这棵树(可能显示 fallback)。throwException 的错误路径里还有对应逻辑:水合中抛的错误,优先找 Suspense 边界降级为 client render 而不是直接冒泡到错误边界(ReactFiberThrow.js:552-622),并 queueHydrationError 记录一个"可恢复错误"。
9.9.4 水合中的 SuspenseList 与 hydrationErrors
SuspenseState.hydrationErrors 存水合期间的错误快照,等整棵树提交后统一 log(避免错误在渲染中途就污染控制台)。SuspenseList 在水合中被显式禁止捕获(ReactFiberThrow.js:563-569 的 "SuspenseList should never catch while hydrating")------水合是严格顺序的过程,批量协调会让顺序假设失效。
9.9.5 水合游标与优先级阶梯
"进入脱水实例内部水合"具体做了什么?reenterHydrationStateFromDehydratedSuspenseInstance(ReactFiberHydrationContext.js:204-225)把模块级的水合游标整个重置:
js
function reenterHydrationStateFromDehydratedSuspenseInstance(
fiber: Fiber,
suspenseInstance: SuspenseInstance,
treeContext: TreeContext | null,
): boolean {
if (!supportsHydration) {
return false;
}
nextHydratableInstance =
getFirstHydratableChildWithinSuspenseInstance(suspenseInstance);
hydrationParentFiber = fiber;
isHydrating = true;
hydrationErrors = null;
didSuspendOrErrorDEV = false;
hydrationDiffRootDEV = null;
rootOrSingletonContext = false;
if (treeContext !== null) {
restoreSuspendedTreeContext(fiber, treeContext);
}
return true;
}
nextHydratableInstance 是水合的"读头":指向服务器 HTML 里下一个待认领的节点。进入 Suspense 实例 = 把读头拨到该实例内部的第一个子节点;hydrationParentFiber 记录"现在认领到的节点挂给哪个 fiber";restoreSuspendedTreeContext 恢复水合期间的 TreeContext(Suspense 边界会影响子树里的 context 传递,见第 4 章 TreeContext 部分)。此后子树里每个 HostComponent 的 tryHydrateInstance(ReactFiberHydrationContext.js:251+)都从 nextHydratableInstance 顺序消费------认领顺序必须与 Fizz 的输出顺序严格一致 ,错位就触发 mismatch 走 client render。首次遇到边界时用的 claimNextHydratableSuspenseInstance(ReactFiberHydrationContext.js:518+)则是从根读头"认领下一个 Suspense 占位(注释节点)"。两个函数一个"进边界内部",一个"找到边界",共同实现分块水合的游标模型。
选择性水合里"升级到哪个 lane"由 getBumpedLaneForHydration(ReactFiberLane.js:1092-1114)决定:
js
export function getBumpedLaneForHydration(
root: FiberRoot,
renderLanes: Lanes,
): Lane {
const renderLane = getHighestPriorityLane(renderLanes);
const bumpedLane =
(renderLane & SyncUpdateLanes) !== NoLanes
? // Unify sync lanes. ...
SyncHydrationLane
: getBumpedLaneForHydrationByLane(renderLane);
// Check if the lane we chose is suspended. If so, that indicates that we
// already attempted and failed to hydrate at that level. Also check if we're
// already rendering that lane, which is rare but could happen.
if ((bumpedLane & (root.suspendedLanes | renderLanes)) !== NoLanes) {
// Give up trying to hydrate and fall back to client render.
return NoLane;
}
return bumpedLane;
}
getBumpedLaneForHydrationByLane 的映射表(ReactFiberLane.js:1116+)就是完整的阶梯:
| 事件 lane | 水合 lane |
|---|---|
SyncLane |
SyncHydrationLane |
InputContinuousLane |
InputContinuousHydrationLane |
DefaultLane |
DefaultHydrationLane |
TransitionLane* |
TransitionHydrationLane |
| 其它 | SelectiveHydrationLane → IdleHydrationLane |
"试过且失败就放弃" :如果目标 lane 已经在 root.suspendedLanes 里(说明这个优先级试过没水合完),返回 NoLane,调用方转而 client render。这防止了"同一优先级反复重试水合"的死循环------与 suspenseState.retryLane 的记忆配合(ReactFiberBeginWork.js:3077-3079 的 attemptHydrationAtLane !== suspenseState.retryLane)。
水合期间收集的错误也不直接抛出,而是 queueHydrationError 入队、在提交时经 upgradeHydrationErrorsToRecoverable(ReactFiberHydrationContext.js:907+)升级为"可恢复错误"统一 log------用户看到的是正常的客户端渲染结果,控制台里有一条带 digest 的警告,两头都兼顾。
9.9.6 服务端一侧:Fizz 如何切块(简述)
客户端机制依赖服务端的"切块约定"。Fizz 在 packages/react-server/src/ReactFizzServer.js 里为每个 <Suspense> 边界输出:fallback 的 HTML + <!--$?--><template id="B:0"></template> 形式的占位,边界内容就绪后输出 <div hidden id="S:0">...</div><script>$RC(...)</script>,$RC 函数把内容搬到占位处并删掉 fallback。客户端的 SuspenseInstance 就是这些注释/template 节点的句柄。isSuspenseInstancePending/isSuspenseInstanceFallback 查询的就是这些节点是否已补齐、或服务器是否输出了"放弃"标记(digest)。这与客户端 updateDehydratedSuspenseComponent 的三个分支严格对偶。
9.10 SuspenseList:批量协调多个边界
<SuspenseList> 是一个"协调一组 Suspense 边界"的组件:控制这一组边界以什么顺序揭示内容 (revealOrder)、没就绪的尾巴怎么处理 (tail)。它在 React 18 被标记为 legacy/deprecated(新代码不建议使用),但源码完整保留,而且它的 ForceSuspenseFallback 上下文位至今仍被 shouldRemainOnFallback 消费------要完整理解 Suspense 就不能跳过它。
9.10.1 渲染状态:initSuspenseListRenderState
updateSuspenseListComponent(ReactFiberBeginWork.js:3447-3628)不做普通 reconcile,它把 children 切成头(head)和尾(tail):
js
// packages/react-reconciler/src/ReactFiberSuspenseComponent.js:45-59
export type SuspenseListRenderState = {
isBackwards: boolean,
// The currently rendering tail row.
rendering: null | Fiber,
// The absolute time when we started rendering the most recent tail row.
renderingStartTime: number,
// The last of the already rendered children.
last: null | Fiber,
// Remaining rows on the tail of the list.
tail: null | Fiber,
// Tail insertions setting.
tailMode: SuspenseListTailMode,
// Keep track of total number of forks during multiple passes
treeForkCount: number,
};
以默认的 forwards 为例(ReactFiberBeginWork.js:3600-3623):
js
case 'forwards':
default: {
const lastContentRow = findLastContentRow(workInProgress.child);
let tail;
if (lastContentRow === null) {
// The whole list is part of the tail.
tail = workInProgress.child;
workInProgress.child = null;
} else {
// Disconnect the tail rows after the content row.
// We're going to render them separately later.
tail = lastContentRow.sibling;
lastContentRow.sibling = null;
}
initSuspenseListRenderState(
workInProgress,
false, // isBackwards
tail,
lastContentRow,
tailMode,
treeForkCount,
);
break;
}
findLastContentRow 找"最后一个已经在显示真实内容的行"(ReactFiberBeginWork.js:3276-3295):新行不算内容行;行内有任何边界处于 fallback 也不算。头 = 已显示内容的行,尾 = 其后的新行/fallback 行。头先渲染;尾在 completeWork 里一行一行来(时间切片到行级)。
四种 revealOrder 的策略:
| revealOrder | 行为 |
|---|---|
forwards(默认) |
按顺序揭示,前面的没好后面不显示 |
backwards |
反序揭示,reverseChildren 把 children 链表反转后渲染 |
together |
要么全部揭示,要么全部保持 fallback |
independent |
等于没有 SuspenseList,各自为政 |
9.10.2 ForceSuspenseFallback:第二遍渲染强制 fallback
SuspenseList 的"协调"发生在 completeWork(ReactFiberCompleteWork.js:1709-1942)。第一遍渲染完头之后,检查头里是否有边界还挂着:
js
// packages/react-reconciler/src/ReactFiberCompleteWork.js:1739-1789(节选)
const cannotBeSuspended =
renderHasNotSuspendedYet() &&
(current === null || (current.flags & DidCapture) === NoFlags);
if (!cannotBeSuspended) {
let row = workInProgress.child;
while (row !== null) {
const suspended = findFirstSuspended(row);
if (suspended !== null) {
didSuspendAlready = true;
workInProgress.flags |= DidCapture;
cutOffTailIfNeeded(renderState, false);
// If this is a newly suspended tree, it might not get committed as
// part of the second pass. ...
const retryQueue: RetryQueue | null =
suspended.updateQueue as any;
workInProgress.updateQueue = retryQueue;
scheduleRetryEffect(workInProgress, retryQueue);
// Rerender the whole list, but this time, we'll force fallbacks
// to stay in place.
// Reset the effect flags before doing the second pass since that's now invalid.
// Reset the child fibers to their original state.
workInProgress.subtreeFlags = NoFlags;
resetChildFibers(workInProgress, renderLanes);
// Set up the Suspense List Context to force suspense and
// immediately rerender the children.
pushSuspenseListContext(
workInProgress,
setShallowSuspenseListContext(
suspenseStackCursor.current,
ForceSuspenseFallback,
),
);
...
return workInProgress.child;
}
row = row.sibling;
}
}
发现头里有挂起的边界时,SuspenseList 推翻整个第一遍,重来 :清掉 effect flags、重置子 fiber、把 ForceSuspenseFallback 位推进上下文,然后从头重渲。第二遍里每个行内的 Suspense 边界会通过 shouldRemainOnFallback 读到这个位------已经在 fallback 的行留在 fallback,没渲染过的新行直接进 fallback (updateSuspenseListComponent 开头 3466-3475 行也会读这个位,把 DidCapture 打给自己)。效果就是 forwards 语义:"第 3 行没好,第 4、5 行不许先露脸"。
findFirstSuspended(ReactFiberSuspenseComponent.js:63-107)是 SuspenseList 的探测工具,深度优先找第一个"处于 fallback 或 dehydrated"的边界,同时会顺带修正 return 指针(node.child.return = node)------它假设被扫描的树还没完成 return 链接。
尾部的逐行渲染则在 renderState.tail !== null 分支(ReactFiberCompleteWork.js:1887-1939):每完成一行,把 renderingStartTime 与当前时间比较,超预算就放弃剩余尾部(workInProgress.lanes = SomeRetryLane 自我重试),否则弹出下一行继续。tailMode 三档:
visible:尾部即使 fallback 也显示(一个接一个转圈);collapsed:只显示第一个 fallback,其余折叠;hidden(默认):尾部全新的行直接不显示,且如果尾部自己挂起,删除它而不是挂起父级 (pushSuspenseListCatch,把自己变成 handler)。
9.10.3 为什么 SuspenseList 被弃用
从源码就能看出它的成本:多遍渲染(头一遍 + forced 一遍 + 尾部逐行)、整棵子树重置(resetChildFibers)、复杂的上下文位管理。它与并发渲染的交互(renderState.treeForkCount、水合时的 pushTreeFork)产生了大量特判。React 18 之后,官方建议的替代方案是:每个数据源一个 Suspense 边界 + transition ,让 lane 系统而不是 SuspenseList 来决定揭示节奏------更少的魔法,更好的并发语义。但 ForceSuspenseFallback 的消费链路(shouldRemainOnFallback)作为基础设施保留了下来。
9.11 Offscreen / Activity:后台渲染与树隐藏
9.11.1 Offscreen 是 Suspense 的"隐藏引擎"
前面反复提到:Suspense 的主内容 fragment 就是一个 OffscreenComponent fiber。所以 Offscreen 的能力就是 Suspense 隐藏语义的完整版。updateOffscreenComponent(ReactFiberBeginWork.js:620-812)按 mode 分三种行为:
visible:正常渲染子树,memoizedState = null;hidden:渲染子树(或按需 bailout),memoizedState = OffscreenState(非空即隐藏),并pushOffscreenSuspenseHandler;unstable-defer-without-hiding:只延迟 effect 不隐藏 DOM(中间态,少用)。
隐藏期间发生了几件重要的事,全部可以在 ReactFiberHiddenContext.js 与 ReactFiberBeginWork.js 的 Offscreen 分支里看到:
- lane 纠缠(entangled lanes) :进入隐藏子树时把"本来会提交的 lanes"并入
entangledRenderLanes------隐藏子树里的更新不算数(用户看不见),但 React 要记住"这里欠着一笔渲染"; - suspense handler 切换 :
pushOffscreenSuspenseHandler把 handler 设为 Offscreen 自己(隐藏内容里挂起 → 整个边界不提交,而不是显示 fallback); - cache pool 收拢 :
getSuspendedCache()(9.4.2 节)。
9.11.2 Activity:React 19 的预渲染组件
<Activity> 是 React 19 对 Offscreen 的正式命名与产品化(ActivityComponent tag)。与"裸 Offscreen"相比,它的定位是**"预渲染 + 状态保持的页面切换"**:
jsx
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<RoutePanel />
</Activity>
切到 hidden 时:DOM 隐藏(display:none)、layout effect 卸载(disappear)、state 与 DOM 保留 、effects 的 passive cleanup 延迟到重新可见或真正卸载;切回 visible 时:reappear layout effects、显示 DOM。对路由场景,这就是"返回上一页时列表滚动位置和输入内容还在"的实现基础------而且预渲染让下一页在隐藏期间就已经把数据请求、lazy chunk 都跑完了。
源码里 Activity 与 Suspense 共享同一套上下文设施(pushDehydratedActivitySuspenseHandler、ActivityComponent 出现在 throwException 的 handler switch 里),但有两个关键差异:
- Activity 没有 fallback :隐藏内容挂起 → 边界自己
ShouldCapture(ReactFiberThrow.js:487-517的 Offscreen 分支与ReactFiberBeginWork.js:4025-4037的 Activity 分支),表现为"这次不提交这个 Activity 的内容",而不是显示任何 UI; - Activity 可以是 dehydrated 的(SSR 时
<Activity mode="hidden">的内容整块不水合,pushDehydratedActivitySuspenseHandler的注释:"We can just leave it dehydrated if it suspends.")。
9.11.3 "不要单独使用 Offscreen"的忠告
裸 <Offscreen> 从未公开导出。直接用它的问题:隐藏子树里的更新不会触发可见 UI 变化,但 lane 系统仍然要为它们调度(OffscreenLane、baseLanes),调试困难;Activity 在它之上补足了可见性语义(offscreenInstance._visibility、OffscreenVisible 位)与 passive effect 的生命周期管理(commitOffscreenPassiveMountEffects)。19.3.1 的实践中,只应使用 <Activity>。
9.11.4 Activity 的 passive effect 生命周期
Offscreen/Activity 的 passive effect 处理比普通组件多一层状态机,入口是 commitOffscreenPassiveMountEffects(ReactFiberCommitWork.js:3627+)与 passive 阶段的 OffscreenComponent 分支(ReactFiberCommitWork.js:4213-4340、4586-4660)。核心是 OffscreenInstance._visibility 的两个位(ReactFiberOffscreenComponent.js:53-54):
js
export const OffscreenVisible = /* */ 0b001;
export const OffscreenPassiveEffectsConnected = /* */ 0b010;
OffscreenVisible:mutation 阶段写入(9.8.2 节),表示当前 DOM 是否可见;OffscreenPassiveEffectsConnected:passive 阶段维护,表示这棵子树的 passive effects 是否"挂着"(连通过)。
隐藏时的规则(ReactFiberCommitWork.js:4232-4292 附近):只有"曾经可见过"的子树才断开 passive effects ;一直隐藏的子树(比如 <Activity mode="hidden"> 预渲染的内容)effects 从未连接过,什么都不做。重新可见时再把 passive effects 挂回去(reappear)。还有一个延迟断开逻辑(ReactFiberCommitWork.js:5163-5181):非 Suspense 直接子级的 Offscreen,其 passive effects 的断开被推迟到"一个延迟窗口之后"------源码注释写明了动机:如果树在延迟耗尽前重新出现(reappear),就不必白跑一次"断开又重连"。
对使用者的含义:<Activity mode="hidden"> 里的组件,其 useEffect 在隐藏期间的行为取决于它之前是否可见过------首次挂载就隐藏的组件 effect 根本不运行;可见过再隐藏的组件 effect 会断开。写"预渲染页面"时不能假设 effect 一定不跑。
9.12 React 19 的变化:use()、suspensey commit、preinit/preload
9.12.1 use():把"抛 Promise"变成正式契约
React 18 及以前,挂起的唯一正规入口是 React.lazy(内部抛 thenable)或库的私有协议(Relay 的 throw thenable)。React 19 把 use(promise) 扶正(packages/react/src/ReactHooks.js:207)。相比旧协议的变化:
- 不透明异常 :抛
SuspenseException而非 thenable 本体(9.2.3 节),用户的try/catch无法吞掉挂起信号;checkIfUseWrappedInTryCatch会检测并警告。 - replay 语义 :
use()挂起的组件在数据 resolve 后原地重放 (replaySuspendedUnitOfWork,9.3.4 节),而不是从根重启------旧式throw promise强制 unwind(SuspendedOnDeprecatedThrowPromise的分支注释明说原因:replay 对旧用法可能造成无限 ping)。 - Hook 位置记账 :
use()计入 Hook 顺序(第 7 章),条件调用会被 lint 规则和checkIfUseWasUsedBefore(ReactFiberThenable.js:452-479)双重盯上。 - 支持 context :
use(Context)在渲染期间读 context(ReactFiberHooks.js:1171-1173),与 thenable 共用use名字但走readContext。
注意 use() 不是 Hook 的顺序依赖者而是 thenable 的位置依赖者 :它不创建 Hook 节点,而是占一个 ThenableState 槽位(thenableIndexCounter)。所以"条件 use()"的问题不是 Hook 顺序崩坏,而是"同一位置前后渲染出现不同 thenable 被缓存击穿"的静默错误------DEV 警告专门描述了这个场景。
9.12.2 suspensey commit:连提交都可以挂起
React 19 引入了一整套"suspensey resources "机制:图片、样式表、字体等资源未就绪时,整个 commit 阶段暂停,而不是先提交再闪一下无样式内容(FOUC)。
链路入口在 completeRootWhenReady(ReactFiberWorkLoop.js:1578-1681,9.3 节之后 commit 前的最后一道关):
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:1598-1618(节选)
const BothVisibilityAndMaySuspendCommit = Visibility | MaySuspendCommit;
const subtreeFlags = finishedWork.subtreeFlags;
...
const maySuspendCommit =
subtreeFlags & ShouldSuspendCommit ||
(subtreeFlags & BothVisibilityAndMaySuspendCommit) ===
BothVisibilityAndMaySuspendCommit;
let suspendedState: null | SuspendedState = null;
if (isViewTransitionEligible || maySuspendCommit || isGestureTransition) {
// Before committing, ask the renderer whether the host tree is ready.
// If it's not, we'll wait until it notifies us.
suspendedState = startSuspendingCommit();
// This will walk the completed fiber tree and attach listeners to all
// the suspensey resources. The renderer is responsible for accumulating
// all the load events. This all happens in a single synchronous
// transaction, so it track state in its own module scope.
// This will also track any newly added or appearing ViewTransition
// components for the purposes of forming pairs.
accumulateSuspenseyCommit(finishedWork, lanes, suspendedState);
accumulateSuspenseyCommit(ReactFiberCommitWork.js:4873-5040)遍历完成树,对每个 suspensey 资源问宿主两件事:preloadInstance(已经好了吗?)和 suspendInstance(没好?登记监听)。DOM 渲染器的实现:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:6639-6667(节选)
export function suspendInstance(
state: SuspendedState,
instance: Instance,
type: Type,
props: Props,
): void {
if (!enableSuspenseyImages && !enableViewTransition) {
return;
}
if (
typeof instance.decode === 'function'
) {
// If this browser supports decode() API, we use it to suspend waiting on the image.
// The loading should have already started at this point, so it should be enough to
// just call decode() which should also wait for the data to finish loading.
state.imgCount++;
...
const ping = onUnsuspendImg.bind(state);
instance.decode().then(ping, ping);
}
}
图片用 decode() 等待"解码完成"(比 load 更严格------解码完才能真正无闪烁绘制);样式表(suspendResource)等 load 事件。没就绪则 waitForCommitToBeReady 返回一个 subscribe 函数,commit 被推迟,root.cancelPendingCommit 持有取消句柄(新一轮渲染开始时会取消挂起的 commit)。同时 ReactFiberWorkLoop.js:2384-2388 的 SuspendedOnInstanceAndReadyToContinue 分支会在让出主线程后再给资源一次机会 (preloadInstance 复查),避免无谓的 unwind。
挂起 commit 的超时策略值得注意(ReactFiberWorkLoop.js:1631-1645):
js
// For timeouts we use the previous fallback commit for retries and
// the start time of the transition for transitions. This offset
// represents the time already passed.
const timeoutOffset = includesOnlyRetries(lanes)
? globalMostRecentFallbackTime - now()
: includesOnlyTransitions(lanes)
? globalMostRecentTransitionTime - now()
: 0;
// At the end, ask the renderer if it's ready to commit, or if we should
// suspend. If it's not ready, it will return a callback to subscribe to
// a ready event.
const schedulePendingCommit = waitForCommitToBeReady(
suspendedState,
timeoutOffset,
);
timeoutOffset 的含义:retry 渲染的等待预算从"上次 fallback 提交"起算,transition 从"transition 开始"起算 。也就是说 suspensey commit 的等待时间不是从零开始,而是继承用户已经等待过的时长------避免"已经等了 2 秒,再让图片等 3 秒"的双重超时。这与 render 阶段的 300ms 节流是同一设计哲学:等待预算全局共享。
这套机制与 Suspense 的关系:render 阶段的挂起管"数据",commit 阶段的挂起管"资产" 。两者共用 SuspendedState/ping 的概念,但入口、恢复路径完全独立(后者不过 throwException)。
9.12.3 preinit / preload:资源提示的指令式 API
React 19 在 react-dom 上导出了一族资源 API(packages/react-dom/src/shared/ReactDOMFloat.js):prefetchDNS、preconnect、preload、preinit、preloadModule、preinitModule。它们不经过 reconciler,直接调 ReactDOMSharedInternals.d(当前 dispatcher)的宿主实现:
js
// packages/react-dom/src/shared/ReactDOMFloat.js:89-152(preload 主体,节选)
const as = options.as;
const crossOrigin = getCrossOriginStringAs(as, options.crossOrigin);
ReactDOMSharedInternals.d /* ReactDOMCurrentDispatcher */
.L(/* preload */ href, as, {
crossOrigin,
integrity: ...,
nonce: ...,
type: ...,
fetchPriority: ...,
referrerPolicy: ...,
imageSrcSet: ...,
imageSizes: ...,
media: ...,
});
preload(href, {as: 'script' | 'style' | 'font' | 'image' ...}):等价于提前插入<link rel="preload">;preinit(href, {as: 'script' | 'style'}):更强------提前下载并执行/应用(插入真实 script/link 标签),适合"确定马上要用"的资源;- 在 RSC 流式渲染中,这些调用会被 Fizz 捕获并提升到
<head>,实现"组件还没渲染到,资源已经在下"。
与 Suspense 的配合:preinit 提前执行的 chunk 会让后续 lazy() 的 import() 命中缓存,preload 的图片会让 suspensey commit 直接放行。这是 React 19 "声明式异步 UI"拼图的资源侧闭环。
9.12.4 其它值得知道的 19 期变化
enableSuspenseAvoidThisFallback引入(默认关,9.5.5 节);- CPU Suspense (
<Suspense defer>,enableCPUSuspense实验标志,9.4.3 节):用 Suspense 语法处理"CPU 重的树"; SuspenseyCommitException/suspendCommit()基础设施(ReactFiberThenable.js:67, 343);- Gesture/ViewTransition 与 Suspense 的联动 :
completeRootWhenReady里isGestureTransition/isViewTransitionEligible与maySuspendCommit并列判断(ReactFiberWorkLoop.js:1600-1630)------视图过渡动画期间,Suspense 揭示也被纳入等待。
9.12.5 useActionState / useOptimistic:Suspense 语义的近亲
useActionState 的挂起信号是独立的 SuspenseActionException(ReactFiberThenable.js:72-79),handleThrow 把它与 SuspenseException 同等对待(ReactFiberWorkLoop.js:2323-2339)------都是 SuspendedOnImmediate。但产品语义不同:use() 挂起表示"数据没到,显示 fallback";useActionState 挂起表示"action 还在跑,UI 暂时不可交互",通常不需要 fallback,而是配合 isPending 显示按钮 loading。useOptimistic 则完全不走挂起协议------它同步地先渲染乐观值,action 完成后自然回正。三者与 Suspense 的关系可以概括为:use() 处理"读"的异步,useActionState 处理"写"的异步,useOptimistic 处理"写的即时反馈"。
9.13 边界与坑
9.13.1 fallback 闪烁:什么时候必然发生
即使有 9.5.4 节的三道防线,以下情况仍会闪:
- 非 transition 更新在 shell 挂起 :
RootSuspendedWithDelay穿透到 commit(!includesOnlyTransitions && !includesOnlyRetries→ "Commit the placeholder")。用户点了一个按钮触发同步数据请求 → fallback 必然出现。解法:startTransition包裹(第 8 章)。 - fallback 内容本身很大:fallback 的插入本身是一次大 commit,如果 fallback 是个重组件,"出现 fallback"就成了二次卡顿。解法:fallback 保持轻量,或用骨架屏的静态部分。
- 节流窗口外的连续 retry:300ms 节流只压制"纯 retry 提交",如果 retry 之间夹着新更新(新 lane),节流失效。解法:从产品上避免"无限分页 + 每页一个边界 + 快速滚动"。
9.13.2 不要在 Suspense 里放关键交互
fallback 显示时主内容 display:none,其中的按钮/输入框不可聚焦、不可点击 (这就是 9.8.4 节"焦点抢救"存在的原因------焦点在隐藏内容里会被移出)。把"必须一直可用的操作"(如表单提交按钮、全局搜索框)放进会挂起的边界,挂起期间用户直接失去它们。同理,fallback 里不要放需要焦点的元素------fallback 消失时焦点一样会丢。
9.13.3 Suspense 与错误边界的分工
源码里的分界线极其清晰:throwException 的 wakeable 分支(ReactFiberThrow.js:381-548)与错误分支(:551 之后)。
- 挂起(thenable pending)→ Suspense:显示 fallback,数据到了恢复;
- 拒绝(thenable rejected)→ 错误边界 :
trackUsedThenable的'rejected'分支throw thenable.reason(ReactFiberThenable.js:220-239),此时value.then虽在,但 rejected thenable 走到的是SuspendedAndReadyToContinue的 replay:replaySuspendedUnitOfWork重新执行组件,trackUsedThenable同步走到'rejected'分支抛出真实错误,这次是SuspendedOnError路径,找getDerivedStateFromError。
实践结论:每个 Suspense 边界外层(或同级)都应该有错误边界。只有 fallback 没有错误边界,Promise reject 时会一路冒泡到根,卸载整棵树。React 19 推荐的组合是错误边界包裹 Suspense:
jsx
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<Skeleton />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
9.13.4 "现场创建 Promise"反模式
9.2.3 节的 DEV 警告已经宣判了这个用法。再强调一下机理:渲染中 use(fetch(url)) → 每次渲染新 Promise → trackUsedThenable 按 index 保留第一个、丢弃后续(thenable.then(noop, noop) 静默)→ 界面停在一个 Promise 上,如果它 reject,你看到的是"卡住的旧数据"而不是错误。正确做法:
jsx
// 反模式:每次渲染新 Promise
function Bad({ id }) {
const data = use(fetch(`/api/${id}`)); // reject 时行为不可预期,重复渲染静默丢弃
return <div>{data.name}</div>;
}
// 正模式:Promise 在渲染外创建并缓存(或使用 Suspense 兼容库)
const cache = new Map();
function getResource(id) {
if (!cache.has(id)) cache.set(id, fetch(`/api/${id}`).then(r => r.json()));
return cache.get(id);
}
function Good({ id }) {
const data = use(getResource(id));
return <div>{data.name}</div>;
}
shellSuspendCounter > 100 的熔断(9.2.3 节)就是为这个反模式兜底的------同步渲染反复在 shell 挂起 100 次后直接抛错。
9.13.5 legacy 模式的遗产
disableLegacyMode 相关的分支在 19.3.1 中仍然存在(ReactDOM.render 仍会走 legacy)。legacy Suspense 的行为差异:同步提交 fallback、没有 ping、假装挂起组件渲染了 null (markSuspenseBoundaryShouldCapture 的 legacy 分支,ReactFiberThrow.js:250-315),主内容会以"不一致状态"被提交。如果你维护老代码库看到"fallback 出现时组件行为怪异、componentDidCatch 乱触发",源头就在这里。新代码一律 createRoot + concurrent。
9.13.6 StrictMode 与挂起
DEV + StrictMode 下组件会被双调用,use() 也会跑两遍------但 thenable 按 index 缓存,第二次拿到的是同一个 thenable(previous === thenable,直接命中 fulfilled 分支或再次抛 SuspenseException),行为一致。真正要注意的是双调用期间副作用:挂起组件的 effects 不会运行(组件没 commit),但模块级副作用(如创建 Promise)会跑两次------又一个"把 Promise 创建挪到渲染外"的理由。
9.13.7 SuspenseList 的两个真 bug 温床
- 水合顺序 :SuspenseList 的多遍渲染假设树是顺序渲染的,而水合算法同样假设顺序------
ReactFiberCompleteWork.js:1784-1787和:1933-1936到处在补pushTreeFork的坑。SuspenseList + SSR 是官方都不推荐的组合。 - retryQueue 转移 :
ReactFiberCompleteWork.js:1763-1766把挂起行的 retryQueue "托管"给 SuspenseList------如果这些 thenable 在第二遍前 resolve,监听关系变得非常微妙(注释承认:"In theory we could get clever and keep track of how many dependencies remain but it gets tricky...")。
9.13.8 key 与边界的交互:切换 key 等于重置边界
<Suspense key={id}> 与 <Suspense> 内 children 的 key 变化,会走 reconciliation 的常规路径:key 不同 → 旧边界被删除、新边界被挂载。这意味着旧边界的主内容(哪怕已 resolve)会被整体卸载重建 ,updateQueue 里的 wakeable、retryCache、cachePool 全部作废。这是特性不是 bug(key 的语义就是"不同的东西"),但实践中常见误用:把 key 当作"强制刷新"的手段,结果丢掉了 Suspense 的状态保持能力,还触发一次完整的 fallback。想刷新数据应该更新数据源(新 Promise + transition),而不是换 key。
9.13.9 fallback 与 View Transition 的叠加
React 19 的 View Transition(<ViewTransition> 组件,见 ReactFiberBeginWork.js:3630 起的 updateViewTransition)与 Suspense 揭示可以在同一次 commit 里叠加:completeRootWhenReady 会把 isViewTransitionEligible 与 maySuspendCommit 并列处理(ReactFiberWorkLoop.js:1600-1630),mutation 阶段 commitBeforeMutationEffects_begin 用 BeforeAndAfterMutationTransitionMask 扫描受影响的子树(ReactFiberCommitWork.js:373-378)。实践中要小心:fallback 的插入/删除会成为 transition 的"before/after"配对对象------如果 fallback 出现又消失(节流窗口内),用户可能看到一次"空骨架 → 内容"的动画,而不是平滑过渡。对动画敏感的区域,优先用 transition + 保留旧内容,而不是 fallback 切换。
9.13.10 隐藏子树里的表单与受控输入
主内容被 display:none 后,其中的 <input> 仍在 DOM 里且保持值(非受控的 DOM 状态不丢),但:
- 用户无法聚焦/输入(不可见元素不可交互);
- 如果隐藏期间有代码以编程方式
focus()它,浏览器会失败或产生不可见焦点(offscreenDirectParentIsHidden的存在就是为了在 commit 层防止这类半隐藏状态); - 表单的自动填充、密码管理器行为在
display:none子树里不可靠。
结论:长表单不要放在会频繁挂起的边界里。要么把表单提到边界外,要么接受"挂起期间表单暂时不可用"并在 UX 上明示。
9.13.11 一个真实的坑:fallback 里的组件也会跑 effect
fallback fragment 是普通 fiber,其中的 useEffect/useLayoutEffect 正常执行 。于是"骨架屏组件里的 effect"会在每次 fallback 出现时运行、fallback 消失时 cleanup。常见事故:骨架屏里放了埋点 useEffect(() => track('skeleton_shown')),节流窗口内的多次 fallback 切换导致重复上报。原则:fallback 保持纯展示,任何副作用都说明它不该在 fallback 里。
9.14 性能影响量化
9.14.1 时间复杂度
| 操作 | 复杂度 | 源码位置 |
|---|---|---|
trackUsedThenable 命中缓存 |
O(1) | ReactFiberThenable.js:212-219 |
trackUsedThenable 首次见到 thenable |
O(1) + 一次 then 注册 |
:276-293 |
attachPingListener |
O(1)(WeakMap + Set) | ReactFiberWorkLoop.js:4987-5013 |
throwException wakeable 分支 |
O(1)(查栈 + 打标记) | ReactFiberThrow.js:381-485 |
| fallback 切换的 render 成本 | O(fallback 大小)------主内容被 bailout,不渲染 | updateSuspenseFallbackChildren → bailoutOffscreenComponent |
hideOrUnhideAllChildren |
O(子树 host 节点数),commit mutation 阶段同步执行 | ReactFiberCommitWork.js:1202 |
| disappear/reappear layout | O(子树 fiber 数) | :3101 / :3304 |
findFirstSuspended |
O(行内 fiber 数),SuspenseList 专用 | ReactFiberSuspenseComponent.js:63 |
| retry 渲染 | O(边界主内容大小),但可时间切片 | retryTimedOutBoundary |
关键认知:Suspense 的"隐藏"是廉价的(样式改写 + effect 卸载),"显示 fallback"也是廉价的(主内容不渲染)。真正昂贵的是 retry 时主内容的重新渲染------这就是 RetryLane 存在的原因(低优先级、可打断、节流)。
9.14.2 内存
每个 Suspense 边界的常驻内存:一个 SuspenseState(或共享的 SUSPENDED_MARKER 常量)、一个 OffscreenState、updateQueue 里的 Set<Wakeable>、retryCache(WeakSet)。hidden 主内容的整棵 Fiber 树 + DOM 都保留在内存里------这是"状态保持"的代价。一个隐藏了 50 个重型页面的 Activity 栈就是 50 棵完整树。生产环境应控制同时隐藏的规模(框架通常只保留 N 层)。
9.14.3 什么时候 Suspense 反而是负优化
- 极小数据 + 极快网络:挂起/恢复的固定开销(两次渲染 + 一次节流判断)超过直接同步渲染。症状:内容"晚半拍"出现。对策:这种数据根本不该走 Suspense,同步渲染即可。
- 巨型 fallback:fallback 本身渲染慢,等于把等待时间翻倍。
- 过深的边界嵌套 :每层边界一次 mount/update 判定,unwind 链路变长;且
shellBoundary逻辑让深层边界的 fallback 决策更复杂。经验法则:按"用户可理解的加载单元"设边界(一个路由区块、一个数据卡片),不要按组件粒度。
9.14.4 Profiler 视角
ReactFiberWorkLoop.js 的 profiling 分支对 Suspense 有专门记录:logSuspendedRenderPhase(渲染被 suspend)、logComponentSuspended(哪个组件挂起、挂在什么 wakeable 上)、logComponentDisappeared/Reappeared(Offscreen 消失/重现)、trackSuspendedTime。DevTools 的 Performance 轨里能看到"suspended"区段与挂起组件的 wakeable 名称(thenable.displayName,见 ReactFiberThenable.js:179-204 的 _debugInfo)------排查"谁在等什么"的第一现场。
9.14.5 观测与调试:四个抓手
- DEV 警告即诊断书 。源码里的每条 Suspense 相关
console.error都对应一个明确病因:- "A component was suspended by an uncached promise..." → 渲染中现场建 Promise(9.2.3/9.13.4);
- "A component suspended while responding to synchronous input..." → legacy 根无边界挂起,或缺
startTransition(ReactFiberThrow.js:539-544); - "An unknown Component is an async Client Component..." →
shellSuspendCounter > 100熔断(9.2.3); - "This library called use() to suspend in a previous render but did not call use() when it finished..." → 条件
use()(9.12.1)。 看到警告先读英文原文,React 的这些消息写得极其精确。
ReactSharedInternals.didUsePromise+act:测试环境里act()会检查未 await 的 Promise,把"测试通过了但线上挂起"类问题暴露在 CI。- Profiler 的 Suspense 轨道 :
logComponentSuspended/logSuspendedRenderPhase记录哪个组件挂在什么 wakeable 上、渲染被 suspend 的时间段(ReactFiberWorkLoop.js:2392-2410)。 onRecoverableError:hydrateRoot的onRecoverableError选项收到所有水合降级事件(upgradeHydrationErrorsToRecoverable的产物),SSR 项目应上报它。
9.15 用法与最佳实践
9.15.1 边界的粒度:按"加载单元"而非"组件"
jsx
// 反模式:每个小组件一个边界 → 十个 spinner、十次节流竞争、嵌套判定复杂
<Card>
<Suspense fallback={<Dot />}><Avatar /></Suspense>
<Suspense fallback={<Dot />}><Name /></Suspense>
<Suspense fallback={<Dot />}><Stats /></Suspense>
</Card>
// 正模式:一个边界覆盖一个用户可理解的加载单元
<Suspense fallback={<CardSkeleton />}>
<CardHeader />
<CardBody />
<CardFooter />
</Suspense>
源码依据:attachSuspenseRetryListeners 按边界去重监听、节流按"fallback 出现/消失"计时------边界越多,节流窗口内的 commit 次数越多,闪烁概率越高(9.14.1 表)。
9.15.2 导航场景:transition + 边界保留
jsx
function navigate(url) {
startTransition(() => {
setResource(fetchPage(url)); // 新边界树的 Promise
});
}
<Suspense fallback={<Spinner />}>
<Page resource={resource} />
</Suspense>
startTransition 让 lanes 变成 TransitionLane → RootSuspendedWithDelay 时 finishConcurrentRender 选择不提交 → 旧页面原地不动 → 数据就绪 ping → 重启渲染 → 一次性呈现新页面。这是 9.5.2 节源码逻辑的产品化。若不用 transition,用户会看到:旧页 → 闪一下 Spinner → 新页。
9.15.3 懒加载:preload 前置 + Activity 预热
jsx
const Settings = lazy(() => import('./Settings'));
function Nav() {
return (
<>
{/* 悬停/聚焦即预热 chunk,点击时 import() 命中缓存 */}
<Link
to="/settings"
onMouseEnter={() => ReactDOM.preload('/settings.js', {as: 'script'})}
>
设置
</Link>
{/* 或:后台预渲染整棵路由树 */}
<Activity mode={atSettings ? 'visible' : 'hidden'}>
<Settings />
</Activity>
</>
);
}
注意 preload 的 as 必须与资源类型匹配(script/style/font/image),DEV 下有完整校验(ReactDOMFloat.js:90-112)。
9.15.4 与 useDeferredValue 的分工
useDeferredValue:值本身变化快、渲染慢(搜索框输入 → 列表过滤);- Suspense:值本身还没到(数据未就绪)。
两者在 updateSuspenseComponent 里有接缝:didPrimaryChildrenDefer + getRemainingWorkInPrimaryTree 会把 deferred lane 合并进主内容的剩余工作(9.4.3 节)------即"Suspense 边界里的 useDeferredValue 派生的任务"会被正确记录为重试优先级。
9.15.5 SSR:边界划分决定首屏与流式策略
- 边界内的数据慢:首屏先出 fallback,数据到了 Fizz 流式补齐------首屏快,但该区域有加载态;
- 边界外的数据慢:整个首屏被阻塞------不要这么做;
- 每个路由区块一个边界 :配合
preinit提前下发关键 chunk,是 Next.js App Router 的默认策略。
判断准则:把"不阻塞用户理解页面"的部分包进边界(评论区、推荐栏、次级数据),把"首屏核心"(标题、主操作)留在边界外。
9.15.6 自测清单
- Promise reject 时有错误边界接住吗?
- 导航/筛选操作用
startTransition包了吗? - 有元素需要"挂起期间仍可交互"吗?它放在边界外了吗?
- fallback 是轻量的吗(<10ms 渲染)?
- 隐藏中的 Activity/Offscreen 树超过 3 层了吗?
- 有没有在渲染中现场
fetch并use()?
9.16 源码索引表
9.16.1 react 包(packages/react/src)
| 文件 | 符号 | 行号 | 职责 |
|---|---|---|---|
| ReactLazy.js | lazy |
226-263 | 创建 lazy 组件(_status/_result/_init payload) |
| ReactLazy.js | lazyInitializer |
70-224 | payload 四态状态机;resolve 时双轨打补丁 |
| ReactLazy.js | Uninitialized/Pending/Resolved/Rejected |
25-28 | payload 状态常量 |
| ReactHooks.js | use |
207-210 | use() 公共入口 |
| ReactStartTransition.js | startTransition |
--- | 见第 8 章,本章 9.5 节消费 |
9.16.2 react-reconciler:thenable 与挂起协议
| 文件 | 符号 | 行号 | 职责 |
|---|---|---|---|
| ReactFiberThenable.js | SuspenseException |
57-65 | render 挂起的不透明异常 |
| ReactFiberThenable.js | SuspenseyCommitException |
67-70 | commit 挂起异常 |
| ReactFiberThenable.js | noopSuspenseyCommitThenable |
84-93 | 假 thenable,触发 fallback 用 |
| ReactFiberThenable.js | createThenableState |
95-106 | ThenableState 初始化 |
| ReactFiberThenable.js | isThenableResolved |
108-111 | 同步探测 status |
| ReactFiberThenable.js | trackUsedThenable |
125-341 | thenable 记账 + 补丁 + 三态分发 |
| ReactFiberThenable.js | suspendCommit |
343-350 | suspensey commit 入口 |
| ReactFiberThenable.js | resolveLazy |
352-371 | lazy 挂起 → 标准协议转译 |
| ReactFiberThenable.js | getSuspendedThenable |
378-395 | 取回真实 thenable |
| ReactFiberThenable.js | checkIfUseWrappedInTryCatch |
397-408 | DEV:检测用户吞掉挂起 |
| ReactFiberThenable.js | checkIfUseWasUsedBefore |
452-479 | DEV:条件 use() 检测 |
| ReactFiberHooks.js | useThenable |
1098-1157 | Hook 侧 thenable 入口(位置计数) |
| ReactFiberHooks.js | use |
1159-1179 | reconciler 侧 use 分派 |
| ReactChildFiber.js | unwrapThenable |
282-289 | thenable 作为 children 的入口 |
9.16.3 react-reconciler:Suspense 上下文与状态
| 文件 | 符号 | 行号 | 职责 |
|---|---|---|---|
| ReactFiberSuspenseComponent.js | SuspenseState 类型 |
31-43 | 边界 memoizedState 的类型 |
| ReactFiberSuspenseComponent.js | SuspenseListRenderState |
45-59 | SuspenseList 渲染态 |
| ReactFiberSuspenseComponent.js | RetryQueue |
61 | Set<Wakeable> |
| ReactFiberSuspenseComponent.js | findFirstSuspended |
63-107 | 找第一个挂起边界 |
| ReactFiberSuspenseContext.js | suspenseHandlerStackCursor |
22-23 | "最近的 catch"栈 |
| ReactFiberSuspenseContext.js | shellBoundary |
37-41 | shell 边界(不可见最外层) |
| ReactFiberSuspenseContext.js | pushPrimaryTreeSuspenseHandler |
43-102 | 进主内容:压 handler |
| ReactFiberSuspenseContext.js | pushFallbackTreeSuspenseHandler |
104-109 | 进 fallback:复用外层 handler |
| ReactFiberSuspenseContext.js | getSuspenseHandler |
150-152 | throwException 查栈用 |
| ReactFiberSuspenseContext.js | ForceSuspenseFallback |
176 | SuspenseList 强制 fallback 位 |
| ReactFiberSuspenseContext.js | pushSuspenseListCatch |
213-225 | tail hidden 模式自任 handler |
| ReactFiberOffscreenComponent.js | OffscreenState |
37-43 | 非空即隐藏 |
| ReactFiberOffscreenComponent.js | OffscreenQueue |
45-49 | Offscreen 的 updateQueue 结构 |
| ReactFiberOffscreenComponent.js | OffscreenInstance |
56-61 | _visibility/_retryCache 等 |
| ReactFiberHiddenContext.js | isCurrentTreeHidden |
--- | 当前树是否隐藏 |
9.16.4 react-reconciler:throw 与工作循环
| 文件 | 符号 | 行号 | 职责 |
|---|---|---|---|
| ReactFiberThrow.js | resetSuspendedComponent |
203-239 | context 预播 + legacy 回滚 |
| ReactFiberThrow.js | markSuspenseBoundaryShouldCapture |
241-362 | 盖章 ShouldCapture;启发式注释在 316-356 |
| ReactFiberThrow.js | throwException |
364-705 | 挂起/错误总分拣 |
| ReactFiberWorkLoop.js | RootExitStatus 枚举 |
427-434 | 含 RootSuspendedWithDelay |
| ReactFiberWorkLoop.js | SuspendedReason 枚举 |
445-455 | 十种挂起原因 |
| ReactFiberWorkLoop.js | workInProgressSuspendedReason |
461 | 当前挂起原因 |
| ReactFiberWorkLoop.js | FALLBACK_THROTTLE_MS |
528 | 300ms 节流窗口 |
| ReactFiberWorkLoop.js | renderDidSuspend |
2546-2550 | RootSuspended |
| ReactFiberWorkLoop.js | renderDidSuspendDelayIfPossible |
2552-2596 | RootSuspendedWithDelay |
| ReactFiberWorkLoop.js | markCommitTimeOfFallback |
2535-2537 | 节流计时起点 |
| ReactFiberWorkLoop.js | handleThrow |
2302-2410+ | 异常分拣为 SuspendedReason |
| ReactFiberWorkLoop.js | renderRootConcurrent resumeOrUnwind |
2820-3023 | 各 reason 的重试/展开决策 |
| ReactFiberWorkLoop.js | throwAndUnwindWorkLoop |
3234-3340 | 调 throwException + unwind |
| ReactFiberWorkLoop.js | finishConcurrentRender |
1420-1576 | 退出状态 → commit 决策 |
| ReactFiberWorkLoop.js | completeRootWhenReady |
1578-1700+ | suspensey commit 关卡 |
| ReactFiberWorkLoop.js | requestRetryLane |
870-882 | RetryLane 分配 |
| ReactFiberWorkLoop.js | attachPingListener |
4970-5014 | render 阶段 ping 监听 |
| ReactFiberWorkLoop.js | pingSuspendedRoot |
5016-5096 | ping 决策(重启/记录/调度) |
| ReactFiberWorkLoop.js | retryTimedOutBoundary |
5137-5153 | 边界级重试 |
| ReactFiberWorkLoop.js | resolveRetryWakeable |
5164-5199 | commit 阶段 retry 回调 |
| ReactFiberWorkLoop.js | shellSuspendCounter 检查 |
2739-2741 | 无限 ping 熔断计数 |
| ReactFiberLane.js | RetryLanes/SomeRetryLane |
94-100 | 4 个 RetryLane |
| ReactFiberLane.js | claimNextRetryLane |
747-754 | 轮询分配 |
| ReactFiberLane.js | OffscreenLane/DeferredLane |
109-110 | 预渲染/延迟 lane |
9.16.5 react-reconciler:begin/complete 与 commit
| 文件 | 符号 | 行号 | 职责 |
|---|---|---|---|
| ReactFiberBeginWork.js | SUSPENDED_MARKER |
2287-2292 | fallback 档位常量 |
| ReactFiberBeginWork.js | mountSuspenseOffscreenState |
2294-2299 | cachePool 收拢 |
| ReactFiberBeginWork.js | updateSuspenseOffscreenState |
2301-2332 | cachePool 复用/刷新 |
| ReactFiberBeginWork.js | shouldRemainOnFallback |
2335-2363 | ForceSuspenseFallback 消费 |
| ReactFiberBeginWork.js | getRemainingWorkInPrimaryTree |
2365-2381 | childLanes + deferred 合并 |
| ReactFiberBeginWork.js | updateSuspenseComponent |
2383-2647 | Suspense 渲染总入口 |
| ReactFiberBeginWork.js | mountSuspensePrimaryChildren |
2649-2667 | 主内容 = Offscreen(visible) |
| ReactFiberBeginWork.js | mountSuspenseFallbackChildren |
2669-2732 | 挂载时双 fragment |
| ReactFiberBeginWork.js | updateSuspensePrimaryChildren |
2753-2788 | 回主内容 = 删 fallback |
| ReactFiberBeginWork.js | updateSuspenseFallbackChildren |
2790-2878 | 切 fallback = 主内容转 hidden |
| ReactFiberBeginWork.js | retrySuspenseComponentWithoutHydrating |
2880-2906 | 水合失败降级 client render |
| ReactFiberBeginWork.js | mountDehydratedSuspenseComponent |
2949-2970 | 首遇脱水边界:留 lane |
| ReactFiberBeginWork.js | updateDehydratedSuspenseComponent |
2972-3217 | 水合四分支 + 选择性水合 |
| ReactFiberBeginWork.js | mountLazyComponent |
2113-2207 | lazy 解析与挂载 |
| ReactFiberBeginWork.js | updateSuspenseListComponent |
3447-3628 | SuspenseList 头尾切分 |
| ReactFiberBeginWork.js | initSuspenseListRenderState |
3397-3427 | 渲染态初始化 |
| ReactFiberBeginWork.js | beginWork SuspenseComponent 早退 |
4039-4100 | childLanes 预筛 |
| ReactFiberBeginWork.js | beginWork dispatch |
4375-4377 | → updateSuspenseComponent |
| ReactFiberCompleteWork.js | scheduleRetryEffect |
640-672 | Update/ScheduleRetry 标志 |
| ReactFiberCompleteWork.js | SuspenseListComponent 分支 | 1709-1942 | 第二遍强制 + 尾部逐行 |
| ReactFiberCompleteWork.js | bubbleProperties |
793-900+ | flags/lanes/subtreeFlags 冒泡 |
| ReactFiberCompleteWork.js | SuspenseComponent 分支 | 1549-1665 | 第二遍渲染触发、Visibility/cache/retry 调度 |
| ReactFiberCompleteWork.js | OffscreenComponent 分支 | 1966-2010+ | pop handler、Visibility 比较、bubble 条件 |
| ReactFiberHydrationContext.js | reenterHydrationStateFromDehydratedSuspenseInstance |
204-225 | 水合游标重置 |
| ReactFiberHydrationContext.js | claimNextHydratableSuspenseInstance |
518+ | 认领下一个 Suspense 占位 |
| ReactFiberHydrationContext.js | upgradeHydrationErrorsToRecoverable |
907+ | 水合错误转可恢复错误 |
| ReactFiberLane.js | getBumpedLaneForHydration |
1092-1114 | 水合 lane 阶梯与放弃判定 |
| ReactFiberLane.js | getBumpedLaneForHydrationByLane |
1116+ | lane → hydration lane 映射表 |
| ReactFiberCommitWork.js | commitSuspenseCallback |
1857-1873 | suspenseCallback(实验) |
| ReactFiberCommitWork.js | commitSuspenseHydrationCallbacks |
1910-1945 | 水合完成回调 |
| ReactFiberCommitWork.js | getRetryCache |
1947-1976 | 按边界的 retry 去重缓存 |
| ReactFiberCommitWork.js | attachSuspenseRetryListeners |
1978-2008 | commit 阶段 retry 监听 |
| ReactFiberCommitWork.js | isSuspenseBoundaryBeingHidden |
2013-2025 | 可见→隐藏判定 |
| ReactFiberCommitWork.js | SuspenseComponent mutation | 2515-2566 | 节流计时 + retry 交接 |
| ReactFiberCommitWork.js | OffscreenComponent mutation | 2567-2707 | 隐藏栈 + disappear + hide/unhide |
| ReactFiberCommitWork.js | hideOrUnhideAllChildren |
1202 | display:none 改写 |
| ReactFiberCommitWork.js | disappearLayoutEffects |
3101-3287 | layout unmount |
| ReactFiberCommitWork.js | reappearLayoutEffects |
3304-3625 | layout remount |
| ReactFiberCommitWork.js | commitOffscreenPassiveMountEffects |
3627+ | Offscreen passive effect 连接 |
| ReactFiberCommitWork.js | passive OffscreenComponent 分支 | 4213-4340 / 4586-4660 | _visibility 位状态机 |
| ReactFiberCommitWork.js | accumulateSuspenseyCommit |
4873-5040 | suspensey commit 遍历 |
| ReactFiberFlags.js | DidCapture/ShouldCapture |
21/61 | 两代捕获标志 |
| ReactFiberFlags.js | Visibility/ScheduleRetry/MaySuspendCommit |
39/46/76 | Offscreen 相关标志 |
| ReactFiberReconciler.js | shouldSuspend |
585-589 | DevTools 强制 fallback |
9.16.6 宿主层与特性开关
| 文件 | 符号 | 行号 | 职责 |
|---|---|---|---|
| react-dom/.../ReactFiberConfigDOM.js | preloadInstance |
6575-6586 | 图片是否已加载 |
| react-dom/.../ReactFiberConfigDOM.js | suspendInstance |
6639-6667 | 图片 decode 挂起 |
| react-dom/.../ReactFiberConfigDOM.js | suspendResource |
6669+ | 样式表挂起 |
| react-dom/.../ReactFiberConfigDOM.js | startSuspendingCommit |
6612-6627 | SuspendedState 初始化 |
| react-dom/src/shared/ReactDOMFloat.js | preload |
89-152 | 资源预加载 API |
| react-dom/src/shared/ReactDOMFloat.js | preinit |
211+ | 预下载并执行 |
| shared/ReactFeatureFlags.js | enableSuspenseAvoidThisFallback |
123 | 默认 false |
| shared/ReactFeatureFlags.js | enableCPUSuspense |
125 | 实验:<Suspense defer> |
| shared/ReactFeatureFlags.js | alwaysThrottleRetries |
135 | 默认 true |
9.17 自测 FAQ
Q1:组件里 throw promise 和 use(promise) 在 React 19 有区别吗? 有。throw promise 走 SuspendedOnDeprecatedThrowPromise:强制 unwind、无 replay、无位置缓存、无法检测用户 try/catch 吞掉。use() 走 SuspendedOnImmediate:可原地 replay、有 ThenableState 缓存、抛不透明异常。新代码只用 use();看到库里还在 throw thenable,那是历史兼容路径。
Q2:为什么 fallback 出现时旧内容没有卸载,我的 useEffect cleanup 却没有执行? 因为隐藏只触发 disappear layout 遍历(disappearLayoutEffects):layout effect cleanup 和类组件 componentWillUnmount 会跑,passive effect cleanup 不跑 ,DOM 用 display:none 保留。要清理副作用,用 useLayoutEffect 的 cleanup,或自己管理 AbortController。
Q3:startTransition 为什么能让旧界面"赖着不走"? startTransition 把更新派到 TransitionLane。shell 挂起时退出状态是 RootSuspendedWithDelay,finishConcurrentRender 判定 includesOnlyTransitions(lanes) 成立 → 不 commit、不显示 fallback,只 markRootSuspended 挂着。数据 ping 到达 → prepareFreshStack 重启渲染 → 直接出新内容。
Q4:两个组件等同一个 Promise,会挂两个监听吗? 不会。render 阶段 attachPingListener 用 root.pingCache(WeakMap<thenable, Set>)按 lanes 去重;commit 阶段 attachSuspenseRetryListeners 用每个边界自己的 retryCache 去重。极端情况(不同 lanes 的两次渲染)可能各挂一个,ping 各自处理,互不干扰。
Q5:Promise reject 会发生什么?Suspense 会一向 retry 吗? 不会无限 retry。trackUsedThenable 对 rejected thenable 直接 throw reason(真实错误)。第一次挂起时它表现为"挂起";fallback 提交后 retry 触发 replay,组件重执行时同步走到 'rejected' 分支抛出错误 → 走 SuspendedOnError 路径找错误边界。所以 Promise reject 的归宿是错误边界,不是 Suspense。
Q6:<Suspense> 没有 fallback 属性会怎样? nextProps.fallback 为 undefined,mountSuspenseFallbackChildren 用 createFiberFromFragment(undefined, ...) 建一个空 fragment------视觉上内容直接消失(变成空白),而不是保留旧内容。边界仍然可以工作(恢复后内容回来),但 UX 上通常应该始终提供 fallback。
Q7:为什么我的 lazy 组件在 fallback 里卡住不动? 典型原因是 chunk 加载失败(网络错误)且没有错误边界:rejected 的 thenable 把错误一路抛到根。另一个可能是循环 lazy(lazy(() => import('./X')) 里 X 又 lazy 回自己)。检查控制台是否有 "A component suspended while responding to synchronous input" 或 chunk 404。
Q8:hydration 时点按钮没反应,几秒后突然处理了,为什么? 这就是选择性水合:点击发生在尚未 hydrate 的边界内,事件被捕获但无法立即派发;React 用 SelectiveHydrationException 打断当前渲染,把该边界升级到 InputContinuousHydrationLane 优先水合,水合完立即重放事件。如果"几秒"很久,说明水合总工作量太大------检查是否有巨型同步渲染阻塞,或把非关键区域包进 <Suspense>/<Activity mode="hidden"> 推迟水合。
Q9:Suspense 边界里能用 useEffect 发请求吗? 能,但那样 Suspense 只在一个渲染周期后生效(effect 在 commit 后跑,setState 触发第二次渲染才挂起),首屏仍有一次"空渲染"。声明式的做法是把 Promise 的创建挪到渲染外(或框架/库里),让渲染第一次执行时就能挂起。
Q10:嵌套边界中,内层 fallback 里再挂起,为什么外层 fallback 替换了内层? pushFallbackTreeSuspenseHandler 在渲染 fallback 时把 handler 栈恢复为外层边界------"catch 块里再抛,同一个 catch 接不住"。这是有意的防递归设计:否则 fallback 渲染自己的 fallback 会无限套娃。
Q11:use() 可以在条件语句里吗? 不行,且比其它 Hook 更隐蔽:use() 不占 Hook 槽位而占 ThenableState 槽位,条件调用导致同一 index 前后渲染出现不同 thenable,新的被静默丢弃(thenable.then(noop, noop)),界面停在旧 Promise 上。DEV 的 checkIfUseWasUsedBefore 会在组件最终 unsuspend 后发现"上次挂起位置这次没调 use()"并打出带堆栈的警告。
Q12:React 19 的图片"挂起提交"会让我的 commit 延迟很多吗? waitForCommitToBeReady 有一个基于字节数/时间的超时策略(timeoutOffset 结合 globalMostRecentFallbackTime/globalMostRecentTransitionTime),并且只对 ShouldSuspendCommit/MaySuspendCommit 标记的子树生效------默认只有 <img>(支持 decode 时)和 preinit 的样式表等"suspensey"资源。普通 commit 不受影响。若看到 commit 被推迟,用 DevTools Performance 的 suspended commit 区段定位是哪些资源。
Q13:为什么 Suspense 边界里 useLayoutEffect 的 cleanup 在 fallback 显示时执行了,useEffect 的没有? 隐藏只触发 disappear layout 遍历(disappearLayoutEffects):layout effect cleanup、类组件 componentWillUnmount、ref detach 会执行;passive effect cleanup 不执行,DOM 保留。这是 9.8.3 节的直接结论。把"必须随隐藏清理"的逻辑放 layout effect。
Q14:completeWork 里为什么有时候 return workInProgress? ReactFiberCompleteWork.js:1587-1598:边界完成时仍带 DidCapture,就把自己作为下一个工作单元返回,让工作循环再次 beginWork 这个边界------updateSuspenseComponent 这次会读 DidCapture 走 fallback 分支。这就是"第一遍主内容、第二遍 fallback"的循环实现,不是递归。
Q15:多个兄弟 Suspense 边界同时挂起,会显示几个 fallback? 每个边界各自盖章、各自显示自己的 fallback------除非它们的共同外层还有边界。这正是"按加载单元设边界"的原因:想要一个统一加载态,就在外层再包一个边界(内层全挂 → 外层只显示一次)。注意外层 fallback 出现时内层全部转 hidden。
Q16:<Activity> 和 <Suspense> 怎么选? 要"显示加载 UI"用 Suspense;要"保留/预渲染但不显示"用 Activity。Activity 没有 fallback:它挂起时表现为"这次不提交这块内容",而不是显示任何占位。两者可以组合:Activity 包裹路由面板,面板内部再用 Suspense 处理数据。
Q17:retry 是同步还是异步? 异步、低优先级、可打断。retryTimedOutBoundary 只做"入队 + 调度"(enqueueConcurrentRenderForLane + ensureRootIsScheduled),实际渲染发生在后续的调度回调里,走 workLoopConcurrent(每帧让出)。所以 ping 之后 UI 不会"瞬间"更新,中间还可能被更高优先级的输入插队。
Q18:为什么我的 fallback 出现概率论式地时快时慢? 三个时间源在叠加:SuspendedOnImmediate 的让出一帧(微任务数据可免 fallback)、RootSuspendedWithDelay 的 transition 不提交、300ms 节流窗口(globalMostRecentFallbackTime)。网络抖动会让数据落在"让出的一帧内/外"的边界上,表现为时闪时不闪。稳定复现的办法:固定数据延迟(测试用假定时器),或全程 startTransition + 接受"旧内容停留更久"。
Q19:SSR 时 <Suspense> 的 fallback 会被流式替换吗?顺序有保证吗? 会,且顺序有保证。Fizz 按边界在 HTML 中的输出顺序流式补齐;$RC 脚本把 <div hidden> 内容搬到占位处。客户端的 SuspenseInstance 就是这些占位节点的句柄,registerSuspenseInstanceRetry(ReactFiberCommitWork.js:754-779)为 pending 实例登记"服务器内容到达后 retry 该边界"的回调。多个边界并发就绪时,React 按认领顺序处理,不保证与网络到达顺序一致。
Q20:能自己实现一个"假 wakeable"驱动 Suspense 吗(比如用于测试)? 可以,这就是 wakeable 抽象开放性的体现:任何带 then 的对象都行。测试里常见的模式是"可外部 resolve 的 thenable":
js
function createControllableThenable() {
let resolve;
const thenable = {
then(onFulfilled) {
resolve = onFulfilled;
},
};
return {
thenable,
resolve(value) {
resolve(value);
},
};
}
挂起它 → 显示 fallback;调用 resolve → ping → retry。注意真实使用中应提供带 status 补丁的 thenable(或直接 Promise),否则 trackUsedThenable 会自己打补丁(pendingThenable.status = 'pending'),行为与原生 Promise 一致。noopSuspenseyCommitThenable 就是 React 内部"假 wakeable"的用法样板。
Q21:shellSuspendCounter 熔断后应用还能恢复吗? 熔断抛出的是一个普通 Error("An unknown Component is an async Client Component..."),会沿错误路径冒泡到错误边界或根------应用不会自动恢复 ,需要用户刷新或错误边界提供重试。计数器本身在每次 renderRootSync 以 didSuspendInShell 为条件递增(ReactFiberWorkLoop.js:2739-2741),没有重置逻辑(直到页面生命周期结束)。所以它是最后防线,不是常规控制流。
Q22:本章知识和第 8 章并发特性如何互相印证? Suspense 是 lane 系统的最大消费者:transition lanes 决定"挂起是否提交"(9.5.2)、RetryLane 决定重试优先级(9.7.5)、OffscreenLane 决定预渲染/水合节奏(9.9.2)、entangledRenderLanes 决定隐藏子树里的 lane 纠缠(9.11.1)、markRootSuspended 的 spawnedLane/didAttemptEntireTree 决定"这棵树试完了没有"(9.3.4)。读第 8 章时可以把 Suspense 当作这些机制的"应用场景"来对照。
9.18 本章要点小结
- Suspense 的本质是一个 try/catch :children 渲染中抛出 thenable(或
use()未就绪),最近边界切换到 fallback;thenable resolve 后 ping/retry 恢复。源码级入口是throwException的 wakeable 分支(ReactFiberThrow.js:381-548)。 - wakeable 抽象是自给自足的状态机 :React 不依赖原生 Promise,而是在 thenable 上打
status/value/reason补丁(trackUsedThenable),实现同步探测(isThenableResolved)与按位置缓存(ThenableState),并抛出不透明的SuspenseException防止用户代码吞掉挂起信号。 - 一个
<Suspense>= 三个 Fiber :SuspenseComponent(状态机 + retryQueue)+ primaryChildFragment(Offscreen,显示 fallback 时转 hidden 但保留 DOM 与 state)+ fallbackFragment。memoizedState三态(null / SUSPENDED_MARKER / dehydrated)就是边界的档位。 - 找边界靠栈不靠 return 链 :
suspenseHandlerStackCursor在 beginWork 时同步维护;shellBoundary区分"理想/不理想 fallback",是 React 18 之后所有"不闪"策略的判定基础。 - 双 listener 架构 :render 阶段的
attachPingListener(数据可能比 fallback 快)+ commit 阶段的attachSuspenseRetryListeners(fallback 之后的恢复),分别通向"重启整个渲染"和"只重试该边界"。 - retry 用独立的 RetryLane 组 :低优先级、可打断、轮询分配、被节流(300ms
FALLBACK_THROTTLE_MS)------保持时间切片,饿不死输入。 - commit 阶段是"样式改写 + effect 编排"而非 DOM 重建 :
hideOrUnhideAllChildren改display:none,disappear/reappear layout 遍历管理 effect 生命周期;markCommitTimeOfFallback记录节流时钟。 - hydration 让 Suspense 变成"分块认领" :dehydrated SuspenseState、OffscreenLane 默认优先级、
SelectiveHydrationException实现的选择性水合、失败降级 client render。 - React 19 的三块新拼图 :
use()正式化挂起契约;suspensey commit 把"等资源"提前到提交前(MaySuspendCommit、suspendInstance);preload/preinit把资源加载变成可编排的指令式 API。 - 分工原则 :pending → Suspense;rejected → 错误边界;CPU 重 →
useDeferredValue/<Suspense defer>;隐藏预渲染 →<Activity>。边界按"用户可理解的加载单元"划分,导航挂起一律startTransition。
下一章预告(第 10 章 事件系统) :本章多次提到"事件发生在未 hydrate 边界内会被暂存重放""discrete 事件的优先级"。第 10 章会揭开 React 19 事件系统的面纱:从原生事件委托到
dispatchDiscreteEvent,从 lane 优先级到SuspendedOnHydration与事件重放的完整闭环。
(本章完。所有行号基于 react-19.3.1 仓库 React 19.3.1 源码;引用其它章节时使用系列统一编号。若发现注释与代码不一致处,正文已按"以源码为准"的原则注明。)