React 19.3.1 源码深度剖析-第 9 章(上)Suspense 核心机制

0 阅读47分钟

React 19.3.1 源码深度剖析-第 9 章(上)Suspense 核心机制

本篇是《第 9 章 Suspense 与懒加载》的上半部分,聚焦 Suspense 的核心机制:动机、wakeable/thenable 抽象、「抛出一个 Promise」的完整链路、SuspenseComponent Fiber 结构、边界语义。

下半部分(第 09 章 B)覆盖 lazy()、retry 队列与 ping、commit 阶段处理、hydration 中的 Suspense、SuspenseList / Offscreen / Activity、React 19 新变化以及边界坑与最佳实践。

阅读前置:第 3 章(Fiber)、第 8 章(并发特性)。


系列导航:第 1 章=总览、第 2 章=元素/JSX、第 3 章=Fiber 架构与双缓冲、第 4 章=Reconciliation 协调算法、第 5 章=Scheduler、第 6 章=ReactDOM 渲染器与 Commit、第 7 章=Hooks、第 8 章=并发特性、第 9 章=本章、第 10 章=事件系统、第 11 章=编译器、第 13 章=React19 新特性。


目录

  • 9.0 本章导读
  • 9.1 动机:为什么需要 Suspense
  • 9.2 wakeable / thenable 抽象:React 为什么不直接用 Promise
  • 9.3 “抛出一个 Promise”的完整链路
    • 9.3.7 标志如何沿树上传:bubbleProperties 与 subtreeFlags
  • 9.4 SuspenseComponent Fiber 的结构
    • 9.4.5 completeWork 中的 SuspenseComponent:第二遍渲染与 Visibility 调度
  • 9.5 边界语义:fallback 何时出现、何时退场
  • 9.6 lazy() 的实现:惰性加载与 thenable 状态缓存
  • 9.7 retry 队列与 ping:数据就绪后如何“复活”边界
  • 9.8 Suspense 在 commit 阶段
  • 9.9 hydration 中的 Suspense:选择性水合与分块水合
    • 9.9.5 水合游标与优先级阶梯
    • 9.9.6 服务端一侧:Fizz 如何切块(简述)
  • 9.10 SuspenseList:批量协调多个边界
  • 9.11 Offscreen / Activity:后台渲染与树隐藏
  • 9.12 React 19 的变化:use()、suspensey commit、preinit/preload
  • 9.13 边界与坑:闪烁、竞态、错误边界分工
    • 9.13.8 key 与边界的交互
    • 9.13.9 fallback 与 View Transition 的叠加
    • 9.13.10 隐藏子树里的表单与受控输入
    • 9.13.11 fallback 里的组件也会跑 effect
  • 9.14 性能影响量化
  • 9.15 用法与最佳实践
  • 9.16 源码索引表
  • 9.17 自测 FAQ
  • 9.18 本章要点小结

9.0 本章导读

如果说第 8 章讲的并发特性(transition、lane、时间切片)是 React 的“发动机”,那么 Suspense 就是这台发动机上最精密的“变速箱”:它决定了当数据还没到位时,用户的屏幕上演哪一出——是显示旧的界面、显示一个骨架屏、还是整个挂起等待。

Suspense 在 React 源码中不是一个单独的模块,而是一张横跨多个文件的协作网络:

  • 定义“什么算可等待”:packages/react-reconciler/src/ReactFiberThenable.js 中的 thenable 状态机;
  • 定义“谁来接住这次挂起”:packages/react-reconciler/src/ReactFiberSuspenseContext.js 中的 suspenseHandlerStackCursor 与 shellBoundary;
  • 执行“接住”动作:packages/react-reconciler/src/ReactFiberThrow.js 中的 throwException;
  • 定义边界 Fiber 长什么样:packages/react-reconciler/src/ReactFiberBeginWork.js 中的 updateSuspenseComponent 与一堆 mountSuspense*/updateSuspense* 辅助函数;
  • 定义“数据好了之后怎么办”:packages/react-reconciler/src/ReactFiberWorkLoop.js 中的 attachPingListener、pingSuspendedRoot、retryTimedOutBoundary;
  • 定义“DOM 层怎么动”:packages/react-reconciler/src/ReactFiberCommitWork.js 中的 mutation/layout 遍历;
  • 定义“代码怎么懒加载”:packages/react/src/ReactLazy.js。

本章会把这七条线全部打通。读完你应该能回答这样几个问题:

  1. 组件里 throw promise(或 use(promise))之后,CPU 里究竟发生了什么,直到屏幕上出现骨架屏?
  2. Promise resolve 之后,React 怎么知道该重新渲染哪个边界?为什么叫“ping”而不是“回调”?
  3. 为什么 React 18 之后,transition 中切换到新页面不再闪一下骨架屏?源码里是哪几行实现的?
  4. <Suspense> 在 Fiber 树上到底是几个节点?fallback 和主内容是什么关系?
  5. SSR + hydration 场景下,一个边界可以“部分水合”是怎么回事?

所有结论都会落到 react-19.3.1 仓库的真实源码上。版本为 React 19.3.1。


9.1 动机:为什么需要 Suspense

9.1.1 命令式加载态的三个顽疾

在 Suspense 出现之前(以及今天很多没有使用 Suspense 的代码里),异步 UI 的标准写法是“命令式加载态”:

function ProfilePage({ id }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchProfile(id).then(
      d => {
        setData(d);
        setLoading(false);
      },
      e => {
        setError(e);
        setLoading(false);
      },
    );
  }, [id]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <Profile data={data} />;
}

这段代码有三个结构性问题,而且它们不是“写得不够优雅”的问题,是模式本身的问题:

顽疾一:加载态与数据耦合,导致“瀑布”与竞态。 每个组件各自管理 loading,意味着组件树的每一层都要等上一层把数据拿完、渲染完、子组件的 useEffect 触发,才能开始自己的请求。这就是经典的请求瀑布(request waterfall)。竞态则更隐蔽:快速切换 id 时,旧请求的 then 可能后于新请求到达,setData 把旧数据写进新状态——上面代码连 ignore 标志都没有,是一个真实的 bug。

顽疾二:“加载中”这个 UI 状态无法统一协调。 页面上有五个组件各自转圈,用户看到的是五个 spinner 乱闪;或者反过来,一个高层组件为了“统一加载态”,不得不把所有子组件的数据请求上移到自己身上,把 props 层层传下去——为了 UX 牺牲了组件的内聚性。

顽疾三:加载态会“闪”。 数据 50ms 就返回时,显示一个只出现 50ms 的 spinner,体验上比多等 200ms 显示完整内容更差(感知上闪烁比等待更难受)。但命令式代码里,组件作者要在每个组件里各自实现“延迟显示 loading”的防抖逻辑,几乎没人真的做。

9.1.2 Suspense 的核心思想:把“加载中”变成边界属性

Suspense 的思路是:加载态不该由数据消费方(子组件)拥有,而应该由边界(<Suspense>)拥有。

<Suspense fallback={<Spinner />}>
  <ProfilePage id={id} />
</Suspense>

ProfilePage 内部只关心“有数据时怎么渲染”,没数据时它要做的唯一一件事情是:抛出一个 Promise(或通过 use() 读一个未就绪的 thenable)。谁接住这个 Promise?最近的那个 <Suspense> 边界。接住之后发生什么?边界切换成“显示 fallback”模式;数据就绪之后再切回来。

这个设计带来三个直接好处:

  1. 一个边界统一一片子树的加载态:子树里十个组件挂起,只显示一个 fallback,不会十个小圈乱闪;
  2. 消除请求瀑布:数据请求的发起方不再受“父组件先渲染”约束(配合 React 19 的 RSC 或 Relay 这类 Suspense 兼容库,请求可以在渲染前并行发起);
  3. 加载态的显示/隐藏时机可以被 React 统一节流:FALLBACK_THROTTLE_MS(300ms,见 ReactFiberWorkLoop.js:528)就是干这个的——后面会逐行分析。

9.1.3 历史演进:从 lazy 到数据挂起

Suspense 在 React 中的演进大致是四个阶段,理解这条线对理解今天源码里的各种“历史包袱”至关重要(源码里大量 disableLegacyMode、SuspendedOnDeprecatedThrowPromise 之类的分支都是这条历史的沉积层):

阶段版本能力源码痕迹
代码分割React 16.6 (2018)React.lazy() + <Suspense>,只支持“抛 thenable 触发 fallback”ReactLazy.js、SuspendedOnDeprecatedThrowPromise
并发模式试验React 16.13 ~ 17experimental 的 useTransition、Suspense “保持旧界面”行为RootSuspendedWithDelay、renderDidSuspendDelayIfPossible
并发渲染正式可用React 18 (2022)transition 中挂起不显示 fallback 而保留旧内容;选择性水合shellBoundary、SuspendedOnHydration、SelectiveHydrationException
use() 与资源挂起React 19 (2024)use(promise) 正式 API;suspensey commit(图片/样式表挂起提交);<Activity>ReactFiberThenable.js 的 SuspenseException、MaySuspendCommit、ActivityComponent

注意一个关键事实:React 19.3.1 的源码里,Suspense 的“render 阶段挂起”和“commit 阶段挂起”是两套并行的机制。前者是“抛 thenable → 显示 fallback”,后者是“等图片/样式表加载完再 commit”(suspensey commit)。本章 9.12 节会详细拆解后者。

9.1.4 一个最小可复现的心智模型

先用一句话概括本章主角的行为:

<Suspense> 是一个 try/catch:children 渲染时如果抛出 thenable,就改用 fallback 渲染;thenable resolve 时,重新渲染 children。

再用一张图概括它在 Fiber 世界里的位置(详见 9.4 节):

flowchart TB
    A[&#34;&#34;] --> B[&#34;SuspenseComponent Fiber<br/>memoizedState = SUSPENDED_MARKER 或 null&#34;]
    B --> C[&#34;primaryChildFragment<br/>Offscreen Fiber, mode=hidden/visible<br/>children 真正渲染在这里&#34;]
    B --> D[&#34;fallbackChildFragment<br/>普通 Fiber, 渲染 fallback&#34;]
    C --> E[&#34;用户组件树&#34;]
    D --> F[&#34;fallback UI&#34;]

接下来的章节,就是把这句话和这张图逐行展开。


9.2 wakeable / thenable 抽象:React 为什么不直接用 Promise

9.2.1 “有 then 方法的对象”就是 wakeable

React 内部从来不要求你传入原生 Promise。它要求的只是一个** wakeable(可唤醒对象)**:任何拥有 then 方法的对象。这个判断散落在几处,措辞完全一致:

  • ReactFiberThrow.js:382:if (typeof value.then === 'function') —— “This is a wakeable. The component suspended.”
  • ReactFiberWorkLoop.js:2356-2359:const isWakeable = thrownValue !== null && typeof thrownValue === 'object' && typeof thrownValue.then === 'function';
  • ReactFiberHooks.js:1163:if (typeof usable.then === 'function') —— use() 的 thenable 分支。

为什么不用 Promise?三个原因:

第一,同步可检测性。 React 需要在渲染过程中同步地知道“这个 thenable 现在到底好了没有”,以便决定“立即重试”还是“先显示 fallback”。原生 Promise 不提供同步查询状态的 API——.then() 只能注册回调,无法问“你 resolved 了吗”。React 的解决方案是自己给 thenable 打补丁:在 thenable 上挂 status、value、reason 三个 expando 属性。这就是 ReactFiberThenable.js 里 trackUsedThenable 的核心工作。

第二,不依赖 thenable 的实现。 只要对象有 then(onFulfilled, onRejected),React 就能等它。这让 Relay 的自定义 thenable、React Server Components 的 Flight thenable、React.lazy 的 payload thenable 都能走同一套挂起机制。

第三,可控的缓存与去重。 React 需要知道“同一个组件这次渲染用的 thenable 和上次是不是同一个”,以此决定能否复用上次的结果。这同样需要自己维护状态,而不是依赖 Promise 的引用相等。

9.2.2 ThenableState:挂在组件 Fiber 上的 thenable 缓存

先看类型定义。ReactFiberThenable.js 顶部:

// packages/react-reconciler/src/ReactFiberThenable.js:36-53
opaque type ThenableStateDev = {
  didWarnAboutUncachedPromise: boolean,
  thenables: Array<Thenable<any>>,
};

opaque type ThenableStateProd = Array<Thenable<any>>;

export opaque type ThenableState = ThenableStateDev | ThenableStateProd;

function getThenablesFromState(state: ThenableState): Array<Thenable<any>> {
  if (__DEV__) {
    const devState: ThenableStateDev = state as any;
    return devState.thenables;
  } else {
    const prodState = state as any;
    return prodState;
  }
}

DEV 下 ThenableState 是一个对象,生产环境就是一个裸数组——这是 React 常用的“DEV 额外记账”手法(第 3 章讲过 Fiber 上大量类似设计)。数组按索引存 thenable:第 0 个 use() 调用读的 thenable 放在 thenables[0],第 1 个放在 `thenables[1]``,以此类推。

这个状态存在哪?存在函数组件的 updateQueue 上(准确说是 FunctionComponentUpdateQueue 之外的另一个位置——ReactFiberHooks.js 的模块级变量 thenableState,在 renderWithHooks 时初始化和清理)。首次挂起时创建:

// packages/react-reconciler/src/ReactFiberThenable.js:95-106
export function createThenableState(): ThenableState {
  // The ThenableState is created the first time a component suspends. If it
  // suspends again, we'll reuse the same state.
  if (__DEV__) {
    return {
      didWarnAboutUncachedPromise: false,
      thenables: [],
    };
  } else {
    return [];
  }
}

注意注释:一个组件只会创建一个 ThenableState,之后复用。这意味着重渲染时同一个位置如果拿到不同的 thenable,会走“保留旧的、丢弃新的”逻辑——这正是 trackUsedThenable 里最精妙的一段。

9.2.3 trackUsedThenable 逐行拆解

trackUsedThenable 是 thenable 机制的心脏,所有入口(use()、lazy 解包、reconcile 时遇到 thenable 类型的 children)最终都汇到它。完整源码:

// packages/react-reconciler/src/ReactFiberThenable.js:125-341
export function trackUsedThenable<T>(
  thenableState: ThenableState,
  thenable: Thenable<T>,
  index: number,
  fiber: null | Fiber, // DEV-only
): T {

它的职责可以概括成四件事:记账(按索引缓存)、同步探测状态、给 thenable 打 status 补丁、决定返回值或抛出。逐段看。

第一段:act 环境标记。

  if (__DEV__ && ReactSharedInternals.actQueue !== null) {
    ReactSharedInternals.didUsePromise = true;
  }

在 act() 测试环境里记录“本组件用过 Promise”,act 结束时会检查是否有未 await 的 Promise 并给出警告。这是纯测试设施。

第二段:按索引记账 + “组件是幂等的”假设。

  const trackedThenables = getThenablesFromState(thenableState);
  const previous = trackedThenables[index];
  if (previous === undefined) {
    trackedThenables.push(thenable);
  } else {
    if (previous !== thenable) {
      // Reuse the previous thenable, and drop the new one. We can assume
      // they represent the same value, because components are idempotent.
      ...
      thenable.then(noop, noop);
      thenable = previous;
    }
  }

这是理解 React “重渲染复用”语义的关键。假设组件第一次渲染时 use(fetchData()) 挂起了(fetchData() 在渲染中现场创建了一个新 Promise,这是反模式,但确实会发生)。Promise resolve 后 React 重新渲染组件,组件又执行了 fetchData()——这是一个全新的 Promise 对象,与上次挂起的那个不是同一个引用。如果 React 傻乎乎地等这个新 Promise,就会无限循环:每次重渲染都创建新 Promise、都挂起、都重渲染……

React 的破解方式:按位置(index)覆盖写入,且旧值优先。第一次见到的 thenable 占据 trackedThenables[index],之后同位置再来新 thenable,直接丢弃新的、沿用旧的。理由写在注释里:“components are idempotent(组件是幂等的)”——同一个组件在同一个位置读的数据,被认为是同一个数据。

这里有两个细节值得注意:

  1. thenable.then(noop, noop)——给被丢弃的 Promise 挂一个空回调,纯粹是为了避免 unhandled rejection 警告。如果不挂,一个 reject 的 Promise 被丢弃会在控制台报未处理拒绝。
  2. DEV 下会对“现场创建未缓存 Promise”给出一次警告(didWarnAboutUncachedPromise 保证每个组件只警告一次):
console.error(
  'A component was suspended by an uncached promise. Creating ' +
    'promises inside a Client Component or hook is not yet ' +
    'supported, except via a Suspense-compatible library or framework.',
);

这句话信息量很大:React 19 的立场是——客户端组件里现场创建 Promise 并 use() 它,不是受支持的用法。受支持的路径是 Suspense 兼容的库/框架(Relay、Next.js App Router 的 RSC、React.cache 等)在渲染前把 Promise 准备好并缓存。这也是“为什么我的 use(fetch(...)) 在客户端组件里行为很怪”的官方答案。

第三段:DEV 调试信息。(enableAsyncDebugInfo 分支,给 thenable 挂 _debugInfo,记录 I/O 起止时间,用于 DevTools 的 Suspense 调试视图。与主流程无关,略过。)

第四段:状态机。 这是核心。React 在 thenable 对象自身上维护一个三态状态机:

  // We use an expando to track the status and result of a thenable so that we
  // can synchronously unwrap the value. Think of this as an extension of the
  // Promise API, or a custom interface that is a superset of Thenable.
  //
  // If the thenable doesn't have a status, set it to "pending" and attach
  // a listener that will update its status and result when it resolves.
  switch (thenable.status) {
    case 'fulfilled': {
      // This could be a bad instrumentation that doesn't set .value.
      const fulfilledValue: T = thenable.value;
      return fulfilledValue;
    }
    case 'rejected': {
      const rejectedError = thenable.reason;
      checkIfUseWrappedInAsyncCatch(rejectedError);
      ...
      throw rejectedError;
    }
    default: {
      if (typeof thenable.status === 'string') {
        // Only instrument the thenable if the status if not defined. If
        // it's defined, but an unknown value, assume it's been instrumented by
        // a custom userspace implementation. We treat it as "pending".
        // Attach a dummy listener, to ensure that any lazy initialization can
        // happen. Flight lazily parses JSON when the value is actually awaited.
        thenable.then(noop, noop);
      } else {
        // This is an uncached thenable that we haven't seen before.
        // Detect infinite ping loops caused by uncached promises.
        const root = getWorkInProgressRoot();
        if (root !== null && root.shellSuspendCounter > 100) {
          throw new Error(
            'An unknown Component is an async Client Component. ' +
            ...
          );
        }

        const pendingThenable: PendingThenable<T> = thenable as any;
        pendingThenable.status = 'pending';
        pendingThenable.then(
          fulfilledValue => {
            if (thenable.status === 'pending') {
              const fulfilledThenable: FulfilledThenable<T> = thenable as any;
              fulfilledThenable.status = 'fulfilled';
              fulfilledThenable.value = fulfilledValue;
            }
          },
          (error: mixed) => {
            if (thenable.status === 'pending') {
              const rejectedThenable: RejectedThenable<T> = thenable as any;
              rejectedThenable.status = 'rejected';
              rejectedThenable.reason = error;
            }
          },
        );
      }
      ...

逐个分支解读:

  • 'fulfilled':直接返回 thenable.value。同步解包,零成本。注意注释里那句“This could be a bad instrumentation that doesn't set .value”——React 信任补丁的正确性,不防御性检查,因为这是热路径。
  • 'rejected':throw thenable.reason。rejected 的 thenable 会被转成真正的错误抛出,最终由错误边界(getDerivedStateFromError)处理——Suspense 不处理 rejection,这是 Suspense 与错误边界的分界线(9.13 节细说)。checkIfUseWrappedInAsyncCatch 检查 reject 的原因是不是 React 内部的 SuspenseException,防止内部异常泄漏到用户 Promise 链里造成更混乱的错误。
  • default(无 status):这是“第一次见到这个 thenable”的情况。做三件事:
    1. 无限 ping 循环检测:root.shellSuspendCounter > 100 时抛错。shellSuspendCounter 在 renderRootSync 末尾递增(ReactFiberWorkLoop.js:2739-2741),记录“同步渲染在 shell 里挂起且什么都没提交”的次数。超过 100 次说明组件每次重渲染都挂起在新 Promise 上——经典的 async Client Component 误用。这是一个熔断机制,不是常规逻辑。
    2. 打补丁:pendingThenable.status = 'pending',然后挂 then 监听。监听回调里的 if (thenable.status === 'pending') 是幂等守卫——防止 thenable 被多个来源重复 resolve 时状态机错乱。
    3. 打完补丁后,再检查一次 status:
      // Check one more time in case the thenable resolved synchronously.
      switch ((thenable as Thenable<T>).status) {
        case 'fulfilled': { ... return fulfilledThenable.value; }
        case 'rejected': { ... throw rejectedError; }
      }

      // Suspend.
      //
      // Throwing here is an implementation detail that allows us to unwind the
      // call stack. But we shouldn't allow it to leak into userspace. Throw an
      // opaque placeholder value instead of the actual thenable. ...
      suspendedThenable = thenable;
      ...
      throw SuspenseException;

这个“再检查一次”处理的是同步 resolve 的 thenable(比如 Promise.resolve(x) 在某些微任务时序下的边界情况,或自定义 thenable 在 then 注册时同步触发回调)。如果补丁期间 thenable 已经 fulfilled,直接返回值,根本不挂起。

最后,真正挂起时抛出的不是 thenable 本身,而是 SuspenseException:

// packages/react-reconciler/src/ReactFiberThenable.js:57-65
export const SuspenseException: mixed = new Error(
  "Suspense Exception: This is not a real error! It's an implementation " +
    'detail of `use` to interrupt the current render. You must either ' +
    'rethrow it immediately, or move the `use` call outside of the ' +
    '`try/catch` block. Capturing without rethrowing will lead to ' +
    'unexpected behavior.\n\n' +
    'To handle async errors, wrap your component in an error boundary, or ' +
    "call the promise's `.catch` method and pass the result to `use`.",
);

这是一个极具 React 风格的设计:抛出不透明占位符,真实 thenable 走模块级变量传递。

// packages/react-reconciler/src/ReactFiberThenable.js:376-395
let suspendedThenable: Thenable<any> | null = null;
let needsToResetSuspendedThenableDEV = false;
export function getSuspendedThenable(): Thenable<mixed> {
  // This is called right after `use` suspends by throwing an exception. `use`
  // throws an opaque value instead of the thenable itself so that it can't be
  // caught in userspace. Then the work loop accesses the actual thenable using
  // this function.
  ...
}

为什么不直接抛 thenable?因为用户代码可能写 try { use(p) } catch (e) { /* 吞掉 */ }——如果抛的是 thenable,用户的 catch 会吞掉挂起信号,React 的栈展开就断了。抛一个不透明的 SuspenseException,用户 catch 了也无法利用它做任何事,而且 checkIfUseWrappedInTryCatch 会在之后检测“SuspenseException 被用户代码捕获了”并打警告。工作循环则在 handleThrow 里通过 getSuspendedThenable() 取回真正的 thenable。真实 thenable 存在模块级变量里,说明同一时刻只有一个 thenable 处于“已抛出未取回”状态——这成立是因为 JS 单线程 + 同步栈展开。

9.2.4 三个特殊 thenable:noop、commit 挂起与 lazy

ReactFiberThenable.js 里还定义了三个特殊角色:

// packages/react-reconciler/src/ReactFiberThenable.js:67-93
export const SuspenseyCommitException: mixed = new Error(
  'Suspense Exception: This is not a real error, and should not leak into ' +
    "userspace. If you're seeing this, it's likely a bug in React.",
);

export const SuspenseActionException: mixed = new Error(...);

// This is a noop thenable that we use to trigger a fallback in throwException.
export const noopSuspenseyCommitThenable = {
  then() {
    if (__DEV__) {
      console.error(
        'Internal React error: A listener was unexpectedly attached to a ' +
          '"noop" thenable. This is a bug in React. Please file an issue.',
      );
    }
  },
};
  • SuspenseException:render 阶段挂起信号(use / lazy)。
  • SuspenseyCommitException:commit 阶段挂起信号。suspendCommit() 抛出它,表示“当前 commit 要暂停,等宿主资源就绪”(9.12 节的 suspensey commit)。
  • SuspenseActionException:useActionState 的挂起信号。
  • noopSuspenseyCommitThenable:一个冒牌 thenable,用于“我想让 throwException 走挂起分支,但并没有真的 thenable 要等”的场景(比如 CPU Suspense、Offscreen 的 ScheduleRetry)。任何人试图 then 它都会报内部错误——它本就不该被等待。

这四种异常/占位符在 handleThrow 里被分流,构成下一节的主线。

9.2.5 Wakeable 与 Thenable 的类型契约

把类型定义也钉住,后面读源码时不会再对 status?: void 之类的写法感到困惑。shared/ReactTypes.js:

// packages/shared/ReactTypes.js:103-151(节选)
// The subset of a Thenable required by things thrown by Suspense.
// This doesn't require a value to be passed to either handler.
export interface Wakeable {
  then(onFulfill: () => mixed, onReject: () => mixed): void | Wakeable;
}

// The subset of a Promise that React APIs rely on. This resolves a value.
interface ThenableImpl<T> {
  then(
    onFulfill: (value: T) => mixed,
    onReject: (error: mixed) => mixed,
  ): void | Wakeable;
  displayName?: string;
}
interface UntrackedThenable<T> extends ThenableImpl<T> {
  status?: void;
  _debugInfo?: null | ReactDebugInfo;
}

export interface PendingThenable<T> extends ThenableImpl<T> {
  status: 'pending';
  ...
}
export interface FulfilledThenable<T> extends ThenableImpl<T> {
  status: 'fulfilled';
  value: T;
  ...
}
export interface RejectedThenable<T> extends ThenableImpl<T> {
  status: 'rejected';
  reason: mixed;
  ...
}
export type Thenable<T> =
  | UntrackedThenable<T>
  | PendingThenable<T>
  | WeakPendingThenable<T>
  | FulfilledThenable<T>
  | RejectedThenable<T>;

三个知识点:

  1. Wakeable 与 Thenable 是两个类型。Wakeable 只要求 then 存在且回调不传值——throwException/attachPingListener 面向 Wakeable(它们不关心值,只关心“好了没”);Thenable 要求值语义——trackUsedThenable/use() 面向 Thenable(要解包出 value)。挂起链路上 Wakeable 是“门禁”,Thenable 是“载荷”。
  2. UntrackedThenable.status?: void 就是“尚未被 React 打补丁”的状态类型——对应 trackUsedThenable 里 typeof thenable.status === 'string' 为假的分支(9.2.3 节)。被打补丁后类型“升级”为 PendingThenable,运行时靠 Flow 的 as any 断言完成,JS 侧无任何开销。
  3. WeakPendingThenable(status: 'pending_weak') 是 Flight(RSC)专用的“弱挂起”态:它表示 thenable 对应的是“可以延迟解析的大对象”,trackUsedThenable 对未知 status 字符串的处理(挂 noop 监听当作 pending)正是为它留的门。displayName 则纯粹服务于 DevTools 的 Suspense 可视化。

9.2.6 Context 与挂起的交互:预播机制

挂起组件的子树从未被渲染,那么子树里的 context consumer 怎么办?resetSuspendedComponent 调用的 propagateParentContextChangesToDeferredTree(ReactFiberNewContext.js:397-409)是答案:

export function propagateParentContextChangesToDeferredTree(
  current: Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  const forcePropagateEntireTree = true;
  propagateParentContextChanges(
    current,
    workInProgress,
    renderLanes,
    forcePropagateEntireTree,
  );
}

它复用常规的 context 传播(第 4 章),但强制遍历整棵被丢弃的子树,把“父级 provider 变了”的消息写进子树 fiber 的 lanes。效果:retry 重建这棵子树时,即使 consumer fiber 是全新的,也能因为 lanes 里记着“依赖的 context 变了”而读到新值。没有这一步,<Suspense> 内消费 context 的组件会在 retry 后读到过期 context——一个极难复现的 bug,源码用一次强制遍历消掉了它。这也是为什么 updateSuspenseComponent 的更新路径里随处可见 lazilyPropagateParentContextChanges 与 hasContextChanged 的检查(ReactFiberBeginWork.js:3042-3053、4065-4074):边界的每次重试都是一次 context 传播的补课机会。


9.3 “抛出一个 Promise”的完整链路

这是本章的核心。我们从一次 use(promise) 出发,跟踪它从抛出到屏幕上出现 fallback、再到数据就绪后恢复的完整生命周期。全程对照源码。

9.3.1 全景调用链

先给全链路的 mermaid 时序图,后面每一段都会展开:

sequenceDiagram
    participant U as 用户组件
    participant H as ReactFiberHooks.use
    participant T as trackUsedThenable
    participant WL as WorkLoop.handleThrow
    participant TU as throwAndUnwindWorkLoop
    participant TE as throwException
    participant SH as SuspenseHandlerStack
    participant B as SuspenseComponent Fiber
    participant P as Promise(thenable)
    participant R as retryTimedOutBoundary

    U->>H: use(promise)
    H->>T: useThenable(thenable)
    T->>T: 按 index 记账 / 打 status 补丁
    alt thenable 已 fulfilled
        T-->>U: 直接返回值,不挂起
    else 未就绪
        T-->>U: throw SuspenseException(真实 thenable 存入模块变量)
        U->>WL: 异常沿 JS 调用栈抛出
        WL->>WL: getSuspendedThenable() 取回 thenable<br/>SuspendedReason = SuspendedOnImmediate
        WL->>TU: throwAndUnwindWorkLoop(root, fiber, thenable, reason)
        TU->>TE: throwException(root, returnFiber, sourceFiber, thenable, lanes)
        TE->>TE: value.then 是函数 → wakeable 分支
        TE->>SH: getSuspenseHandler() 找最近边界
        SH-->>TE: suspenseBoundary Fiber
        TE->>B: markSuspenseBoundaryShouldCapture()<br/>flags |= ShouldCapture
        TE->>B: updateQueue = new Set([wakeable])
        TE->>P: attachPingListener(root, wakeable, lanes)
        Note over WL: unwindUnitOfWork 向上抛<br/>边界 beginWork 看到 ShouldCapture
        Note over B: updateSuspenseComponent: showFallback=true<br/>mount/updateSuspenseFallbackChildren
        Note over WL: commit fallback
        P-->>P: resolve
        P->>WL: ping → pingSuspendedRoot
        WL->>R: ensureRootIsScheduled → 新渲染
        R->>B: retryTimedOutBoundary(boundary, retryLane)
        Note over B: 重渲染 primary children<br/>数据已就绪 → 提交真实内容
    end

注意图里两个容易忽略的细节,后文会验证:

  1. 挂起时 use() 抛出的是 SuspenseException,不是 thenable;thenable 通过模块级变量“走私”给工作循环。
  2. “找边界”不是沿着 return 指针往上爬,而是查一个栈 suspenseHandlerStackCursor——这是渲染阶段同步维护的“最近的 catch 块”。

9.3.2 第一站:use() 与 thenable 入口

use() 的公共 API 极简:

// packages/react/src/ReactHooks.js:207-210
export function use<T>(usable: Usable<T>): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.use(usable);
}

reconciler 侧的分派实现按类型三分——thenable / context / 其它:

// packages/react-reconciler/src/ReactFiberHooks.js:1159-1179
function use<T>(usable: Usable<T>): T {
  if (usable !== null && typeof usable === 'object') {
    if (typeof usable.then === 'function') {
      // This is a thenable.
      const thenable: Thenable<T> = usable as any;
      return useThenable(thenable);
    } else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
      // Fiber is the final renderer, so there is no downstream host that
      // needs to recover this subtree. Continue rendering through it.
      return undefined as any;
    } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
      const context: ReactContext<T> = usable as any;
      return readContext(context);
    }
  }
  throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

useThenable 在调 trackUsedThenable 之前先做了一件 Hook 特有的事——维护 thenable 的位置计数器:

// packages/react-reconciler/src/ReactFiberHooks.js:1098-1110
function useThenable<T>(thenable: Thenable<T>): T {
  // Track the position of the thenable within this fiber.
  const index = thenableIndexCounter;
  thenableIndexCounter += 1;
  if (thenableState === null) {
    thenableState = createThenableState();
  }
  const result = trackUsedThenable(
    thenableState,
    thenable,
    index,
    __DEV__ ? currentlyRenderingFiber : null,
  );

thenableIndexCounter 是模块级计数器,每次组件渲染开始时归零(在 renderWithHooks 里)。所以“第几个 use()” = “数组第几个槽位”,与 Hook 的顺序依赖是同一套思想(第 7 章讲过 Hook 的位置依赖,这里 thenable 缓存复用了该约束)。

useThenable 剩下的一大段(1112-1155 行)是在处理一个微妙问题:use() 挂起后重渲染时,dispatcher 要从 “re-render 专用 dispatcher” 切回正常的 mount/update dispatcher。原因是挂起时组件只渲染到 use() 那一行就中断了,后面的 Hook 一个都没执行;数据就绪后重渲染,要能把中断处之后的 Hook 补齐,就必须把 dispatcher 换回来。这段注释写得很清楚,不再赘述。

另一个入口是 reconcile 阶段的 thenable children——如果组件直接 return promise(把 Promise 当 React 节点渲染,曾经的实验性用法):

// packages/react-reconciler/src/ReactChildFiber.js:282-289
function unwrapThenable<T>(thenable: Thenable<T>): T {
  const index = thenableIndexCounter;
  thenableIndexCounter += 1;
  if (thenableState === null) {
    thenableState = createThenableState();
  }
  return trackUsedThenable(thenableState, thenable, index, null);
}

与 useThenable 的区别仅在于不传 fiber(DEV 警告信息少一点)。这印证了 9.2 节的论断:所有入口最终汇到 trackUsedThenable。

9.3.3 第二站:handleThrow —— 异常的分拣中心

trackUsedThenable 抛出 SuspenseException 后,异常沿着 performUnitOfWork → beginWork → renderWithHooks → 用户组件的 JS 调用栈一路向上,最终被 renderRootSync / renderRootConcurrent 的 try/catch 接住:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:2728-2730(renderRootSync 内)
    } catch (thrownValue) {
      handleThrow(root, thrownValue);
    }

handleThrow 是“异常分拣中心”,它做的第一件事是保持工作循环现场(注释原话:“Until we decide whether we're going to unwind or replay, we should preserve the current state of the work loop without resetting anything.”),然后把异常归类为 SuspendedReason:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:2302-2370(节选)
function handleThrow(root: FiberRoot, thrownValue: any): void {
  resetHooksAfterThrow();
  if (__DEV__) {
    resetCurrentFiber();
  }

  if (
    thrownValue === SuspenseException ||
    thrownValue === SuspenseActionException
  ) {
    // This is a special type of exception used for Suspense. For historical
    // reasons, the rest of the Suspense implementation expects the thrown value
    // to be a thenable, because before `use` existed that was the (unstable)
    // API for suspending. This implementation detail can change later, once we
    // deprecate the old API in favor of `use`.
    thrownValue = getSuspendedThenable();
    // Don't suspend work loop, except to check if the data has
    // immediately resolved (i.e. in a microtask). Otherwise, trigger the
    // nearest Suspense fallback.
    workInProgressSuspendedReason = SuspendedOnImmediate;
  } else if (thrownValue === SuspenseyCommitException) {
    thrownValue = getSuspendedThenable();
    workInProgressSuspendedReason = SuspendedOnInstance;
  } else if (thrownValue === SelectiveHydrationException) {
    workInProgressSuspendedReason = SuspendedOnHydration;
  } else {
    // This is a regular error.
    const isWakeable =
      thrownValue !== null &&
      typeof thrownValue === 'object' &&
      typeof thrownValue.then === 'function';

    workInProgressSuspendedReason = isWakeable
      ? // A wakeable object was thrown by a legacy Suspense implementation.
        // This has slightly different behavior than suspending with `use`.
        SuspendedOnDeprecatedThrowPromise
      : // This is a regular error.
        SuspendedOnError;
  }

  workInProgressThrownValue = thrownValue;
  ...

SuspendedReason 全集(ReactFiberWorkLoop.js:445-455):

枚举值含义触发场景
NotSuspended没挂起初始值
SuspendedOnError真错误组件抛 Error
SuspendedOnDatause() 挂起且工作循环决定“等数据”并发渲染中 Suspense 边界外挂起
SuspendedOnImmediateuse()/lazy 挂起,先检查是否微任务已 resolveuse()、React.lazy
SuspendedOnInstance宿主实例(图片等)未就绪suspensey commit
SuspendedOnInstanceAndReadyToContinue同上,已让出主线程后再查一次suspensey commit 恢复
SuspendedOnDeprecatedThrowPromise老的 throw promise 用法旧代码/库直接抛 thenable
SuspendedAndReadyToContinue已让出过主线程,准备继续SuspendedOnImmediate/Data 之后
SuspendedOnHydration选择性水合要打断当前渲染SelectiveHydrationException
SuspendedOnActionuseActionState 挂起Form Actions

注意 SuspendedOnDeprecatedThrowPromise 的注释:“This has slightly different behavior than suspending with use.”——旧式 throw promise 会被强制 unwind(不能 replay),因为 replay 语义对旧用法可能造成无限 ping 循环(renderRootConcurrent 里 2976-2989 行专门处理它)。

9.3.4 第三站:throwAndUnwindWorkLoop —— 从“记录”到“展开”

handleThrow 只负责记录状态。真正决定“展开还是原地重试”的是 renderRootSync / renderRootConcurrent 顶部的 resumeOrUnwind 逻辑。两条路径策略不同:

同步渲染路径(renderRootSync,ReactFiberWorkLoop.js:2662-2724):不让出主线程,除了 SuspendedOnHydration(重置栈)外,其余一律立即 throwAndUnwindWorkLoop——同步渲染里没有“等一等”的选项。

并发渲染路径(renderRootConcurrent,ReactFiberWorkLoop.js:2820-3023):这是最精细的一段,逐个 reason 决策:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:2877-2888
          case SuspendedOnImmediate: {
            // If this fiber just suspended, it's possible the data is already
            // cached. Yield to the main thread to give it a chance to ping. If
            // it does, we can retry immediately without unwinding the stack.
            workInProgressSuspendedReason = SuspendedAndReadyToContinue;
            break outer;
          }

use() 挂起时走的就是这里:先把状态改成 SuspendedAndReadyToContinue,然后 break outer 退出工作循环,把主线程让出去。为什么要让出去?因为 thenable 可能在微任务里已经 resolve 了(比如缓存命中、或 Promise.resolve() 链),或者用户代码紧接着就会 ping 它。让出一帧,ensureRootIsScheduled 会安排一个回调重新进入工作循环;再次进入时 SuspendedAndReadyToContinue 分支会重新检查:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:2889-2908
          case SuspendedAndReadyToContinue: {
            const thenable: Thenable<mixed> = thrownValue as any;
            if (isThenableResolved(thenable)) {
              // The data resolved. Try rendering the component again.
              workInProgressSuspendedReason = NotSuspended;
              workInProgressThrownValue = null;
              replaySuspendedUnitOfWork(unitOfWork);
            } else {
              // Otherwise, unwind then continue with the normal work loop.
              workInProgressSuspendedReason = NotSuspended;
              workInProgressThrownValue = null;
              throwAndUnwindWorkLoop(
                root,
                unitOfWork,
                thrownValue,
                SuspendedAndReadyToContinue,
              );
            }
            break;
          }

resolved → replaySuspendedUnitOfWork(原地重放组件,不展开栈、不显示 fallback);未 resolved → throwAndUnwindWorkLoop(展开栈、走 throwException、显示 fallback)。isThenableResolved 就是读补丁状态:

// packages/react-reconciler/src/ReactFiberThenable.js:108-111
export function isThenableResolved(thenable: Thenable<mixed>): boolean {
  const status = thenable.status;
  return status === 'fulfilled' || status === 'rejected';
}

一次属性读取,同步完成。这就是 9.2 节“同步可检测性”的用武之地——React 能在不 await 的情况下知道数据好了没有。

还有一类更“激进”的挂起:SuspendedOnData。当渲染在边界外挂起(没有 Suspense 边界能接,或边界在 shell 里)时,并发渲染会把整个工作循环挂起,直到数据 resolve:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:2843-2876
          case SuspendedOnData:
          case SuspendedOnAction: {
            const thenable: Thenable<mixed> = thrownValue as any;
            if (isThenableResolved(thenable)) {
              // The data resolved. Try rendering the component again.
              ...
              replaySuspendedUnitOfWork(unitOfWork);
              break;
            }
            // The work loop is suspended on data. We should wait for it to
            // resolve before continuing to render.
            const onResolution = () => {
              // Check if the root is still suspended on this promise.
              if (
                (workInProgressSuspendedReason === SuspendedOnData ||
                  workInProgressSuspendedReason === SuspendedOnAction) &&
                workInProgressRoot === root
              ) {
                // Mark the root as ready to continue rendering.
                workInProgressSuspendedReason = SuspendedAndReadyToContinue;
              }
              // Ensure the root is scheduled. We should do this even if we're
              // currently working on a different root, so that we resume
              // rendering later.
              ensureRootIsScheduled(root);
            };
            thenable.then(onResolution, onResolution);
            break outer;
          }

即“整个渲染暂停,等数据”,这是 React 18+ 无边界挂起的标准行为(配合 renderDidSuspendDelayIfPossible,见 9.5 节)。

throwAndUnwindWorkLoop 本体(ReactFiberWorkLoop.js:3234-3340)逻辑分两段:先同步调用 throwException 找边界、打标记;再根据 unitOfWork.flags & Incomplete 决定是否 unwindUnitOfWork:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3234-3276(节选)
function throwAndUnwindWorkLoop(
  root: FiberRoot,
  unitOfWork: Fiber,
  thrownValue: mixed,
  suspendedReason: SuspendedReason,
) {
  // This is a fork of performUnitOfWork specifcally for unwinding a fiber
  // that threw an exception.
  //
  // Return to the normal work loop. This will unwind the stack, and potentially
  // result in showing a fallback.
  resetSuspendedWorkLoopOnUnwind(unitOfWork);

  const returnFiber = unitOfWork.return;
  try {
    // Find and mark the nearest Suspense or error boundary that can handle
    // this "exception".
    const didFatal = throwException(
      root,
      returnFiber,
      unitOfWork,
      thrownValue,
      workInProgressRootRenderLanes,
    );
    if (didFatal) {
      panicOnRootError(root, thrownValue);
      return;
    }
  } catch (error) {
    ...
  }

注意 resetSuspendedWorkLoopOnUnwind 先重置了 Hook 链表和 child reconciler 的模块级状态——挂起组件的 Hook 链表处于“半成品”状态,必须先拆掉防止污染下一次渲染。

9.3.5 第四站:throwException 的 wakeable 分支(逐行)

throwException(ReactFiberThrow.js:364-705)是整条链路的枢纽。它面对一个 value,要回答三个问题:这是挂起还是错误?谁能处理?处理完之后工作循环该干什么?

// packages/react-reconciler/src/ReactFiberThrow.js:364-380
function throwException(
  root: FiberRoot,
  returnFiber: Fiber | null,
  sourceFiber: Fiber,
  value: mixed,
  rootRenderLanes: Lanes,
): boolean {
  // The source fiber did not complete.
  sourceFiber.flags |= Incomplete;

首先给抛出异常的 Fiber 打上 Incomplete——它没渲染完。这个标志会让 completeUnitOfWork 见到它时转向 unwind 而不是 complete(ReactFiberWorkLoop.js:3373-3383):

    if ((completedWork.flags & Incomplete) !== NoFlags) {
      // This fiber did not complete, because one of its children did not
      // complete. Switch to unwinding the stack instead of completing it.
      const skipSiblings = workInProgressRootDidSkipSuspendedSiblings;
      unwindUnitOfWork(completedWork, skipSiblings);
      return;
    }

然后判断 wakeable:

// packages/react-reconciler/src/ReactFiberThrow.js:381-397
  if (value !== null && typeof value === 'object') {
    if (typeof value.then === 'function') {
      // This is a wakeable. The component suspended.
      const wakeable: Wakeable = value as any;
      resetSuspendedComponent(sourceFiber, rootRenderLanes);

resetSuspendedComponent(ReactFiberThrow.js:203-239)做两件重要的事:

  1. 把上下文变更传播进被丢弃的子树。挂起组件的 children 从未被访问,但父级的 context 可能变了——正常渲染时 context 传播靠“遍历到 consumer fiber”,而现在 consumer fiber 还没建出来,所以必须手动把变更“预播”到 current 对应的子树上,否则 retry 时旧子树读不到新 context:
// packages/react-reconciler/src/ReactFiberThrow.js:203-217
function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
  const currentSourceFiber = sourceFiber.alternate;
  if (currentSourceFiber !== null) {
    // Since we never visited the children of the suspended component, we
    // need to propagate the context change now, to ensure that we visit
    // them during the retry.
    //
    // We don't have to do this for errors because we retry errors without
    // committing in between. So this is specific to Suspense.
    propagateParentContextChangesToDeferredTree(
      currentSourceFiber,
      sourceFiber,
      rootRenderLanes,
    );
  }

注释里那句对比很精准:错误重试不 commit,所以不需要预播;Suspense 的 retry 之间隔着一次 commit,所以必须预播。

  1. legacy 模式下把挂起组件的 memoizedState/updateQueue 回滚(历史包袱,略过)。

接下来就是全章最关键的“找边界”:

// packages/react-reconciler/src/ReactFiberThrow.js:396-446
      // Mark the nearest Suspense boundary to switch to rendering a fallback.
      const suspenseBoundary = getSuspenseHandler();
      if (suspenseBoundary !== null) {
        switch (suspenseBoundary.tag) {
          case ActivityComponent:
          case SuspenseComponent:
          case SuspenseListComponent: {

getSuspenseHandler() 查的是一张栈,不是 Fiber 树的 return 链:

// packages/react-reconciler/src/ReactFiberSuspenseContext.js:22-23, 150-152
const suspenseHandlerStackCursor: StackCursor<Fiber | null> =
  createCursor(null);
...
export function getSuspenseHandler(): Fiber | null {
  return suspenseHandlerStackCursor.current;
}

这张栈在 beginWork 进入 Suspense/Offscreen/Activity/SuspenseList 时 push,离开时 pop:

// packages/react-reconciler/src/ReactFiberSuspenseContext.js:43-102(节选)
export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void {
  ...
  push(
    suspenseStackCursor,
    setDefaultShallowSuspenseListContext(suspenseStackCursor.current),
    handler,
  );
  ...
  push(suspenseHandlerStackCursor, handler, handler);
  if (shellBoundary === null) {
    if (current === null || isCurrentTreeHidden()) {
      // This boundary is not visible in the current UI.
      shellBoundary = handler;
    } else {
      const prevState: SuspenseState = current.memoizedState;
      if (prevState !== null) {
        // This boundary is showing a fallback in the current UI.
        shellBoundary = handler;
      }
    }
  }
}

// packages/react-reconciler/src/ReactFiberSuspenseContext.js:104-109
export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
  // We're about to render the fallback. If something in the fallback suspends,
  // it's akin to throwing inside of a `catch` block. This boundary should not
  // capture. Reuse the existing handler on the stack.
  reuseSuspenseHandlerOnStack(fiber);
}

两个设计要点:

  • “渲染 fallback 时复用外层 handler”:fallback 内部再挂起,不能自己接自己(否则无限递归),要抛给更外层——就像 try/catch 的 catch 块里再抛异常不会被同一个 catch 接住。pushFallbackTreeSuspenseHandler 的注释把这个类比写得明明白白。
  • 两张栈并行:suspenseHandlerStackCursor(谁能接)和 suspenseStackCursor(SuspenseList 的 ForceSuspenseFallback 位掩码),push/pop 必须成对——这就是为什么 Offscreen/Activity 那些分支里即使“不需要”也要 push 一个占位(源码注释多次出现 “A SuspenseList context is only pushed here to avoid a push/pop mismatch”)。

pushPrimaryTreeSuspenseHandler 里还埋了一个重要变量:shellBoundary。它是“当前树中不可见的最外层边界”——注释解释:

// packages/react-reconciler/src/ReactFiberSuspenseContext.js:25-37
// Represents the outermost boundary that is not visible in the current tree.
// Everything above this is the "shell". When this is null, it means we're
// rendering in the shell of the app. If it's non-null, it means we're rendering
// deeper than the shell, inside a new tree that wasn't already visible.
//
// The main way we use this concept is to determine whether showing a fallback
// would result in a desirable or undesirable loading state. Activing a fallback
// in the shell is considered an undersirable loading state, because it would
// mean hiding visible (albeit stale) content in the current tree — we prefer to
// show the stale content, rather than switch to a fallback. But showing a
// fallback in a new tree is fine, because there's no stale content to
// prefer instead.

翻译成 UX 语言:shell 里显示 fallback = 用加载态替换掉用户正在看的(哪怕是旧的)内容 = 不好的加载态。这正是 React 18 之后“transition 中挂起保留旧内容”这一行为的判定依据,9.5 节会看到它如何被消费。

回到 throwException。找到边界后,先做节流相关的记录:

// packages/react-reconciler/src/ReactFiberThrow.js:413-436
            if (disableLegacyMode || sourceFiber.mode & ConcurrentMode) {
              if (getShellBoundary() === null) {
                // Suspended in the "shell" of the app. This is an undesirable
                // loading state. We should avoid committing this tree.
                renderDidSuspendDelayIfPossible();
              } else {
                // If we suspended deeper than the shell, we don't need to delay
                // the commmit. However, we still call renderDidSuspend if this is
                // a new boundary, to tell the work loop that a new fallback has
                // appeared during this render.
                const current = suspenseBoundary.alternate;
                if (current === null) {
                  renderDidSuspend();
                }
              }
            }
  • getShellBoundary() === null:挂起发生在 shell → renderDidSuspendDelayIfPossible() → 把退出状态设为 RootSuspendedWithDelay。这是“不要提交这棵树”的信号:既然要显示的是“替换可见内容”的坏 fallback,那不如什么都不提交,等数据好了直接出新内容(如果是 transition),或者等一个节流超时。
  • 挂起在 shell 深处(新出现的边界):只 renderDidSuspend()(RootSuspended)——正常提交 fallback 没问题,但要告诉工作循环“这次渲染出现了新 fallback”,供节流和 SuspenseList 使用。

然后是两个不可省略的动作:

// packages/react-reconciler/src/ReactFiberThrow.js:438-445
            suspenseBoundary.flags &= ~ForceClientRender;
            markSuspenseBoundaryShouldCapture(
              suspenseBoundary,
              returnFiber,
              sourceFiber,
              root,
              rootRenderLanes,
            );

markSuspenseBoundaryShouldCapture(ReactFiberThrow.js:241-362)是“给边界盖章”的函数。它先处理 legacy 模式(直接 DidCapture + 假装渲染了 null 的一堆 hack,modern 构建里 disableLegacyMode 为 false 时这段仍然保留,但我们以并发路径为主),并发路径极其简洁:

// packages/react-reconciler/src/ReactFiberThrow.js:357-361
  suspenseBoundary.flags |= ShouldCapture;
  // TODO: I think we can remove this, since we now use `DidCapture` in
  // the begin phase to prevent an early bailout.
  suspenseBoundary.lanes = rootRenderLanes;
  return suspenseBoundary;

注意函数上方 319-356 行那段长篇注释,是整个 Suspense 启发式策略的“设计文档”,值得完整引用(这是理解 React “什么时候重来一次、什么时候干等”的总纲):

// packages/react-reconciler/src/ReactFiberThrow.js:316-356
  // Confirmed that the boundary is in a concurrent mode tree. Continue
  // with the normal suspend path.
  //
  // After this we'll use a set of heuristics to determine whether this
  // render pass will run to completion or restart or "suspend" the commit.
  // The actual logic for this is spread out in different places.
  //
  // This first principle is that if we're going to suspend when we complete
  // a root, then we should also restart if we get an update or ping that
  // might unsuspend it, and vice versa. The only reason to suspend is
  // because you think you might want to restart before committing. However,
  // it doesn't make sense to restart only while in the period we're suspended.
  //
  // Restarting too aggressively is also not good because it starves out any
  // intermediate loading state. So we use heuristics to determine when.

  // Suspense Heuristics
  //
  // If nothing threw a Promise or all the same fallbacks are already showing,
  // then don't suspend/restart.
  //
  // If this is an initial render of a new tree of Suspense boundaries and
  // those trigger a fallback, then don't suspend/restart. We want to ensure
  // that we can show the initial loading state as quickly as possible.
  //
  // If we hit a "Delayed" case, such as when we'd switch from content back into
  // a fallback, then we should always suspend/restart. Transitions apply
  // to this case. If none is defined, JND is used instead.
  //
  // If we're already showing a fallback and it gets "retried", allowing us to show
  // another level, but there's still an inner boundary that would show a fallback,
  // then we suspend/restart for 500ms since the last time we showed a fallback
  // anywhere in the tree. This effectively throttles progressive loading into
  // a consistent train of commits. ...

“JND”是 Just Noticeable Difference(最小可觉差)——人眼对 ~100ms 以下的变化不敏感,React 用节流窗口把连续的加载态“攒成一批”提交。注意这段注释说 500ms,而实际常量是 300ms(ReactFiberWorkLoop.js:528)——以源码为准,注释是过时的(规范允许我们指出这一点)。

ShouldCapture 与 DidCapture 的区别是理解 Suspense 状态机的钥匙:

  • ShouldCapture:throwException 阶段打上,“这次渲染这个边界要捕获”,此时还没决定渲染哪棵树;
  • DidCapture:unwind 到边界、updateSuspenseComponent 真正读取并消费它,切换为 fallback 渲染后立即清除(ReactFiberBeginWork.js:2406 workInProgress.flags &= ~DidCapture;)。

为什么需要两个标志?因为从“盖章”到“边界被渲染”之间,unwind 要经过若干层父 Fiber;ShouldCapture 会沿着 return 链被 bubbleProperties 收拢,而边界真正 beginWork 时用 DidCapture 做“早退防止”(beginWork 的 SuspenseComponent 分支在 attemptEarlyBailoutIfNoScheduledUpdate 之前检查 DidCapture,防止带伤跳过)。throwException 的注释也承认了这一点:“I think we can remove this, since we now use DidCapture in the begin phase to prevent an early bailout.”

盖章之后是挂 wakeable 到边界 + 挂 ping 监听:

// packages/react-reconciler/src/ReactFiberThrow.js:446-484
            // Retry listener
            //
            // If the fallback does commit, we need to attach a different type of
            // listener. This one schedules an update on the Suspense boundary to
            // turn the fallback state off.
            //
            // Stash the wakeable on the boundary fiber so we can access it in the
            // commit phase.
            //
            // When the wakeable resolves, we'll attempt to render the boundary
            // again ("retry").

            // Check if this is a Suspensey resource. We do not attach retry
            // listeners to these, because we don't actually need them for
            // rendering. Only for committing. Instead, if a fallback commits
            // and the only thing that suspended was a Suspensey resource, we
            // retry immediately.
            const isSuspenseyResource =
              wakeable === noopSuspenseyCommitThenable;
            if (isSuspenseyResource) {
              suspenseBoundary.flags |= ScheduleRetry;
            } else {
              const retryQueue: RetryQueue | null =
                suspenseBoundary.updateQueue as any;
              if (retryQueue === null) {
                suspenseBoundary.updateQueue = new Set([wakeable]);
              } else {
                retryQueue.add(wakeable);
              }

              // We only attach ping listeners in concurrent mode. Legacy
              // Suspense always commits fallbacks synchronously, so there are
              // no pings.
              if (disableLegacyMode || suspenseBoundary.mode & ConcurrentMode) {
                attachPingListener(root, wakeable, rootRenderLanes);
              }
            }
            return false;

这段是“双 listener”架构的第一半:

  1. suspenseBoundary.updateQueue = new Set([wakeable])——把 wakeable 存到边界 Fiber 的 updateQueue 上(类型是 RetryQueue = Set<Wakeable>,ReactFiberSuspenseComponent.js:61)。注意这是 SuspenseComponent 特有的复用:类组件的 updateQueue 存 state 更新,Suspense 边界的 updateQueue 存“我在等哪些 thenable”。多个兄弟组件挂起时,retryQueue.add(wakeable) 累积成一个集合——一次 retry 全部检查。
  2. attachPingListener(root, wakeable, rootRenderLanes)——立刻给 thenable 挂监听。为什么要“立刻”而不是等 fallback commit?注释在 attachPingListener 里回答了:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4970-4986(节选)
export function attachPingListener(
  root: FiberRoot,
  wakeable: Wakeable,
  lanes: Lanes,
) {
  // Attach a ping listener
  //
  // The data might resolve before we have a chance to commit the fallback. Or,
  // in the case of a refresh, we'll never commit a fallback. So we need to
  // attach a listener now. When it resolves ("pings"), we can decide whether to
  // try rendering the tree again.
  //
  // Only attach a listener if one does not already exist for the lanes
  // we're currently rendering (which acts like a "thread ID" here).

数据可能在 fallback 提交前就 resolve;refresh 场景(startTransition 刷新)根本不显示 fallback。所以 ping 监听必须在渲染阶段就挂上,不能拖到 commit。而且用 root.pingCache(Map<Wakeable, Set<lanes>>)去重——同一个 thenable + 同一组 lanes 只挂一个监听,lanes 在这里充当“线程 ID”。多个组件等同一个 Promise 时,这个去重避免 N 个监听炸开。

  1. isSuspenseyResource 分支:如果 wakeable 是 noopSuspenseyCommitThenable(没有任何真实数据要等,只是想触发 fallback/重试,比如 CPU Suspense),就不挂监听,而是给边界打 ScheduleRetry 标志——commit 后立即安排一次 retry。

Offscreen 作为 handler 时逻辑类似但队列结构不同(OffscreenQueue.retryQueue,ReactFiberThrow.js:487-517),因为它还要共存 transitions 和 markerInstances。

最后,没有边界的情况:

// packages/react-reconciler/src/ReactFiberThrow.js:523-547
      } else {
        // No boundary was found. Unless this is a sync update, this is OK.
        // We can suspend and wait for more data to arrive.

        if (disableLegacyMode || root.tag === ConcurrentRoot) {
          // In a concurrent root, suspending without a Suspense boundary is
          // allowed. It will suspend indefinitely without committing.
          attachPingListener(root, wakeable, rootRenderLanes);
          renderDidSuspendDelayIfPossible();
          return false;
        } else {
          // In a legacy root, suspending without a boundary is always an error.
          const uncaughtSuspenseError = new Error(
            'A component suspended while responding to synchronous input. This ' +
              'will cause the UI to be replaced with a loading indicator. To ' +
              'fix, updates that suspend should be wrapped ' +
              'with startTransition.',
          );
          value = uncaughtSuspenseError;
        }
      }

并发根下无边界挂起是合法的:整个根挂起,不提交,等数据。legacy 根下则降级成错误(会卸载整棵树换成 loading——这条错误消息在 React 17 时代广为流传)。

而 throwException 剩下的 551 行以后,是错误路径:找错误边界(ClassComponent + getDerivedStateFromError/componentDidCatch)、HostRoot 兜底、renderDidError()。Suspense 与错误边界在这里分家:wakeable 走上面所有逻辑,Error 走下面。这个 if/else 结构就是“Suspense 不处理 rejection”的源码级证据。

9.3.6 第五站:unwind 与 fallback 渲染

throwException 返回后,throwAndUnwindWorkLoop 根据 Incomplete 标志向上 unwind:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3276-3327(节选)
  if (unitOfWork.flags & Incomplete) {
    // Unwind the stack until we reach the nearest boundary.
    let skipSiblings;

    if (
      // The current algorithm for both hydration and error handling assumes
      // that the tree is rendered sequentially. So we always skip the siblings.
      getIsHydrating() ||
      suspendedReason === SuspendedOnError
    ) {
      skipSiblings = true;
    } else if (
      // Check whether this is a prerender
      !workInProgressRootIsPrerendering &&
      // Offscreen rendering is also a form of speculative rendering
      !includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
    ) {
      // This is not a prerender. Skip the siblings during this render. A
      // separate prerender will be scheduled for later.
      skipSiblings = true;
      workInProgressRootDidSkipSuspendedSiblings = true;

      // Because we're skipping the siblings, schedule an immediate retry of
      // this boundary.
      if (
        suspendedReason === SuspendedOnData ||
        suspendedReason === SuspendedOnAction ||
        suspendedReason === SuspendedOnImmediate ||
        suspendedReason === SuspendedOnDeprecatedThrowPromise
      ) {
        const boundary = getSuspenseHandler();
        if (boundary !== null && boundary.tag === SuspenseComponent) {
          boundary.flags |= ScheduleRetry;
        }
      }
    } else {
      // This is a prerender. Don't skip the siblings.
      skipSiblings = false;
    }

    unwindUnitOfWork(unitOfWork, skipSiblings);
  } else {
    // Although the fiber suspended, we're intentionally going to commit it in
    // an inconsistent state. ...
    completeUnitOfWork(unitOfWork);
  }

要点:默认跳过兄弟节点(skipSiblings = true)——一个组件挂起,它右边的兄弟这次都不渲染了(它们反正会被 fallback 遮住),并给边界打 ScheduleRetry,让一次并行的 prerender 去预热这些被跳过的兄弟。这是 React 18 “sibling prerendering” 优化的一部分(workInProgressRootDidSkipSuspendedSiblings / workInProgressRootIsPrerendering 两个标志就是为它服务的)。

unwind 沿 return 链向上,每层只做“拆栈”(pop 各种 context cursor、清理 effect 链表),直到抵达被 ShouldCapture 盖章的边界。这里有一个容易望文生义的细节:unwind 并不“渲染”边界,它只是把工作循环的指针挪回边界,并让边界走第二次 beginWork。第二次进入的入口就是 updateSuspenseComponent——那是 9.4 节的故事。

9.3.7 标志如何沿树上传:bubbleProperties 与 subtreeFlags

ShouldCapture 打在边界上,但外层组件(比如边界外的某个 <div> 的 Fiber)怎么知道“我下面有东西挂起了”?答案是 completeWork 阶段的 bubbleProperties(ReactFiberCompleteWork.js:793-900+):

// packages/react-reconciler/src/ReactFiberCompleteWork.js:794-817(节选)
function bubbleProperties(completedWork: Fiber) {
  const didBailout =
    completedWork.alternate !== null &&
    completedWork.alternate.child === completedWork.child;

  let newChildLanes: Lanes = NoLanes;
  let subtreeFlags: Flags = NoFlags;

  if (!didBailout) {
    // Bubble up the earliest expiration time.
    ...
    let child = completedWork.child;
    while (child !== null) {
      newChildLanes = mergeLanes(
        newChildLanes,
        mergeLanes(child.lanes, child.childLanes),
      );

      subtreeFlags |= child.subtreeFlags;
      subtreeFlags |= child.flags;
      ...

每个 fiber 完成时把 children 的 flags/subtreeFlags/lanes/childLanes 全部“ OR ”进自己的 subtreeFlags。于是边界上的 DidCapture/ShouldCapture 会沿着 complete 链一路上浮到根。commitRoot 只需要看根的 subtreeFlags 就能知道“这棵树里有没有 Suspense/Offscreen 相关的工作”(finishedWork.subtreeFlags & passiveSubtreeMask,ReactFiberWorkLoop.js:3795),无需全树扫描——这是第 3 章讲过的“subtreeFlags 冒泡”在 Suspense 上的具体应用。

与之配套的是 unwindUnitOfWork(ReactFiberWorkLoop.js 中 unwindWork 的调用方):unwind 每经过一层 Offscreen/Activity/SuspenseList,都要把 beginWork 时 push 的 handler 弹掉(popSuspenseHandler),保证“栈顶永远是当前正在渲染的层的 handler”。这就是为什么 ReactFiberSuspenseContext.js 里每个 push 函数都成对存在,且源码在 Offscreen/Activity 分支里反复注释 “push/pop mismatch”——栈的平衡是这套机制的正确性前提,错一次,后续所有 getSuspenseHandler() 都会返回错误的边界。

至此,“抛 thenable → 捕获”的前半段闭环。后半段“ping → retry”在 9.7 节。


9.4 SuspenseComponent Fiber 的结构

9.4.1 一个 <Suspense> = 三个 Fiber

用户写的:

<Suspense fallback={<Spinner />}>
  <RealContent />
</Suspense>

在 Fiber 树上展开为:

flowchart TB
    subgraph TREE[&#34;Fiber 树(显示 fallback 时)&#34;]
        S[&#34;SuspenseComponent<br/>tag=SuspenseComponent<br/>memoizedState=SUSPENDED_MARKER<br/>updateQueue=Set&#34;]
        P[&#34;primaryChildFragment<br/>tag=OffscreenComponent<br/>mode='hidden'<br/>memoizedState=OffscreenState{baseLanes,cachePool}<br/>childLanes=剩余工作&#34;]
        F[&#34;fallbackChildFragment<br/>普通 Fragment Fiber<br/>渲染 &#34;]
        S -->|child| P
        P -->|sibling| F
        P -.->|&#34;内部子树被隐藏(display:none)但保留&#34;| RC[&#34;RealContent 子树&#34;]
        F --> SP[&#34;Spinner&#34;]
    end
    style S fill:#f9f,stroke:#333
    style P fill:#bbf,stroke:#333
    style F fill:#bfb,stroke:#333

三个节点各司其职:

Fibertag职责
边界本体SuspenseComponent状态机载体(memoizedState)、retry 队列(updateQueue)、被 ShouldCapture/DidCapture 盖章的对象
主内容 fragmentOffscreenComponent(mode 为 hidden/visible)包住真实 children。显示 fallback 时它处于 hidden——DOM 被 display:none 隐藏但保留,state 不丢
fallback fragment普通 Fragment渲染 fallback prop

“主内容用 Offscreen fiber 包装”这个设计是 React 18 的骨架级改动:Suspense 的“隐藏内容”复用了 Offscreen 的隐藏机制,所以 Suspense 天然获得了“保留 state、延迟 effect、可预热”的能力。源码里 mountSuspensePrimaryChildren 直接调用 mountWorkInProgressOffscreenFiber 就是证据:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2649-2667
function mountSuspensePrimaryChildren(
  workInProgress: Fiber,
  primaryChildren: $FlowFixMe,
  renderLanes: Lanes,
) {
  const mode = workInProgress.mode;
  const primaryChildProps: OffscreenProps = {
    mode: 'visible',
    children: primaryChildren,
  };
  const primaryChildFragment = mountWorkInProgressOffscreenFiber(
    primaryChildProps,
    mode,
    renderLanes,
  );
  primaryChildFragment.return = workInProgress;
  workInProgress.child = primaryChildFragment;
  return primaryChildFragment;
}

9.4.2 边界的状态机:memoizedState 的三种取值

SuspenseComponent 的 memoizedState 是它的“档位”:

// packages/react-reconciler/src/ReactFiberSuspenseComponent.js:24-43
// A null SuspenseState represents an unsuspended normal Suspense boundary.
// A non-null SuspenseState means that it is blocked for one reason or another.
// - A non-null dehydrated field means it's blocked pending hydration.
//   - A non-null dehydrated field can use isSuspenseInstancePending or
//     isSuspenseInstanceFallback to query the reason for being dehydrated.
// - A null dehydrated field means it's blocked by something suspending and
//   we're currently showing a fallback instead.
export type SuspenseState = {
  // If this boundary is still dehydrated, we store the SuspenseInstance
  // here to indicate that it is dehydrated (flag) and for quick access
  // to check things like isSuspenseInstancePending.
  dehydrated: null | SuspenseInstance,
  treeContext: null | TreeContext,
  // Represents the lane we should attempt to hydrate a dehydrated boundary at.
  // OffscreenLane is the default for dehydrated boundaries.
  // NoLane is the default for normal boundaries, which turns into "normal" pri.
  retryLane: Lane,
  // Stashed Errors that happened while attempting to hydrate this boundary.
  hydrationErrors: Array<CapturedValue<mixed>> | null,
};

三个档位:

  1. memoizedState === null:正常态,显示主内容。
  2. memoizedState = SUSPENDED_MARKER:因挂起而显示 fallback。这是一个共享的冻结常量:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2287-2292
const SUSPENDED_MARKER: SuspenseState = {
  dehydrated: null,
  treeContext: null,
  retryLane: NoLane,
  hydrationErrors: null,
};

用常量而不是 {} 的好处:省一次对象分配,且 === 比较即可判断“处于 fallback”(commit 阶段大量使用这个比较,如 ReactFiberCommitWork.js:2533)。

  1. memoizedState = {dehydrated: SuspenseInstance, ...}:SSR 脱水边界,等待水合(9.9 节)。

primaryChildFragment 上的 OffscreenState 则是另一套:

// packages/react-reconciler/src/ReactFiberOffscreenComponent.js:37-43
// We use the existence of the state object as an indicator that the component
// is hidden.
export type OffscreenState = {
  // TODO: This doesn't do anything, yet. It's always NoLanes. But eventually it
  // will represent the pending work that must be included in the render in
  // order to unhide the component.
  baseLanes: Lanes,
  cachePool: SpawnedCachePool | null,
};

cachePool 是关键:Offscreen 隐藏时把 Cache(React.unstable_Cache,19 中对应 use + cache 体系)的 pool “收拢”起来,重新显示时恢复——这样隐藏期间 cache 里的数据不会泄漏也不会丢失。mountSuspenseOffscreenState / updateSuspenseOffscreenState 就是干这个的:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2294-2332
function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState {
  return {
    baseLanes: renderLanes,
    cachePool: getSuspendedCache(),
  };
}

function updateSuspenseOffscreenState(
  prevOffscreenState: OffscreenState,
  renderLanes: Lanes,
): OffscreenState {
  let cachePool: SpawnedCachePool | null = null;
  const prevCachePool: SpawnedCachePool | null = prevOffscreenState.cachePool;
  if (prevCachePool !== null) {
    const parentCache = isPrimaryRenderer
      ? CacheContext._currentValue
      : CacheContext._currentValue2;
    if (prevCachePool.parent !== parentCache) {
      // Detected a refresh in the parent. This overrides any previously
      // suspended cache.
      cachePool = {
        parent: parentCache,
        pool: parentCache,
      };
    } else {
      // We can reuse the cache from last time. The only thing that would have
      // overridden it is a parent refresh, which we checked for above.
      cachePool = prevCachePool;
    }
  } else {
    // If there's no previous cache pool, grab the current one.
    cachePool = getSuspendedCache();
  }
  return {
    baseLanes: mergeLanes(prevOffscreenState.baseLanes, renderLanes),
    cachePool,
  };
}

updateSuspenseOffscreenState 的 baseLanes 是合并而非覆盖的:边界每次挂起/重试,把所有见过的 lanes 累积起来,childLanes 则记录“主内容里还剩多少没干完的活”(见下面的 getRemainingWorkInPrimaryTree)。这两个字段合起来回答了一个重要问题:“retry 的时候,到底要不要重新渲染主内容?”——beginWork 的 SuspenseComponent 分支正是用 primaryChildFragment.childLanes 与当前 renderLanes 做交集判断的(ReactFiberBeginWork.js:4070-4075):

        const primaryChildFragment: Fiber = workInProgress.child as any;
        const primaryChildLanes = primaryChildFragment.childLanes;
        if (
          contextChanged ||
          includesSomeLane(renderLanes, primaryChildLanes)
        ) {
          // The primary children have pending work. Use the normal path
          // to attempt to render the primary children again.
          return updateSuspenseComponent(current, workInProgress, renderLanes);
        } else {
          // The primary child fragment does not have pending work marked
          // on it
          pushPrimaryTreeSuspenseHandler(workInProgress);
          // The primary children do not have pending work with sufficient
          // priority. Bailout.
          ...

即“主内容还有这个优先级的活”才值得进 updateSuspenseComponent 重试主内容,否则直接 bailout。这是 Suspense 与 lane 系统(第 8 章)的接缝。

9.4.3 updateSuspenseComponent 逐段拆解

这是 Suspense 渲染逻辑的总入口(ReactFiberBeginWork.js:2383-2647)。我们按“判定 → 挂载 → 更新”三段读。

第一段:决定 showFallback。

// packages/react-reconciler/src/ReactFiberBeginWork.js:2388-2412
  const nextProps: SuspenseProps = workInProgress.pendingProps;

  // This is used by DevTools to force a boundary to suspend.
  if (__DEV__) {
    if (shouldSuspend(workInProgress)) {
      workInProgress.flags |= DidCapture;
    }
  }

  let showFallback = false;
  const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
  if (
    didSuspend ||
    shouldRemainOnFallback(current, workInProgress, renderLanes)
  ) {
    // Something in this boundary's subtree already suspended. Switch to
    // rendering the fallback children.
    showFallback = true;
    workInProgress.flags &= ~DidCapture;
  }

  // Check if the primary children spawned a deferred task (useDeferredValue)
  // during the first pass.
  const didPrimaryChildrenDefer = (workInProgress.flags & DidDefer) !== NoFlags;
  workInProgress.flags &= ~DidDefer;

showFallback = true 的两个来源:

  1. didSuspend——刚刚被 throwException 盖了 DidCapture(消费后立刻清除,防重复触发);
  2. shouldRemainOnFallback——这次渲染并不是因为挂起,但边界当前处于 fallback,且 SuspenseList 要求它继续留在 fallback:
// packages/react-reconciler/src/ReactFiberBeginWork.js:2335-2363
function shouldRemainOnFallback(
  current: null | Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // If we're already showing a fallback, there are cases where we need to
  // remain on that fallback regardless of whether the content has resolved.
  // For example, SuspenseList coordinates when nested content appears.
  if (current !== null) {
    const suspenseState: SuspenseState = current.memoizedState;
    if (suspenseState === null) {
      // Currently showing content. Don't hide it, even if ForceSuspenseFallback
      // is true. More precise name might be "ForceRemainSuspenseFallback".
      // Note: This is a factoring smell. Can't remain on a fallback if there's
      // no fallback to remain on.
      return false;
    }
  }

  // Not currently showing content. Consult the Suspense context.
  const suspenseContext: SuspenseContext = suspenseStackCursor.current;
  return hasSuspenseListContext(
    suspenseContext,
    ForceSuspenseFallback as SuspenseContext,
  );
}

两个条件叠加:当前就在 fallback + 上下文里带着 ForceSuspenseFallback 位(只有 SuspenseList 会设置这个位)。这保证了“已经在显示内容的边界,绝不会因为 SuspenseList 的批量策略被强行按回 fallback”——内容不闪是底线。

didPrimaryChildrenDefer 记录主内容渲染时是否有 useDeferredValue 派生了 deferred 任务(第 8 章),有的话 retry 时要按 deferred 优先级补上:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2365-2381
function getRemainingWorkInPrimaryTree(
  current: Fiber | null,
  primaryTreeDidDefer: boolean,
  renderLanes: Lanes,
) {
  let remainingLanes =
    current !== null ? removeLanes(current.childLanes, renderLanes) : NoLanes;
  if (primaryTreeDidDefer) {
    // A useDeferredValue hook spawned a deferred task inside the primary tree.
    // Ensure that we retry this component at the deferred priority.
    remainingLanes = mergeLanes(remainingLanes, peekDeferredLane());
  }
  return remainingLanes;
}

第二段:挂载路径(current === null)。 三个分支:hydration(9.9 节)、showFallback、CPU Suspense、正常渲染。看 showFallback 分支:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2462-2500
    if (showFallback) {
      pushFallbackTreeSuspenseHandler(workInProgress);

      mountSuspenseFallbackChildren(
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes,
      );
      const primaryChildFragment: Fiber = workInProgress.child as any;
      primaryChildFragment.memoizedState =
        mountSuspenseOffscreenState(renderLanes);
      primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
        current,
        didPrimaryChildrenDefer,
        renderLanes,
      );
      workInProgress.memoizedState = SUSPENDED_MARKER;
      ...
      return bailoutOffscreenComponent(null, primaryChildFragment);
    } else if (enableCPUSuspense && nextProps.defer === true) {
      // This is a CPU-bound tree. Skip this tree and show a placeholder to
      // unblock the surrounding content. Then immediately retry after the
      // initial commit.
      ...
      workInProgress.lanes = SomeRetryLane;
      return bailoutOffscreenComponent(null, primaryChildFragment);
    } else {
      pushPrimaryTreeSuspenseHandler(workInProgress);
      return mountSuspensePrimaryChildren(
        workInProgress,
        nextPrimaryChildren,
        renderLanes,
      );
    }
  • 显示 fallback 时:主内容被构建成 mode: 'hidden' 的 Offscreen fiber,但不渲染其 children——bailoutOffscreenComponent(null, primaryChildFragment) 直接跳过主内容的整棵树(它的 childLanes 记住“这里还有活”),只渲染 fallback fragment。这就是“fallback 出现时主内容一行都没渲染”的源码证据:不是渲染了再藏,是根本没渲染。
  • CPU Suspense(<Suspense defer>,实验特性):与数据无关,纯粹因为“这棵树 CPU 开销大,先让周围内容出图,再用 RetryLane 立刻重试”。workInProgress.lanes = SomeRetryLane 是“自己给自己安排重试”的手法。
  • 正常路径:pushPrimaryTreeSuspenseHandler 把本边界压上 handler 栈(之后的子组件挂起都归它管),然后只构建主内容 fragment。

第三段:更新路径(current !== null)。 非 hydration 时核心是两个函数。先看“切到 fallback”:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2563-2621(节选)
    if (showFallback) {
      pushFallbackTreeSuspenseHandler(workInProgress);

      const nextFallbackChildren = nextProps.fallback;
      const nextPrimaryChildren = nextProps.children;
      updateSuspenseFallbackChildren(
        current,
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes,
      );
      const primaryChildFragment: Fiber = workInProgress.child as any;
      const prevOffscreenState: OffscreenState | null = (current.child as any)
        .memoizedState;
      primaryChildFragment.memoizedState =
        prevOffscreenState === null
          ? mountSuspenseOffscreenState(renderLanes)
          : updateSuspenseOffscreenState(prevOffscreenState, renderLanes);
      ...
      primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(...);
      workInProgress.memoizedState = SUSPENDED_MARKER;
      return bailoutOffscreenComponent(current.child, primaryChildFragment);
    } else {
      ...
      pushPrimaryTreeSuspenseHandler(workInProgress);

      const nextPrimaryChildren = nextProps.children;
      const primaryChildFragment = updateSuspensePrimaryChildren(
        current,
        workInProgress,
        nextPrimaryChildren,
        renderLanes,
      );
      workInProgress.memoizedState = null;
      return primaryChildFragment;
    }

注意 update 路径里 memoizedState 的赋值:fallback 分支设 SUSPENDED_MARKER,主内容分支设 null。这就是“档位切换”的字面发生地。而“显示 fallback 时旧的主内容怎么办”由 updateSuspenseFallbackChildren 回答:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2790-2878(节选)
function updateSuspenseFallbackChildren(
  current: Fiber,
  workInProgress: Fiber,
  primaryChildren: $FlowFixMe,
  fallbackChildren: $FlowFixMe,
  renderLanes: Lanes,
) {
  const mode = workInProgress.mode;
  const currentPrimaryChildFragment: Fiber = current.child as any;
  const currentFallbackChildFragment: Fiber | null =
    currentPrimaryChildFragment.sibling;

  const primaryChildProps: OffscreenProps = {
    mode: 'hidden',
    children: primaryChildren,
  };

  let primaryChildFragment;
  if (
    !disableLegacyMode &&
    (mode & ConcurrentMode) === NoMode &&
    workInProgress.child !== currentPrimaryChildFragment
  ) {
    ... // legacy 分支,复用第一遍渲染的半成品
  } else {
    primaryChildFragment = updateWorkInProgressOffscreenFiber(
      currentPrimaryChildFragment,
      primaryChildProps,
    );
    // Since we're reusing a current tree, we need to reuse the flags, too.
    // (We don't do this in legacy mode, because in legacy mode we don't re-use
    // the current tree; see previous branch.)
    primaryChildFragment.subtreeFlags =
      currentPrimaryChildFragment.subtreeFlags & StaticMask;
  }
  let fallbackChildFragment;
  if (currentFallbackChildFragment !== null) {
    fallbackChildFragment = createWorkInProgress(
      currentFallbackChildFragment,
      fallbackChildren,
    );
  } else {
    fallbackChildFragment = createFiberFromFragment(
      fallbackChildren,
      mode,
      renderLanes,
      null,
    );
    // Needs a placement effect because the parent (the Suspense boundary) already
    // mounted but this is a new fiber.
    fallbackChildFragment.flags |= Placement;
  }

  fallbackChildFragment.return = workInProgress;
  primaryChildFragment.return = workInProgress;
  primaryChildFragment.sibling = fallbackChildFragment;
  workInProgress.child = primaryChildFragment;

  return bailoutOffscreenComponent(null, primaryChildFragment);
}

并发路径下,旧主内容 fiber 被 updateWorkInProgressOffscreenFiber(即 createWorkInProgress,双缓冲的克隆)原样接管,只是 props 的 mode 从 visible 改成 hidden。主内容的子树(DOM、state、effect 链表)原封不动,只是不再被遍历(bailoutOffscreenComponent)。这就是 React 18 起“切到 fallback 后旧内容还在 DOM 里(display:none)”的实现。

subtreeFlags 的保留也很讲究:StaticMask 只保留静态类 flag(如 Snapshot、Callback 之外的持久位),把渲染中产生的动态 flag 清掉——旧子树里“未完成的活”作废,但结构信息保留。

反过来,“从 fallback 切回主内容”由 updateSuspensePrimaryChildren 处理:

// packages/react-reconciler/src/ReactFiberBeginWork.js:2753-2788
function updateSuspensePrimaryChildren(
  current: Fiber,
  workInProgress: Fiber,
  primaryChildren: $FlowFixMe,
  renderLanes: Lanes,
) {
  const currentPrimaryChildFragment: Fiber = current.child as any;
  const currentFallbackChildFragment: Fiber | null =
    currentPrimaryChildFragment.sibling;

  const primaryChildFragment = updateWorkInProgressOffscreenFiber(
    currentPrimaryChildFragment,
    {
      mode: 'visible',
      children: primaryChildren,
    },
  );
  ...
  primaryChildFragment.return = workInProgress;
  primaryChildFragment.sibling = null;
  if (currentFallbackChildFragment !== null) {
    // Delete the fallback child fragment
    const deletions = workInProgress.deletions;
    if (deletions === null) {
      workInProgress.deletions = [currentFallbackChildFragment];
      workInProgress.flags |= ChildDeletion;
    } else {
      deletions.push(currentFallbackChildFragment);
    }
  }

  workInProgress.child = primaryChildFragment;
  return primaryChildFragment;
}

旧 fallback 被塞进 deletions 列表——ChildDeletion 会在 commit 阶段把它从 DOM 卸载。一进一出,非常对称:显示 fallback = 旧主内容转 hidden;显示主内容 = 旧 fallback 删除。

9.4.4 beginWork 入口处的“早退”决策

最后把视角拉回 beginWork 的 dispatch(ReactFiberBeginWork.js:4375):

// packages/react-reconciler/src/ReactFiberBeginWork.js:4375-4377
    case SuspenseComponent: {
      return updateSuspenseComponent(current, workInProgress, renderLanes);
    }

而在这之前,attemptEarlyBailoutIfNoScheduledUpdate 里还有一段 SuspenseComponent 特判(ReactFiberBeginWork.js:4039-4100,9.4.2 节引过半段),处理“边界处于 fallback 时,这次更新到底该不该重试主内容”以及“dehydrated 边界不许渲染 children”。它与 updateSuspenseComponent 的分工是:

  • attemptEarlyBailoutIfNoScheduledUpdate:廉价的预筛(看 childLanes、context),没活就 bailout;
  • updateSuspenseComponent:真正的两棵树二选一。

至此,SuspenseComponent 的结构与渲染决策全部讲完。下一节看这些结构在“时间维度”上的表现——fallback 何时出现、何时退场。

9.4.5 completeWork 中的 SuspenseComponent:第二遍渲染与 Visibility 调度

updateSuspenseComponent 只是“声明渲染哪棵树”,边界的收尾工作在 completeWork(ReactFiberCompleteWork.js:1549-1665)。这里藏着 Suspense “两遍渲染”的最后一环。

第二遍渲染的触发点:

// packages/react-reconciler/src/ReactFiberCompleteWork.js:1585-1598
      popSuspenseHandler(workInProgress);

      if ((workInProgress.flags & DidCapture) !== NoFlags) {
        // Something suspended. Re-render with the fallback children.
        workInProgress.lanes = renderLanes;
        if (
          enableProfilerTimer &&
          (workInProgress.mode & ProfileMode) !== NoMode
        ) {
          transferActualDuration(workInProgress);
        }
        // Don't bubble properties in this case.
        return workInProgress;
      }

如果边界完成时仍带着 DidCapture(子树挂起了),completeWork 把边界自己作为下一个工作单元返回(return workInProgress)——于是 completeUnitOfWork 把 workInProgress 指向边界,performUnitOfWork 再次 beginWork(边界),这次 updateSuspenseComponent 读到 DidCapture,走 fallback 分支。这就是“第一遍渲染主内容、挂起后第二遍渲染 fallback”的循环机制:不是递归函数,而是工作循环里“把自己重新入队”。注意 workInProgress.lanes = renderLanes——强制让边界保持“有活”,否则 attemptEarlyBailoutIfNoScheduledUpdate 可能把它跳过。

Visibility 效果的精确调度:

// packages/react-reconciler/src/ReactFiberCompleteWork.js:1628-1651
      // If the suspended state of the boundary changes, we need to schedule
      // a passive effect, which is when we process the transitions
      if (nextDidTimeout !== prevDidTimeout) {
        if (enableTransitionTracing) {
          const offscreenFiber: Fiber = workInProgress.child as any;
          offscreenFiber.flags |= Passive;
        }

        // If the suspended state of the boundary changes, we need to schedule
        // an effect to toggle the subtree's visibility. When we switch from
        // fallback -> primary, the inner Offscreen fiber schedules this effect
        // as part of its normal complete phase. But when we switch from
        // primary -> fallback, the inner Offscreen fiber does not have a complete
        // phase. So we need to schedule its effect here.
        //
        // We also use this flag to connect/disconnect the effects, but the same
        // logic applies: when re-connecting, the Offscreen fiber's complete
        // phase will handle scheduling the effect. It's only when the fallback
        // is active that we have to do anything special.
        if (nextDidTimeout) {
          const offscreenFiber: Fiber = workInProgress.child as any;
          offscreenFiber.flags |= Visibility;
        }
      }

      const retryQueue: RetryQueue | null = workInProgress.updateQueue as any;
      scheduleRetryEffect(workInProgress, retryQueue);

两个非对称细节,都是“主内容在 fallback 期间不参与渲染”的直接后果:

  1. fallback → primary 时,主内容 Offscreen fiber 有正常的 complete 阶段,自己会调度 Visibility(ReactFiberCommitWork.js 的 Offscreen complete 分支比较 prevIsHidden !== nextIsHidden,见 ReactFiberCompleteWork.js:1977-1989);
  2. primary → fallback 时,主内容 fiber 被 bailout、没有 complete 阶段,所以必须由边界在 completeWork 里代替它打 Visibility 标志。

漏掉这个标志的后果是:主内容不会从 display:none 恢复,用户看到 fallback 永远不消失。这种“代替被跳过的子树打标记”的模式在 Suspense 源码里反复出现(ReactFiberCommitWork.js:2519-2526 的 TODO 也在吐槽这种脆弱性:“this offscreen fiber might not be part of the work-in-progress tree!”)。

cache 的保留与释放:

// packages/react-reconciler/src/ReactFiberCompleteWork.js:1605-1626
      if (nextDidTimeout) {
        const offscreenFiber: Fiber = workInProgress.child as any;
        let previousCache: Cache | null = null;
        if (
          offscreenFiber.alternate !== null &&
          offscreenFiber.alternate.memoizedState !== null &&
          offscreenFiber.alternate.memoizedState.cachePool !== null
        ) {
          previousCache = offscreenFiber.alternate.memoizedState.cachePool.pool;
        }
        let cache: Cache | null = null;
        if (
          offscreenFiber.memoizedState !== null &&
          offscreenFiber.memoizedState.cachePool !== null
        ) {
          cache = offscreenFiber.memoizedState.cachePool.pool;
        }
        if (cache !== previousCache) {
          // Run passive effects to retain/release the cache.
          offscreenFiber.flags |= Passive;
        }
      }

cachePool 换pool时给 Offscreen 打 Passive——passive 阶段负责 retain/release cache 引用(commitOffscreenPassiveMountEffects 等)。这是“隐藏子树里的 cache 数据在 fallback 期间不泄漏、恢复时不丢失”的执行点。

脱水边界的提前返回:completeWork 的 SuspenseComponent 分支开头(ReactFiberCompleteWork.js:1549-1583)先判 dehydrated:completeDehydratedSuspenseBoundary 处理水合收尾,没收完且带 ForceClientRender 时 return workInProgress 让边界立刻重渲染(转 client render),否则 return null 留着下次。水合与普通挂起在 completeWork 层面正式分家。


9.5 边界语义:fallback 何时出现、何时退场

9.5.1 四类挂起场景与退出状态

throwException 里根据 shellBoundary 分出两种记录(renderDidSuspend / renderDidSuspendDelayIfPossible),对应到工作循环的退出状态(ReactFiberWorkLoop.js:427-434):

退出状态含义后续行为
RootInProgress还在渲染继续
RootCompleted正常完成commit
RootSuspended挂起了,但可以提交 fallbackcommit fallback
RootSuspendedWithDelay挂起了,且不应该提交(shell 挂起或节流中)不 commit,等数据/ping/超时
RootSuspendedAtTheShell选择性水合打断切到 hydration lane 重来
RootErrored错误走错误恢复

renderDidSuspendDelayIfPossible 全文(ReactFiberWorkLoop.js:2552-2596):

export function renderDidSuspendDelayIfPossible(): void {
  workInProgressRootExitStatus = RootSuspendedWithDelay;

  if (
    !workInProgressRootDidSkipSuspendedSiblings &&
    // Check if the root will be blocked from committing.
    (includesOnlyTransitions(workInProgressRootRenderLanes) ||
      getSuspenseHandler() === null)
  ) {
    // This render may not have originally been scheduled as a prerender, but
    // something suspended inside the visible part of the tree, which means we
    // won't be able to commit a fallback anyway. Let's proceed as if this were
    // a prerender so that we can warm up the siblings without scheduling a
    // separate pass.
    workInProgressRootIsPrerendering = true;
  }

  // Check if there are updates that we skipped tree that might have unblocked
  // this render.
  if (
    (includesNonIdleWork(workInProgressRootSkippedLanes) ||
      includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)) &&
    workInProgressRoot !== null
  ) {
    // Mark the current render as suspended so that we switch to working on
    // the updates that were skipped. ...
    const didAttemptEntireTree = false;
    markRootSuspended(
      workInProgressRoot,
      workInProgressRootRenderLanes,
      workInProgressDeferredLane,
      didAttemptEntireTree,
    );
  }
}

三个动作:① 设 RootSuspendedWithDelay;② 符合条件的渲染升级为“prerender 模式”(顺带预热被跳过的兄弟);③ 如果有被跳过的非 idle 更新,立刻 markRootSuspended 让出——转去干更紧急的活,这是 lane 系统与 Suspense 的联动。

9.5.2 “完成之后的决策”:finishConcurrentRender

渲染跑完后(或被 suspend 后),finishConcurrentRender(ReactFiberWorkLoop.js:1420-1576)根据退出状态做最终决策:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:1435-1465
    case RootSuspendedWithDelay: {
      if (!includesOnlyTransitions(lanes) && !includesOnlyRetries(lanes)) {
        // Commit the placeholder.
        break;
      }
    }
    // Fallthrough
    case RootSuspendedAtTheShell: {
      // This is a transition, so we should exit without committing a
      // placeholder and without scheduling a timeout. Delay indefinitely
      // until we receive more data.
      ...
      const didAttemptEntireTree = !workInProgressRootDidSkipSuspendedSiblings;
      markRootSuspended(
        root,
        lanes,
        workInProgressDeferredLane,
        didAttemptEntireTree,
      );
      return;
    }

这是 React 18 最著名的行为改进的源码:

  • RootSuspendedWithDelay 且 lanes 是纯 transition/retry:不提交。这就是“startTransition 中导航到新页面,数据没就绪时保持旧界面”的机制——旧界面赖着不走,直到数据就绪后一次性呈现新界面。
  • RootSuspendedWithDelay 但不是 transition(比如默认优先级的更新在 shell 里挂起):穿透到 commit,显示 fallback(“Commit the placeholder”)——普通更新不能无限挂起,必须给用户一个加载态。
  • retry 的节流:RootSuspended + 纯 retry lanes 时,检查距上次 fallback 是否超过 FALLBACK_THROTTLE_MS(300ms),没超就把 commit 挂一个定时器推迟:
// packages/react-reconciler/src/ReactFiberWorkLoop.js:1503-1557(节选)
    if (
      includesOnlyRetries(lanes) &&
      (alwaysThrottleRetries || exitStatus === RootSuspended)
    ) {
      // This render only included retries, no updates. Throttle committing
      // retries so that we don't show too many loading states too quickly.
      const msUntilTimeout =
        globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();

      // Don't bother with a very short suspense time.
      if (msUntilTimeout > 10) {
        ...
        // The render is suspended, it hasn't timed out, and there's no
        // lower priority work to do. Instead of committing the fallback
        // immediately, wait for more data to arrive.
        pendingEffectsLanes = lanes;
        root.timeoutHandle = scheduleTimeout(
          completeRootWhenReady.bind(null, root, finishedWork, ...),
          msUntilTimeout,
        );
        return;
      }
    }

即:retry 渲染成功后不急着 commit,攒一攒。globalMostRecentFallbackTime 由 markCommitTimeOfFallback() 更新(ReactFiberWorkLoop.js:2535-2537),记录点是 commit 阶段 fallback 出现/消失时(ReactFiberCommitWork.js:2531-2551)。300ms 窗口 + “是否 transition”两个旋钮,共同决定加载态出现的节奏。alwaysThrottleRetries 在 19.3.1 中恒为 true(shared/ReactFeatureFlags.js:135),意味着所有纯 retry 提交都会被节流。

9.5.3 嵌套边界:由内向外 vs 由外向内

嵌套边界的行为完全由“谁先被盖章”决定,而盖章顺序就是渲染顺序(深度优先)。考虑:

<Suspense fallback={<Outer />}>        {/* 边界 A */}
  <Suspense fallback={<Inner />}>      {/* 边界 B */}
    <Slow />
  </Suspense>
</Suspense>

Slow 挂起时,getSuspenseHandler() 返回的是栈顶——B(后 push 的)。所以 B 被盖章 ShouldCapture,unwind 到 B 就停:显示 <Inner />,A 及其余内容保持不动。这就是“最近的边界接住”的语义。

但有一个重要例外——A 已经在显示 fallback 的情况。此时 B 的 children 从未真正渲染过(第一次挂起时 B 直接 bailout 主内容只渲染 fallback)。那么“B 的主内容里”再挂起呢?答案:pushFallbackTreeSuspenseHandler 在渲染 B 的 fallback 时把 handler 栈恢复成了 A(“catch 块里再抛,同一个 catch 接不住”),所以 B 的 fallback 内部挂起会穿透到 A。行为上就是:外层 fallback 替换内层 fallback。这防止了“fallback 套 fallback”的无限递归。

还有一种情况:B 显示主内容、A 显示 fallback(A 的子树里 B 已经 resolve 了,但 A 的其它 children 没 resolve)。此时 A 的 update 让 B 重新渲染,B 的数据又挂了 → B 盖章,显示 <Inner />,而 A 的 fallback 还活着——嵌套边界各自独立升降,互不牵连(除非 A 根本没 fallback 可退)。

9.5.4 “内容不闪”的三道防线

React 18/19 的“避免加载态闪烁”不是单一机制,而是三套互补的策略,按生效范围从小到大:

  1. 微任务检查(SuspendedOnImmediate → SuspendedAndReadyToContinue):数据在让出的一帧内 resolve,原地 replay,界面完全无感。
  2. transition 保留旧内容(RootSuspendedWithDelay + 纯 transition lanes 不 commit):startTransition 包裹的更新挂起,旧界面原地不动。
  3. 节流(FALLBACK_THROTTLE_MS):连续的 retry 攒批提交,避免 fallback “闪一下就没了”。

反过来,如果用户没有用 startTransition,shell 里挂起会穿透节流直接显示 fallback(!includesOnlyTransitions && !includesOnlyRetries → break 到 commit)——这是刻意的设计:非 transition 的更新用户期望“有响应”,宁可给 loading 也不能干等。

9.5.5 unstable_avoidThisFallback:源码里的第五道防线

ReactFiberSuspenseContext.js 里还有一个实验开关,值得一看,因为它展示了“不理想 fallback”的完整判定逻辑:

// packages/react-reconciler/src/ReactFiberSuspenseContext.js:57-83
  // Experimental feature: Some Suspense boundaries are marked as having an
  // undesirable fallback state. These have special behavior where we only
  // activate the fallback if there's no other boundary on the stack that we can
  // use instead.
  if (
    enableSuspenseAvoidThisFallback &&
    props.unstable_avoidThisFallback === true &&
    // If an avoided boundary is already visible, it behaves identically to
    // a regular Suspense boundary.
    (current === null || isCurrentTreeHidden())
  ) {
    if (shellBoundary === null) {
      // We're rendering in the shell. There's no parent Suspense boundary that
      // can provide a desirable fallback state. We'll use this boundary.
      push(suspenseHandlerStackCursor, handler, handler);
      // However, because this is not a desirable fallback, the children are
      // still considered part of the shell. So we intentionally don't assign
      // to `shellBoundary`.
    } else {
      // There's already a parent Suspense boundary that can provide a desirable
      // fallback state. Prefer that one.
      const handlerOnStack = suspenseHandlerStackCursor.current;
      push(suspenseHandlerStackCursor, handlerOnStack, handler);
    }
    return;
  }

被标记 unstable_avoidThisFallback 的边界,其 children 挂起时跳过它,用外层更“理想”的边界兜底;只有当它在 shell 里(没有更外层边界可用)才亲自出马。19.3.1 中该 flag 默认关闭(shared/ReactFeatureFlags.js:123 enableSuspenseAvoidThisFallback = false),但代码完整保留,是理解"理想/不理想 fallback"概念的最好样本。


上篇源码索引表(覆盖 9.0 ~ 9.5)

路径相对 packages/react-reconciler/src/。

主题文件关键位置
thenable 追踪ReactFiberThenable.js全文 479 行;trackUsedThenable、getSuspendedThenable
抛出分派ReactFiberThrow.jsthrowException(:364)、markSuspenseBoundaryShouldCapture(:241)
ping 监听注册ReactFiberWorkLoop.jsattachPingListener(:4970)
use 原语ReactFiberHooks.jsuse(:1159)、useThenable(:1098)
Suspense 边界 fiberReactFiberSuspenseComponent.js全文 107 行;SuspenseState
边界上下文栈ReactFiberSuspenseContext.js全文 234 行;suspenseHandlerStackCursor、suspenseStackCursor
begin work 中的边界处理ReactFiberBeginWork.jsupdateSuspenseComponent(:2383)、mountSuspenseOffscreenState(:2294)、updateSuspenseFallbackChildren(:2790)
complete work 中的标记ReactFiberCompleteWork.js边界完成时的 flag 处理
commit 阶段的边界回调ReactFiberCommitWork.jscommitSuspenseCallback、hydration callbacks
挂起与恢复主循环ReactFiberWorkLoop.jshandleThrow、renderRootConcurrent 的 catch 分支

上篇自测 FAQ(覆盖 9.0 ~ 9.5)

Q1:React 为什么不直接用原生 Promise,而要造 thenable 抽象? 因为 React 需要"可重复读取"的语义:Promise 的 .then 只能消费一次结果,而渲染可能被打断并重跑多次。React 的 thenable 要求实现 status(pending/fulfilled/rejected)可被同步读取,这样重跑时不必重新发起请求。

Q2:抛出 Promise 之后,React 怎么知道该挂起哪个边界? 靠 suspenseHandlerStackCursor 这条栈。渲染进入 <Suspense> 时压栈,throwException 弹出最近一个能接住的处理者(还要检查 shouldCapture 与"是否是首次挂载"等条件)。

Q3:markSuspenseBoundaryShouldCapture 标记了就一定显示 fallback 吗? 不一定。它只是标记"这个边界应当捕获"。真正显示 fallback 还要经过 RootSuspended/RootSuspendedWithDelay 的判定——如果是 transition 更新且允许延迟,React 会先不提交 fallback。

Q4:Offscreen fiber 在这里起什么作用? 它是边界的"隐藏容器"。挂起时主内容被包进 Offscreen 并以 mode: 'hidden' 渲染,DOM 保留但 display: none;恢复时只需改回 visible。这避免了销毁重建的开销。

Q5:为什么 fallback 退场需要 SuspenseOffscreen 的 DidCapture 标志? 因为 React 要区分"这是第一次挂载所以显示 fallback"和"这是恢复所以直接显示内容"。DidCapture 清除后,重渲染才会走正常分支把 Offscreen 从 hidden 切回 visible。

Q6:ping 是什么? thenable settle 后调用当初注册的回调(attachPingListener 挂上去的),把对应 lane 重新标脏并触发一次新渲染。这是"挂起 → 等待 → 自动恢复"闭环的最后一环。

Q7:use() 和"在组件里 await"有什么区别? use() 不阻塞渲染流程——它抛出 thenable 让 React 挂起子树,组件其余部分不执行;而 await 会要求整个组件是 async 函数,React 19 之前无法直接渲染。use() 让"读异步资源"保持同步写法。

Q8:多个并列的 Suspense 边界,一个挂起会不会连累另一个? 不会,前提是它们各自独立。throwException 只找最近的祖先边界;兄弟边界互不影响。除非它们共享同一个祖先边界,此时祖先会统一挂起。

Q9:ReactFiberSuspenseContext.js 里为什么有两条栈? suspenseHandlerStackCursor 回答"谁能接住这次抛出"(写入侧),suspenseStackCursor 回答"当前渲染处于哪个边界内"(读取侧)。两者用途不同,因此分开维护。

Q10:上篇和下篇怎么分工? 上篇(本篇)讲核心机制:thenable 抽象、抛出链路、边界 fiber 结构、fallback 语义。下篇《第09章B_lazy、retry、hydration与实战》讲具体消费者:lazy 如何复用这套机制、retry 与错误边界、hydration 中的挂起,以及真实业务里的用法。