React 19.3.1 源码深度剖析-第 9 章(下)lazy、retry、hydration 与实战

0 阅读48分钟

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 状态缓存”用到了极致。

// 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 的四态状态机

// 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 Pendingctor() 返回的 thenable
1 Resolved模块对象 {default: Component}
2 Rejected错误对象

9.6.3 lazyInitializer 逐行

// 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;
  }
}

四个关键设计:

  1. then 注册与 _status 迁移的顺序:先 then,再把状态置为 Pending。如果 thenable 在 then 注册时同步 resolve(某些打包器/预取场景),回调里看到的还是 Uninitialized,条件判断里特意包含了 Uninitialized——直接迁移到 Resolved,连 Pending 都不经过。随后 if (payload._status === Uninitialized) 为假,不会覆盖。这套双条件守卫保证了同步 resolve 不出错。
  2. thenable 补丁与 payload 补丁双轨:_status/_result 是 payload 的状态机(lazy 自己用),thenable.status/value 是 thenable 的 expando(React 的 trackUsedThenable 用)。两者在 resolve 回调里被同时写入——注释 “Make the thenable introspectable” 说的就是这件事。这让 lazy 的 thenable 也能被 isThenableResolved 同步探测。
  3. 错误也是状态,不是每次都抛:Rejected 后 _result 存错误,之后每次 lazyInitializer 都 throw payload._result——同一个错误对象反复抛,错误边界每次都能拿到同一个引用(不会因为“新错误”触发重复日志)。
  4. DEV 校验:Resolved 后检查 moduleObject 是否有 default,专门抓 lazy(() => import('./X').then(m => m)) 这类忘记 default 的写法,错误消息直接给出正确写法示例。

9.6.4 lazy 与 Suspense 的接缝:mountLazyComponent

reconciler 如何消费 lazy:

// 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 挂起的入口:

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):

    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 拉下来:

// 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 节),这意味着:

  1. 错误边界只触发一次恢复:第一次 reject 时错误边界渲染错误 UI;之后如果用户重试(比如重置 key 重挂载边界),新的 lazy 挂载会重新 throw 同一个错误对象——错误边界可能直接再次进入错误态,而不是“再试一次加载”。要真正重试,只能让 lazy 组件重新创建(新的 payload 从 Uninitialized 开始)。这是生产环境“刷新 chunk 失败后白屏”的常见根因,框架通常用 window.location.reload() 兜底。
  2. Fast Refresh 的影响:DEV 下 mountLazyComponent 会调 resolveTypeForHotReloading(ReactFiberBeginWork.js:2124-2126)处理“模块替换后 lazy 组件指向了新模块”的情况;ReactFiberHotReloading.js 的 markFailedErrorBoundaryForHotReloading 则保证热更新时错误边界不会吞掉新错误。改 lazy 组件源码时如果页面卡在错误态,保存一次通常就能恢复——这是设计使然。
  3. 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 的主体,这里补齐它的去重机制:

// 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 之后的决策

// 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);
}

三种情况:

  1. ping 到达时正在渲染同优先级的内容,且当前渲染已 RootSuspendedWithDelay(或节流窗口内的 retry):立刻 prepareFreshStack 从头重启渲染。这是“数据在 fallback 显示前就好了,干脆整个重来”的优化——省掉一次 fallback 的提交。注意 render phase 中不能重启(会崩),降级为记录 workInProgressRootPingedLanes。
  2. 当前渲染不能重启:记录 pinged lanes。markRootSuspended 时看到这些 lanes 被 ping 过,就不把它们标记为 suspended——给 retry 留门。
  3. 无论如何 ensureRootIsScheduled(root):把根重新排进调度。ping 只是“敲门”,真正干活的是后续的调度回调。

9.7.4 retryTimedOutBoundary:边界级重试

fallback 已经提交的情况下,ping 走的是另一条路——不是重启整个根,而是只重试那个边界。入口在 commit 阶段挂的 listener:

// 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 后动作
attachPingListenerrender 阶段(throwException)root.pingCache(按 root+lanes)pingSuspendedRoot:可能重启整个渲染
attachSuspenseRetryListenerscommit 阶段(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:

// 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):

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):

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 组?三个理由,都能在源码里找到对应:

  1. 保持时间切片: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 渲染被显式降速,每隔一帧让出一次,给输入留缝。
  2. 节流可识别:includesOnlyRetries(lanes) 是节流逻辑的前提(finishConcurrentRender 的 includesOnlyRetries 检查)。如果 retry 混用 DefaultLane,就无法区分“这是重试渲染”和“这是新更新”,300ms 节流就失效了。
  3. 失败可重派:retry 渲染又挂起时,需要能再次安排 retry(可能换一个 lane)。markRootSuspended 的 spawnedLane 参数就是干这个的——retry 期间新派生的 lane 被单独追踪(workInProgressSuspendedRetryLanes)。

RetryLane 还有 4 个这么多,是为了多个边界并发 retry 时互不污染:边界 A 的 retry 渲染被边界 B 的更新打断时,A 的 lanes 不会被 B 的 lanes “吸收”,各自的状态机独立。

9.7.6 一条完整的时间线

把散落的片段串成一次标准生命周期(初始挂起 → fallback → 数据就绪 → 恢复):

sequenceDiagram
    participant C as 组件树
    participant WL as WorkLoop
    participant B as Suspense边界
    participant D as DOM
    participant P as Promise

    Note over C: 首次渲染,Slow 组件 use(p)
    C->>WL: throw SuspenseException
    WL->>B: ShouldCapture + updateQueue={p}
    WL->>P: attachPingListener(ping)(render阶段监听①)
    WL->>B: updateSuspenseComponent → fallback 渲染
    WL->>D: commit:插入 fallback DOM,主内容 display:none
    Note over B,D: memoizedState = SUSPENDED_MARKER
    D->>P: attachSuspenseRetryListeners(commit阶段监听②)
    Note over P: 100ms 后 resolve
    alt fallback 已提交(常见)
        P->>B: retry 回调 → resolveRetryWakeable
        B->>WL: retryTimedOutBoundary(boundary, RetryLane)
        WL->>B: 以 RetryLane 重渲染主内容
        B->>WL: 数据就绪,不再挂起,RootCompleted
        WL->>D: commit:删 fallback DOM,主内容显示
    else 数据在 fallback 提交前 resolve(快网)
        P->>WL: ping 回调 → pingSuspendedRoot
        WL->>WL: prepareFreshStack 从头重启
        Note over D: 用户根本没看到 fallback
    end

图里两条分支对应 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 分支

// 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;
    }

三件事:

  1. Visibility 变化时打时间戳:markCommitTimeOfFallback() 更新 globalMostRecentFallbackTime——9.5.2 节的 300ms 节流窗口以此为起点。alwaysThrottleRetries 恒 true,所以 fallback 出现和消失都记。
  2. commitSuspenseCallback:调用实验性的 suspenseCallback prop(源码注释:“TODO: Delete this feature. It's not properly covered by DEV features.”——一个半成品 API,了解即可)。
  3. 把 render 阶段存的 retryQueue 转成 commit 阶段的 listener:finishedWork.updateQueue = null 先清空(防重复),再 attachSuspenseRetryListeners。这是“双 listener”架构的交接点:render 阶段的 ping 监听防“数据比 fallback 快”,commit 阶段的 retry 监听管“fallback 之后的恢复”。

9.8.2 Offscreen 隐藏/显示:hideOrUnhideAllChildren

主内容的显隐由 primaryChildFragment(Offscreen fiber)驱动:

// 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:

// 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 重新挂上:

// 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”。它解释了两个常见现象:

  1. Suspense 主内容里 useLayoutEffect 的 cleanup 会在 fallback 显示时执行(但 useEffect 不会)——所以“清理定时器/取消订阅”必须放 layout effect 或靠显式的 AbortController,不能指望 useEffect cleanup 兜底;
  2. 主内容里类组件的 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 丢失,键盘用户直接“失焦”:

      // 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”:

// 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:

// 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

// 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 实例(服务器内容还没到):

// 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 唤醒。

分支二:可以水合了:

// 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)**的核心:

// 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)把模块级的水合游标整个重置:

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)决定:

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
SyncLaneSyncHydrationLane
InputContinuousLaneInputContinuousHydrationLane
DefaultLaneDefaultHydrationLane
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):

// 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):

      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)。第一遍渲染完头之后,检查头里是否有边界还挂着:

// 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 分支里看到:

  1. lane 纠缠(entangled lanes):进入隐藏子树时把“本来会提交的 lanes”并入 entangledRenderLanes——隐藏子树里的更新不算数(用户看不见),但 React 要记住“这里欠着一笔渲染”;
  2. suspense handler 切换:pushOffscreenSuspenseHandler 把 handler 设为 Offscreen 自己(隐藏内容里挂起 → 整个边界不提交,而不是显示 fallback);
  3. cache pool 收拢:getSuspendedCache()(9.4.2 节)。

9.11.2 Activity:React 19 的预渲染组件

<Activity> 是 React 19 对 Offscreen 的正式命名与产品化(ActivityComponent tag)。与“裸 Offscreen”相比,它的定位是**“预渲染 + 状态保持的页面切换”**:

<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 里),但有两个关键差异:

  1. Activity 没有 fallback:隐藏内容挂起 → 边界自己 ShouldCapture(ReactFiberThrow.js:487-517 的 Offscreen 分支与 ReactFiberBeginWork.js:4025-4037 的 Activity 分支),表现为“这次不提交这个 Activity 的内容”,而不是显示任何 UI;
  2. 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):

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)。相比旧协议的变化:

  1. 不透明异常:抛 SuspenseException 而非 thenable 本体(9.2.3 节),用户的 try/catch 无法吞掉挂起信号;checkIfUseWrappedInTryCatch 会检测并警告。
  2. replay 语义:use() 挂起的组件在数据 resolve 后原地重放(replaySuspendedUnitOfWork,9.3.4 节),而不是从根重启——旧式 throw promise 强制 unwind(SuspendedOnDeprecatedThrowPromise 的分支注释明说原因:replay 对旧用法可能造成无限 ping)。
  3. Hook 位置记账:use() 计入 Hook 顺序(第 7 章),条件调用会被 lint 规则和 checkIfUseWasUsedBefore(ReactFiberThenable.js:452-479)双重盯上。
  4. 支持 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 前的最后一道关):

// 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 渲染器的实现:

// 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):

    // 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)的宿主实现:

// 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 节的三道防线,以下情况仍会闪:

  1. 非 transition 更新在 shell 挂起:RootSuspendedWithDelay 穿透到 commit(!includesOnlyTransitions && !includesOnlyRetries → "Commit the placeholder")。用户点了一个按钮触发同步数据请求 → fallback 必然出现。解法:startTransition 包裹(第 8 章)。
  2. fallback 内容本身很大:fallback 的插入本身是一次大 commit,如果 fallback 是个重组件,“出现 fallback”就成了二次卡顿。解法:fallback 保持轻量,或用骨架屏的静态部分。
  3. 节流窗口外的连续 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:

<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,你看到的是“卡住的旧数据”而不是错误。正确做法:

// 反模式:每次渲染新 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 温床

  1. 水合顺序:SuspenseList 的多遍渲染假设树是顺序渲染的,而水合算法同样假设顺序——ReactFiberCompleteWork.js:1784-1787 和 :1933-1936 到处在补 pushTreeFork 的坑。SuspenseList + SSR 是官方都不推荐的组合。
  2. 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 首次见到 thenableO(1) + 一次 then 注册:276-293
attachPingListenerO(1)(WeakMap + Set)ReactFiberWorkLoop.js:4987-5013
throwException wakeable 分支O(1)(查栈 + 打标记)ReactFiberThrow.js:381-485
fallback 切换的 render 成本O(fallback 大小)——主内容被 bailout,不渲染updateSuspenseFallbackChildren → bailoutOffscreenComponent
hideOrUnhideAllChildrenO(子树 host 节点数),commit mutation 阶段同步执行ReactFiberCommitWork.js:1202
disappear/reappear layoutO(子树 fiber 数):3101 / :3304
findFirstSuspendedO(行内 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 反而是负优化

  1. 极小数据 + 极快网络:挂起/恢复的固定开销(两次渲染 + 一次节流判断)超过直接同步渲染。症状:内容“晚半拍”出现。对策:这种数据根本不该走 Suspense,同步渲染即可。
  2. 巨型 fallback:fallback 本身渲染慢,等于把等待时间翻倍。
  3. 过深的边界嵌套:每层边界一次 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 观测与调试:四个抓手

  1. 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 的这些消息写得极其精确。
  2. ReactSharedInternals.didUsePromise + act:测试环境里 act() 会检查未 await 的 Promise,把“测试通过了但线上挂起”类问题暴露在 CI。
  3. Profiler 的 Suspense 轨道:logComponentSuspended/logSuspendedRenderPhase 记录哪个组件挂在什么 wakeable 上、渲染被 suspend 的时间段(ReactFiberWorkLoop.js:2392-2410)。
  4. onRecoverableError:hydrateRoot 的 onRecoverableError 选项收到所有水合降级事件(upgradeHydrationErrorsToRecoverable 的产物),SSR 项目应上报它。

9.15 用法与最佳实践

9.15.1 边界的粒度:按“加载单元”而非“组件”

// 反模式:每个小组件一个边界 → 十个 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 + 边界保留

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 预热

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 自测清单

  1. Promise reject 时有错误边界接住吗?
  2. 导航/筛选操作用 startTransition 包了吗?
  3. 有元素需要“挂起期间仍可交互”吗?它放在边界外了吗?
  4. fallback 是轻量的吗(<10ms 渲染)?
  5. 隐藏中的 Activity/Offscreen 树超过 3 层了吗?
  6. 有没有在渲染中现场 fetch 并 use()?

9.16 源码索引表

9.16.1 react 包(packages/react/src)

文件符号行号职责
ReactLazy.jslazy226-263创建 lazy 组件(_status/_result/_init payload)
ReactLazy.jslazyInitializer70-224payload 四态状态机;resolve 时双轨打补丁
ReactLazy.jsUninitialized/Pending/Resolved/Rejected25-28payload 状态常量
ReactHooks.jsuse207-210use() 公共入口
ReactStartTransition.jsstartTransition—见第 8 章,本章 9.5 节消费

9.16.2 react-reconciler:thenable 与挂起协议

文件符号行号职责
ReactFiberThenable.jsSuspenseException57-65render 挂起的不透明异常
ReactFiberThenable.jsSuspenseyCommitException67-70commit 挂起异常
ReactFiberThenable.jsnoopSuspenseyCommitThenable84-93假 thenable,触发 fallback 用
ReactFiberThenable.jscreateThenableState95-106ThenableState 初始化
ReactFiberThenable.jsisThenableResolved108-111同步探测 status
ReactFiberThenable.jstrackUsedThenable125-341thenable 记账 + 补丁 + 三态分发
ReactFiberThenable.jssuspendCommit343-350suspensey commit 入口
ReactFiberThenable.jsresolveLazy352-371lazy 挂起 → 标准协议转译
ReactFiberThenable.jsgetSuspendedThenable378-395取回真实 thenable
ReactFiberThenable.jscheckIfUseWrappedInTryCatch397-408DEV:检测用户吞掉挂起
ReactFiberThenable.jscheckIfUseWasUsedBefore452-479DEV:条件 use() 检测
ReactFiberHooks.jsuseThenable1098-1157Hook 侧 thenable 入口(位置计数)
ReactFiberHooks.jsuse1159-1179reconciler 侧 use 分派
ReactChildFiber.jsunwrapThenable282-289thenable 作为 children 的入口

9.16.3 react-reconciler:Suspense 上下文与状态

文件符号行号职责
ReactFiberSuspenseComponent.jsSuspenseState 类型31-43边界 memoizedState 的类型
ReactFiberSuspenseComponent.jsSuspenseListRenderState45-59SuspenseList 渲染态
ReactFiberSuspenseComponent.jsRetryQueue61Set<Wakeable>
ReactFiberSuspenseComponent.jsfindFirstSuspended63-107找第一个挂起边界
ReactFiberSuspenseContext.jssuspenseHandlerStackCursor22-23“最近的 catch”栈
ReactFiberSuspenseContext.jsshellBoundary37-41shell 边界(不可见最外层)
ReactFiberSuspenseContext.jspushPrimaryTreeSuspenseHandler43-102进主内容:压 handler
ReactFiberSuspenseContext.jspushFallbackTreeSuspenseHandler104-109进 fallback:复用外层 handler
ReactFiberSuspenseContext.jsgetSuspenseHandler150-152throwException 查栈用
ReactFiberSuspenseContext.jsForceSuspenseFallback176SuspenseList 强制 fallback 位
ReactFiberSuspenseContext.jspushSuspenseListCatch213-225tail hidden 模式自任 handler
ReactFiberOffscreenComponent.jsOffscreenState37-43非空即隐藏
ReactFiberOffscreenComponent.jsOffscreenQueue45-49Offscreen 的 updateQueue 结构
ReactFiberOffscreenComponent.jsOffscreenInstance56-61_visibility/_retryCache 等
ReactFiberHiddenContext.jsisCurrentTreeHidden—当前树是否隐藏

9.16.4 react-reconciler:throw 与工作循环

文件符号行号职责
ReactFiberThrow.jsresetSuspendedComponent203-239context 预播 + legacy 回滚
ReactFiberThrow.jsmarkSuspenseBoundaryShouldCapture241-362盖章 ShouldCapture;启发式注释在 316-356
ReactFiberThrow.jsthrowException364-705挂起/错误总分拣
ReactFiberWorkLoop.jsRootExitStatus 枚举427-434含 RootSuspendedWithDelay
ReactFiberWorkLoop.jsSuspendedReason 枚举445-455十种挂起原因
ReactFiberWorkLoop.jsworkInProgressSuspendedReason461当前挂起原因
ReactFiberWorkLoop.jsFALLBACK_THROTTLE_MS528300ms 节流窗口
ReactFiberWorkLoop.jsrenderDidSuspend2546-2550RootSuspended
ReactFiberWorkLoop.jsrenderDidSuspendDelayIfPossible2552-2596RootSuspendedWithDelay
ReactFiberWorkLoop.jsmarkCommitTimeOfFallback2535-2537节流计时起点
ReactFiberWorkLoop.jshandleThrow2302-2410+异常分拣为 SuspendedReason
ReactFiberWorkLoop.jsrenderRootConcurrent resumeOrUnwind2820-3023各 reason 的重试/展开决策
ReactFiberWorkLoop.jsthrowAndUnwindWorkLoop3234-3340调 throwException + unwind
ReactFiberWorkLoop.jsfinishConcurrentRender1420-1576退出状态 → commit 决策
ReactFiberWorkLoop.jscompleteRootWhenReady1578-1700+suspensey commit 关卡
ReactFiberWorkLoop.jsrequestRetryLane870-882RetryLane 分配
ReactFiberWorkLoop.jsattachPingListener4970-5014render 阶段 ping 监听
ReactFiberWorkLoop.jspingSuspendedRoot5016-5096ping 决策(重启/记录/调度)
ReactFiberWorkLoop.jsretryTimedOutBoundary5137-5153边界级重试
ReactFiberWorkLoop.jsresolveRetryWakeable5164-5199commit 阶段 retry 回调
ReactFiberWorkLoop.jsshellSuspendCounter 检查2739-2741无限 ping 熔断计数
ReactFiberLane.jsRetryLanes/SomeRetryLane94-1004 个 RetryLane
ReactFiberLane.jsclaimNextRetryLane747-754轮询分配
ReactFiberLane.jsOffscreenLane/DeferredLane109-110预渲染/延迟 lane

9.16.5 react-reconciler:begin/complete 与 commit

文件符号行号职责
ReactFiberBeginWork.jsSUSPENDED_MARKER2287-2292fallback 档位常量
ReactFiberBeginWork.jsmountSuspenseOffscreenState2294-2299cachePool 收拢
ReactFiberBeginWork.jsupdateSuspenseOffscreenState2301-2332cachePool 复用/刷新
ReactFiberBeginWork.jsshouldRemainOnFallback2335-2363ForceSuspenseFallback 消费
ReactFiberBeginWork.jsgetRemainingWorkInPrimaryTree2365-2381childLanes + deferred 合并
ReactFiberBeginWork.jsupdateSuspenseComponent2383-2647Suspense 渲染总入口
ReactFiberBeginWork.jsmountSuspensePrimaryChildren2649-2667主内容 = Offscreen(visible)
ReactFiberBeginWork.jsmountSuspenseFallbackChildren2669-2732挂载时双 fragment
ReactFiberBeginWork.jsupdateSuspensePrimaryChildren2753-2788回主内容 = 删 fallback
ReactFiberBeginWork.jsupdateSuspenseFallbackChildren2790-2878切 fallback = 主内容转 hidden
ReactFiberBeginWork.jsretrySuspenseComponentWithoutHydrating2880-2906水合失败降级 client render
ReactFiberBeginWork.jsmountDehydratedSuspenseComponent2949-2970首遇脱水边界:留 lane
ReactFiberBeginWork.jsupdateDehydratedSuspenseComponent2972-3217水合四分支 + 选择性水合
ReactFiberBeginWork.jsmountLazyComponent2113-2207lazy 解析与挂载
ReactFiberBeginWork.jsupdateSuspenseListComponent3447-3628SuspenseList 头尾切分
ReactFiberBeginWork.jsinitSuspenseListRenderState3397-3427渲染态初始化
ReactFiberBeginWork.jsbeginWork SuspenseComponent 早退4039-4100childLanes 预筛
ReactFiberBeginWork.jsbeginWork dispatch4375-4377→ updateSuspenseComponent
ReactFiberCompleteWork.jsscheduleRetryEffect640-672Update/ScheduleRetry 标志
ReactFiberCompleteWork.jsSuspenseListComponent 分支1709-1942第二遍强制 + 尾部逐行
ReactFiberCompleteWork.jsbubbleProperties793-900+flags/lanes/subtreeFlags 冒泡
ReactFiberCompleteWork.jsSuspenseComponent 分支1549-1665第二遍渲染触发、Visibility/cache/retry 调度
ReactFiberCompleteWork.jsOffscreenComponent 分支1966-2010+pop handler、Visibility 比较、bubble 条件
ReactFiberHydrationContext.jsreenterHydrationStateFromDehydratedSuspenseInstance204-225水合游标重置
ReactFiberHydrationContext.jsclaimNextHydratableSuspenseInstance518+认领下一个 Suspense 占位
ReactFiberHydrationContext.jsupgradeHydrationErrorsToRecoverable907+水合错误转可恢复错误
ReactFiberLane.jsgetBumpedLaneForHydration1092-1114水合 lane 阶梯与放弃判定
ReactFiberLane.jsgetBumpedLaneForHydrationByLane1116+lane → hydration lane 映射表
ReactFiberCommitWork.jscommitSuspenseCallback1857-1873suspenseCallback(实验)
ReactFiberCommitWork.jscommitSuspenseHydrationCallbacks1910-1945水合完成回调
ReactFiberCommitWork.jsgetRetryCache1947-1976按边界的 retry 去重缓存
ReactFiberCommitWork.jsattachSuspenseRetryListeners1978-2008commit 阶段 retry 监听
ReactFiberCommitWork.jsisSuspenseBoundaryBeingHidden2013-2025可见→隐藏判定
ReactFiberCommitWork.jsSuspenseComponent mutation2515-2566节流计时 + retry 交接
ReactFiberCommitWork.jsOffscreenComponent mutation2567-2707隐藏栈 + disappear + hide/unhide
ReactFiberCommitWork.jshideOrUnhideAllChildren1202display:none 改写
ReactFiberCommitWork.jsdisappearLayoutEffects3101-3287layout unmount
ReactFiberCommitWork.jsreappearLayoutEffects3304-3625layout remount
ReactFiberCommitWork.jscommitOffscreenPassiveMountEffects3627+Offscreen passive effect 连接
ReactFiberCommitWork.jspassive OffscreenComponent 分支4213-4340 / 4586-4660_visibility 位状态机
ReactFiberCommitWork.jsaccumulateSuspenseyCommit4873-5040suspensey commit 遍历
ReactFiberFlags.jsDidCapture/ShouldCapture21/61两代捕获标志
ReactFiberFlags.jsVisibility/ScheduleRetry/MaySuspendCommit39/46/76Offscreen 相关标志
ReactFiberReconciler.jsshouldSuspend585-589DevTools 强制 fallback

9.16.6 宿主层与特性开关

文件符号行号职责
react-dom/…/ReactFiberConfigDOM.jspreloadInstance6575-6586图片是否已加载
react-dom/…/ReactFiberConfigDOM.jssuspendInstance6639-6667图片 decode 挂起
react-dom/…/ReactFiberConfigDOM.jssuspendResource6669+样式表挂起
react-dom/…/ReactFiberConfigDOM.jsstartSuspendingCommit6612-6627SuspendedState 初始化
react-dom/src/shared/ReactDOMFloat.jspreload89-152资源预加载 API
react-dom/src/shared/ReactDOMFloat.jspreinit211+预下载并执行
shared/ReactFeatureFlags.jsenableSuspenseAvoidThisFallback123默认 false
shared/ReactFeatureFlags.jsenableCPUSuspense125实验:<Suspense defer>
shared/ReactFeatureFlags.jsalwaysThrottleRetries135默认 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”:

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 本章要点小结

  1. Suspense 的本质是一个 try/catch:children 渲染中抛出 thenable(或 use() 未就绪),最近边界切换到 fallback;thenable resolve 后 ping/retry 恢复。源码级入口是 throwException 的 wakeable 分支(ReactFiberThrow.js:381-548)。
  2. wakeable 抽象是自给自足的状态机:React 不依赖原生 Promise,而是在 thenable 上打 status/value/reason 补丁(trackUsedThenable),实现同步探测(isThenableResolved)与按位置缓存(ThenableState),并抛出不透明的 SuspenseException 防止用户代码吞掉挂起信号。
  3. 一个 <Suspense> = 三个 Fiber:SuspenseComponent(状态机 + retryQueue)+ primaryChildFragment(Offscreen,显示 fallback 时转 hidden 但保留 DOM 与 state)+ fallbackFragment。memoizedState 三态(null / SUSPENDED_MARKER / dehydrated)就是边界的档位。
  4. 找边界靠栈不靠 return 链:suspenseHandlerStackCursor 在 beginWork 时同步维护;shellBoundary 区分“理想/不理想 fallback”,是 React 18 之后所有“不闪”策略的判定基础。
  5. 双 listener 架构:render 阶段的 attachPingListener(数据可能比 fallback 快)+ commit 阶段的 attachSuspenseRetryListeners(fallback 之后的恢复),分别通向“重启整个渲染”和“只重试该边界”。
  6. retry 用独立的 RetryLane 组:低优先级、可打断、轮询分配、被节流(300ms FALLBACK_THROTTLE_MS)——保持时间切片,饿不死输入。
  7. commit 阶段是“样式改写 + effect 编排”而非 DOM 重建:hideOrUnhideAllChildren 改 display:none,disappear/reappear layout 遍历管理 effect 生命周期;markCommitTimeOfFallback 记录节流时钟。
  8. hydration 让 Suspense 变成“分块认领”:dehydrated SuspenseState、OffscreenLane 默认优先级、SelectiveHydrationException 实现的选择性水合、失败降级 client render。
  9. React 19 的三块新拼图:use() 正式化挂起契约;suspensey commit 把“等资源”提前到提交前(MaySuspendCommit、suspendInstance);preload/preinit 把资源加载变成可编排的指令式 API。
  10. 分工原则: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 源码;引用其它章节时使用系列统一编号。若发现注释与代码不一致处,正文已按“以源码为准”的原则注明。)