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

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

四个关键设计:

  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:

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

  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 的主体,这里补齐它的去重机制:

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

三种情况:

  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:

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

  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 分支

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

三件事:

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

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

  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 丢失,键盘用户直接"失焦":

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

  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"相比,它的定位是**"预渲染 + 状态保持的页面切换"**:

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

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

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

  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 前的最后一道关):

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

  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:

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 温床

  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 首次见到 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 反而是负优化

  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 边界的粒度:按"加载单元"而非"组件"

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

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

  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 源码;引用其它章节时使用系列统一编号。若发现注释与代码不一致处,正文已按"以源码为准"的原则注明。)

相关推荐
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(上)
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(下)
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 13 章:React 19 新特性深解
前端·react.js·开源
wflynn1 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
git·开源·go·github·agent·coding-agents
donk13141 小时前
React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南
javascript·分布式·react native·react.js·wpf·harmonyos
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
前端·react.js·开源
小小龙学IT1 小时前
Nginx 开源 Web 服务器与反向代理深度解
前端·nginx·开源
miofly2 小时前
腾讯开源 Hy Memory 2.0 记忆系统,3B 激活参数超越 claude-sonnet-5-5
开源·github