React 19 内核探秘

第9章 并发模式深度解析

作者 杨艺韬 · 9,654 字

第9章 并发模式深度解析

本章要点

  • 并发渲染的本质:不是多线程,而是可中断、可恢复的渲染模型
  • Lane 模型与优先级调度:从二进制位运算到任务插队的完整机制
  • Transition 的实现原理:entangled transitions 与状态一致性
  • Suspense 的挂起与恢复:Promise 协议、Offscreen Fiber 与回退策略
  • Selective Hydration:服务端渲染场景下的并发注水策略
  • Tearing 问题:并发模式下的状态撕裂与 useSyncExternalStore 的解决方案
  • 并发的本质不是"做得更快",而是"让用户感觉更快"

2022 年 3 月,React 18 正式发布。在所有的新特性中,有一个被反复提及却最常被误解的概念——并发模式(Concurrent Mode)。需要先说明的是:React 18 起官方已弃用 "Concurrent Mode" 这一术语——并发不是一个可整体开关的"模式",而是按需生效的并发渲染(concurrent rendering)与并发特性。本章沿用"并发模式"这一习惯说法,指的正是这套渲染机制。

许多开发者第一次听到"并发"这个词时,脑海中浮现的是操作系统课程里的多线程模型:多个线程同时执行,通过锁和信号量协调共享资源。这种直觉是危险的,因为 React 的并发与多线程完全无关。JavaScript 只有一个主线程,React 不能也不会创建新的线程。那么,React 的"并发"到底是什么?

答案是:可中断的渲染。在传统的同步渲染模型中,一旦 React 开始处理一次更新,它会一口气遍历整棵 Fiber 树,直到所有工作完成。在这个过程中,主线程被完全占用——用户的点击、输入、滚动都无法得到响应。并发模式改变了这个契约:React 可以开始渲染一棵树,在中途暂停,去处理更紧急的工作(比如用户输入),然后回来继续之前的渲染。更进一步地,React 甚至可以丢弃正在进行的渲染,转而开始一次全新的渲染。这种能力,是 startTransitionSuspenseuse 等所有现代 React API 的底层基石。

9.1 什么是并发渲染

9.1.1 同步渲染的天花板

在 React 18 之前,所有的渲染都是同步的。让我们用一个具体的场景来理解这意味着什么:

function SearchResults({ query }: { query: string }) {
  // 假设这个组件需要渲染 10000 个搜索结果
  const results = computeResults(query); // 耗时 200ms
  return (
    <ul>
      {results.map(item => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

function SearchPage() {
  const [query, setQuery] = useState('');
  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <SearchResults query={query} />
    </div>
  );
}

在同步渲染模式下,每次用户按下键盘,setQuery 触发的更新会立即开始一次完整的渲染。SearchResults 需要 200ms 来计算和渲染——在这 200ms 内,输入框无法响应用户的下一次击键。用户感受到的是明显的卡顿和输入延迟。

同步渲染的核心问题在于:它假设所有更新都具有相同的紧急程度。但在真实的用户交互中,"输入框立即显示用户输入的字符"和"搜索结果列表更新"显然不是同等紧急的事情。

下图对比了同步渲染与并发渲染的执行模型差异:

sequenceDiagram
    participant U as 用户
    participant M as 主线程
    participant R as React 渲染
    Note over M: 同步渲染模式
    U->>M: 用户输入
    M->>R: 开始渲染(200ms)
    Note over M: 主线程被阻塞
    R-->>M: 渲染完成
    M->>U: 更新 UI
    Note over M: 用户输入在 200ms 内无响应
    Note over M: 并发渲染模式
    U->>M: 用户输入
    M->>R: 开始渲染(5ms 切片)
    R-->>M: 时间切片用尽,让出
    M->>U: 处理新的用户输入
    U->>M: 又一次输入
    M->>U: 立即响应
    M->>R: 继续渲染(5ms 切片)
    R-->>M: 渲染完成
    M->>U: 更新 UI

9.1.2 可中断渲染的工作模型

并发渲染的核心思想可以用一个类比来理解:想象你是一个厨师,正在准备一道需要 30 分钟的炖菜。在同步模式下,你必须站在锅前盯着 30 分钟,期间无法处理任何其他事情。在并发模式下,你可以先把锅放上火,然后去处理一个刚到的外卖订单(紧急任务),处理完后再回来继续看管炖菜。

在 React 的实现中,这种"中断"发生在 Fiber 节点的边界:

// packages/react-reconciler/src/ReactFiberWorkLoop.js
function workLoopConcurrent() {
  // 并发模式的工作循环:每处理一个 Fiber 都检查是否需要让出
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function workLoopSync() {
  // 同步模式的工作循环:一口气做完所有工作
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

区别只有一个条件——shouldYield()。这个来自 Scheduler 的函数检查当前时间切片(通常为 5ms)是否已经用完。如果用完了,React 会把控制权交还给浏览器,让浏览器有机会处理用户事件和渲染更新。

9.1.3 Lane 模型:并发的优先级引擎

下图展示了 Lane 模型的优先级层次结构(bit 位从 0 开始计,位越低(越靠右)优先级越高,取值为 React 19.0 实值):

graph TD
    subgraph 高优先级["高优先级(不可中断)"]
        S["SyncLane<br/>bit 1<br/>同步更新:click, input"]
        IC["InputContinuousLane<br/>bit 3<br/>连续输入:drag, scroll"]
    end
    subgraph 默认["默认优先级"]
        D["DefaultLane<br/>bit 5<br/>普通更新:setTimeout"]
    end
    subgraph 过渡["Transition 优先级(可中断)"]
        T1["TransitionLane1-15<br/>bit 7-21<br/>startTransition 标记的更新"]
    end
    subgraph 低优先级["低优先级"]
        RT["RetryLane1-4<br/>bit 22-25<br/>Suspense 恢复"]
        OFF["OffscreenLane<br/>bit 29<br/>隐藏组件预渲染"]
        DEF["DeferredLane<br/>bit 30<br/>useDeferredValue 的延迟渲染"]
    end
    S -->|"优先级递减"| IC
    IC --> D
    D --> T1
    T1 --> RT
    RT --> OFF
    OFF --> DEF
    style 高优先级 fill:#ffcdd2,stroke:#c62828
    style 默认 fill:#fff9c4,stroke:#f9a825
    style 过渡 fill:#c8e6c9,stroke:#2e7d32
    style 低优先级 fill:#e0e0e0,stroke:#616161

并发渲染的实现依赖于一个精密的优先级系统——Lane 模型。每次更新都被分配一个 Lane(车道),不同的 Lane 代表不同的优先级(以下为 React 19.0 的实际取值;每类事件 Lane 还各有一条配套的 Hydration Lane 占据相邻的低一位,如 SyncHydrationLane = 0b1,此处只列出主干):

// packages/react-reconciler/src/ReactFiberLane.js(React 19.0)
export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;

const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000010000000000;
// ... 共 15 条 Transition Lanes(bit 7 到 bit 21)

const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
// ... 共 4 条 Retry Lanes(bit 22 到 bit 25)

export const OffscreenLane: Lane = /*                   */ 0b0100000000000000000000000000000;
export const DeferredLane: Lane = /*                    */ 0b1000000000000000000000000000000;

这个设计极为巧妙。使用二进制位来表示优先级有几个关键优势:

  1. 集合操作极其高效:合并两个 Lane 只需位或 a | b,取交集用位与 a & b,检查是否包含用 a & b !== 0
  2. 同一优先级可以有多个车道:Transition 有 15 条车道,允许多个 Transition 同时存在而互不干扰
  3. 优先级比较通过位置判断:位越低(越靠右),优先级越高
// 判断一组 Lanes 中最高优先级的 Lane
function getHighestPriorityLane(lanes: Lanes): Lane {
  // 位运算技巧:取最低位的 1
  return lanes & -lanes;
}

// 判断是否包含某个 Lane
function includesSomeLane(a: Lanes, b: Lanes): boolean {
  return (a & b) !== NoLanes;
}

// 合并两组 Lanes
function mergeLanes(a: Lanes, b: Lanes): Lanes {
  return a | b;
}

// 从集合中移除某些 Lanes
function removeLanes(set: Lanes, subset: Lanes): Lanes {
  return set & ~subset;
}

当 React 开始一次渲染时,它需要决定本次渲染要处理哪些 Lane。这个决策过程称为 getNextLanes,它的逻辑遵循严格的优先级层次:

function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
  const pendingLanes = root.pendingLanes;
  if (pendingLanes === NoLanes) return NoLanes;

  // 优先级决策的核心逻辑:
  // 1. 先看非空闲的 Lanes 中,有没有未被 Suspense 挂起的
  // 2. 如果都被挂起了,看有没有被 ping(Promise resolved)的
  // 3. 最后才考虑空闲优先级的 Lanes
  // 在每一层中,都取最高优先级的 Lane(最低位的 1)

  const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
  if (nonIdlePendingLanes !== NoLanes) {
    const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes;
    if (nonIdleUnblockedLanes !== NoLanes) {
      return getHighestPriorityLanes(nonIdleUnblockedLanes);
    }
    const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes;
    if (nonIdlePingedLanes !== NoLanes) {
      return getHighestPriorityLanes(nonIdlePingedLanes);
    }
  }

  return getHighestPriorityLanes(pendingLanes & ~suspendedLanes);
}

深度洞察:Lane 模型的设计灵感来自高速公路的车道系统。在高速公路上,不同车道有不同的速度限制——快车道(SyncLane)只允许高速通行,慢车道(TransitionLane)允许低速行驶。当快车道有车来时,慢车道上的车必须让路。React 的并发调度本质上就是这样一个车道管理系统:高优先级更新可以"超车"低优先级更新,而低优先级更新在等待太久后会被"提速"(过期机制),防止饥饿。

9.2 Transition 的实现机制

9.2.1 startTransition:标记低优先级更新

startTransition 是并发模式最核心的用户侧 API。它的作用看似简单——将一个状态更新标记为"非紧急":

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    // 紧急更新:立即更新输入框
    setQuery(e.target.value);

    // 非紧急更新:搜索结果可以延迟
    startTransition(() => {
      setResults(computeResults(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ResultsList results={results} />
    </div>
  );
}

但在源码层面,startTransition 的实现远比你想象的复杂。它涉及 Lane 的分配、Transition 上下文的管理,以及跨更新的状态一致性保证:

// packages/react/src/ReactStartTransition.js(React 19.0.0 语义,简化)
function startTransition(
  scope: () => void,
  options?: StartTransitionOptions
): void {
  const prevTransition = ReactSharedInternals.T;
  const currentTransition: BatchConfigTransition = {};
  ReactSharedInternals.T = currentTransition;

  if (__DEV__) {
    currentTransition._updatedFibers = new Set();
  }

  try {
    // 在 Transition 上下文中执行回调
    // 回调中的所有 setState 都会被分配 TransitionLane
    const returnValue = scope();

    // React 19:通过共享回调 S 把 async action 接到 reconciler
    // (renderer 侧挂上后,内部会走 entangleAsyncAction 等链路)
    const onStartTransitionFinish = ReactSharedInternals.S;
    if (onStartTransitionFinish !== null) {
      onStartTransitionFinish(currentTransition, returnValue);
    }
    if (
      typeof returnValue === 'object' &&
      returnValue !== null &&
      typeof returnValue.then === 'function'
    ) {
      // 防止未处理 rejection 泄漏到宿主
      returnValue.then(noop, reportGlobalError);
    }
  } finally {
    ReactSharedInternals.T = prevTransition;
  }
}

关键点在于 ReactSharedInternals.T。当这个值不为 null 时,所有通过 dispatchSetState 触发的更新都会被分配 TransitionLane,而不是默认的 SyncLane 或 DefaultLane:

// packages/react-reconciler/src/ReactFiberHooks.js
// dispatchSetState 只是「调用」requestUpdateLane,定义不在本文件
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A
): void {
  const lane = requestUpdateLane(fiber); // 从 ReactFiberWorkLoop 导入
  // ...
}

// packages/react-reconciler/src/ReactFiberWorkLoop.js(定义处,React 19.0.0)
export function requestUpdateLane(fiber: Fiber): Lane {
  // Legacy 模式 → SyncLane;render phase update → 当前 render lanes
  // ...

  // 检查是否在 Transition 上下文中(内部走 requestCurrentTransition → ReactSharedInternals.T)
  const transition = requestCurrentTransition();
  if (transition !== null) {
    const actionScopeLane = peekEntangledActionLane();
    return actionScopeLane !== NoLane
      ? actionScopeLane
      : requestTransitionLane(transition);
  }

  // 非 Transition:事件优先级 → Lane
  return eventPriorityToLane(resolveUpdatePriority());
}

9.2.2 Entangled Transitions:交织的一致性

Entangled transitions(交织的过渡)并不是新概念——transition lane 之间的纠缠(entangleTransitions/markRootEntangled)随 React 17.0 的 Lane 模型一并发布;React 18 启用并发渲染并建立事件优先级→Lane 映射后,这条链路才成为日常可感的路径。React 19 在此之上新增的是异步 Action 的纠缠entangleAsyncAction / peekEntangledActionLane)。两者解决的是同一类问题:当多个 Transition 更新共享相关状态时,React 必须确保它们作为一个整体被提交,而不是部分提交导致 UI 处于不一致的中间状态。

// packages/react-reconciler/src/ReactFiberLane.js
export function entangleTransitions(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane
): void {
  const updateQueue = fiber.updateQueue;
  if (updateQueue === null) {
    return;
  }

  const sharedQueue = updateQueue.shared;
  if (isTransitionLane(lane)) {
    let queueLanes = sharedQueue.lanes;

    // 将当前 Lane 与队列中已有的 Lanes 纠缠在一起
    queueLanes = intersectLanes(queueLanes, root.pendingLanes);

    const newQueueLanes = mergeLanes(queueLanes, lane);
    sharedQueue.lanes = newQueueLanes;

    // 在 root 上标记这些 Lanes 是纠缠的
    markRootEntangled(root, newQueueLanes);
  }
}

export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes): void {
  // 在 root.entanglements 数组中,确保所有纠缠的 Lanes 互相引用
  // 遍历每个 Lane,如果它属于纠缠集合,就将整个纠缠集合加入它的关联列表
  root.entangledLanes |= entangledLanes;
  const entanglements = root.entanglements;
  let lanes = root.entangledLanes;
  while (lanes) {
    const index = pickArbitraryLaneIndex(lanes);
    const lane = 1 << index;
    if ((lane & entangledLanes) | (entanglements[index] & entangledLanes)) {
      entanglements[index] |= entangledLanes;
    }
    lanes &= ~lane;
  }
}

这段代码的效果是:如果 TransitionLane1 和 TransitionLane2 都修改了同一个组件的更新队列,它们就会被"纠缠"在一起。当 React 决定渲染 TransitionLane1 时,它必须同时处理 TransitionLane2,确保这两个更新要么都被提交,要么都不被提交。

深度洞察:Entangled transitions 的命名灵感来自量子力学中的量子纠缠。两个纠缠的粒子,无论相距多远,测量其中一个会立即影响另一个的状态。React 中的 entangled lanes 有着类似的语义:当两个 Lane 被纠缠后,它们就成为了不可分割的整体——你不能只处理其中一个而忽略另一个,因为这会导致 UI 状态的不一致。

9.2.3 Transition 的中断与重启

当一个 Transition 正在渲染的过程中,如果一个更高优先级的更新到来(比如用户点击了一个按钮),React 会中断当前的 Transition 渲染,优先处理紧急更新:

// packages/react-reconciler/src/ReactFiberWorkLoop.js
function ensureRootIsScheduled(root: FiberRoot): void {
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
  );

  const newCallbackPriority = getHighestPriorityLane(nextLanes);

  // 如果已有调度且优先级相同,复用
  if (root.callbackPriority === newCallbackPriority) return;

  // 有更高优先级的工作:取消当前调度
  if (root.callbackNode != null) {
    cancelCallback(root.callbackNode);
  }

  // v19 实际排程在 scheduleTaskForRootDuringMicrotask(ensureRootIsScheduled 只保证入表)
  if () {
    // Sync 工作走 microtask,**不再**映射到 Scheduler ImmediatePriority
    scheduleSyncCallback(/* performWorkOnRoot(..., forceSync) */ root);
    scheduleMicrotask(flushSyncCallbacks);
    root.callbackNode = null;
  } else {
    // lanesToEventPriority → Discrete/Continuous 都映射为 UserBlockingSchedulerPriority
    // (v19 无 lanesToSchedulerPriority;Sync 不进这条 Scheduler 路径)
    let schedulerPriorityLevel;
    switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }
    root.callbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performWorkOnRootViaSchedulerTask.bind(null, root),
    );
  }
  root.callbackPriority = newCallbackPriority;
}

当高优先级更新处理完毕后,React 会重新评估还有哪些 pending lanes 需要处理。被中断的 Transition 不会从中断的地方继续——它会从头开始重新渲染。这看似浪费,但实际上是正确的做法:因为高优先级更新可能已经改变了某些状态,从中断点继续可能产生不一致的结果。

入口命名:React 18 将同步与并发拆成 performSyncWorkOnRoot / performConcurrentWorkOnRoot 双入口;React 19 合并为 performWorkOnRoot(root, lanes, forceSync),由 forceSync 与 lane 集合共同决定是否时间切片。下面用 React 19 的合并入口表述:

// packages/react-reconciler/src/ReactFiberWorkLoop.js(React 19.0.0 语义)
function performWorkOnRoot(root: FiberRoot, lanes: Lanes, forceSync: boolean): void {
  // 每次进入都重新计算要处理的 Lanes(可能已被高优先级更新改变)
  // 三种情况下不允许时间切片,必须同步完成:
  // 1. forceSync 或包含阻塞 Lane(含 DefaultLane)
  // 2. 包含过期 Lane(等太久了)
  // 3. 非 prerender 路径下的同步约束
  const shouldTimeSlice =
    !forceSync &&
    !includesBlockingLane(lanes) &&
    !includesExpiredLane(root, lanes);

  const exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes, /* ... */);

  // ...一致性检查、finishConcurrentRender、ensureRootIsScheduled
}

这里有一个值得特别强调的事实,因为它是社区最常见的误解之一:DefaultLane 也属于阻塞 Lane,不做时间切片。也就是说,setTimeout、网络回调里的普通 setState(默认优先级更新)同样是一口气同步渲染完的。真正走 renderRootConcurrent、能够被切片和中断的,只有 Transition、Retry 等非阻塞 Lane。"用了 React 18/19 之后所有渲染都自动被切片"的说法并不成立——不把更新放进 startTransition(或由 Suspense 触发 Retry),时间切片根本不会发生。

9.3 Suspense 的挂起与恢复

下图展示了 Suspense 从组件挂起到恢复渲染的完整生命周期:

stateDiagram-v2
    [*] --> 正常渲染: beginWork
    正常渲染 --> 抛出Promise: 组件调用 use(promise)
    抛出Promise --> 查找Suspense边界: handleThrow
    查找Suspense边界 --> 显示Fallback: 渲染 fallback UI
    显示Fallback --> 监听Promise: attachPingListener
    监听Promise --> Promise完成: promise.then(ping)
    Promise完成 --> 调度重渲染: scheduleUpdateOnFiber
    调度重渲染 --> 正常渲染: 重新渲染挂起的子树
    正常渲染 --> 显示内容: 渲染成功
    显示内容 --> [*]

9.3.1 Suspense 的核心协议:throw Promise

Suspense 是 React 并发模式中最具创新性的特性之一。它的实现基于一个看起来有些"反常规"的机制:组件通过抛出一个 Promise 来告诉 React 自己还没准备好

// Suspense 的基本模式
function DataComponent() {
  // 如果数据还没准备好,use 会抛出一个 Promise
  const data = use(dataPromise);
  return <div>{data.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <DataComponent />
    </Suspense>
  );
}

当一个组件抛出 Promise 时,React 需要:

  1. 找到最近的 Suspense 边界
  2. 显示 fallback UI
  3. 监听 Promise 的完成
  4. Promise resolve 后,重新渲染被挂起的子树

让我们深入源码来理解这个过程:

// packages/react-reconciler/src/ReactFiberWorkLoop.js
function handleThrow(root: FiberRoot, thrownValue: mixed): void {
  // 重置当前渲染状态
  resetHooksAfterThrow();

  if (thrownValue === SuspenseException) {
    // Suspense 场景:组件抛出了一个 thenable
    thrownValue = getSuspendedThenable();
    workInProgressSuspendedReason =
      shouldRemainOnPreviousScreen() &&
      !includesNonIdleWork(workInProgressRootSkippedLanes) &&
      !includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
        ? SuspendedOnData
        : SuspendedOnImmediate;
  } else {
    // 普通错误
    const isWakeable =
      thrownValue !== null &&
      typeof thrownValue === 'object' &&
      typeof thrownValue.then === 'function';

    workInProgressSuspendedReason = isWakeable
      ? SuspendedOnDeprecatedThrowPromise
      : SuspendedOnError;
  }

  workInProgressThrownValue = thrownValue;
}

9.3.2 Suspense Fiber 的双面结构

Suspense 组件在 Fiber 树中有一个特殊的结构。它有两个可能的子树:主内容(primary children)和回退内容(fallback children)。React 使用一个名为 OffscreenComponent 的特殊 Fiber 类型来管理这个切换:

// packages/react-reconciler/src/ReactFiberBeginWork.js
function updateSuspenseComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  const nextProps = workInProgress.pendingProps;

  let showFallback = false;
  const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;

  if (didSuspend) {
    showFallback = true;
    // 清除 DidCapture 标记
    workInProgress.flags &= ~DidCapture;
  }

  const nextPrimaryChildren = nextProps.children;
  const nextFallbackChildren = nextProps.fallback;

  if (current === null) {
    // 首次挂载
    if (showFallback) {
      // 挂起状态:渲染 fallback
      return mountSuspenseFallbackChildren(
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes
      );
    } else {
      // 正常状态:渲染主内容
      return mountSuspensePrimaryChildren(
        workInProgress,
        nextPrimaryChildren,
        renderLanes
      );
    }
  } else {
    // 更新
    if (showFallback) {
      return updateSuspenseFallbackChildren(
        current,
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes
      );
    } else {
      return updateSuspensePrimaryChildren(
        current,
        workInProgress,
        nextPrimaryChildren,
        renderLanes
      );
    }
  }
}

当显示 fallback 时,主内容不会被完全卸载——它被包裹在一个 mode: 'hidden' 的 Offscreen Fiber 中:

function mountSuspenseFallbackChildren(
  workInProgress: Fiber,
  primaryChildren: ReactNodeList,
  fallbackChildren: ReactNodeList,
  renderLanes: Lanes
): Fiber {
  const mode = workInProgress.mode;

  // 主内容包裹在 Offscreen 组件中,标记为隐藏
  const primaryChildFragment = mountWorkInProgressOffscreenFiber(
    { mode: 'hidden', children: primaryChildren },
    mode,
    NoLanes, // 不在当前渲染中处理主内容
  );

  // Fallback 正常渲染
  const fallbackChildFragment = createFiberFromFragment(
    fallbackChildren,
    mode,
    renderLanes,
    null
  );

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

  return fallbackChildFragment;
}

这个设计使得当 Promise resolve 后,React 可以快速切换回主内容,因为主内容的 Fiber 结构一直保留在内存中。

9.3.3 Promise 解析后的恢复流程

当被挂起的 Promise resolve 后,React 需要触发一次重新渲染。这个过程通过 ping 机制实现。在第 8 章中我们已经看到 trackUsedThenable 如何追踪 Promise 状态并在 pending 时抛出 SuspenseException。挂起后,React 在 throwException 中找到最近的 Suspense 边界,并通过 attachPingListener 注册回调:

// packages/react-reconciler/src/ReactFiberThrow.js
function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
  rootRenderLanes: Lanes
): void {
  sourceFiber.flags |= Incomplete;

  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    const wakeable: Wakeable = (value: any);
    const suspenseBoundary = getSuspenseHandler();
    if (suspenseBoundary !== null) {
      suspenseBoundary.flags &= ~ForceClientRender;
      markSuspenseBoundaryShouldCapture(
        suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes
      );
      // 注册 ping 监听器
      attachPingListener(root, wakeable, rootRenderLanes);
    }
  }
}

function attachPingListener(
  root: FiberRoot,
  wakeable: Wakeable,
  lanes: Lanes
): void {
  let pingCache = root.pingCache;

  let threadIDs;
  if (pingCache === null) {
    pingCache = root.pingCache = new WeakMap();
    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)) {
    threadIDs.add(lanes);

    // 当 Promise resolve 时,ping root 触发重新渲染
    const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
    wakeable.then(ping, ping);
  }
}

function pingSuspendedRoot(
  root: FiberRoot,
  wakeable: Wakeable,
  pingedLanes: Lanes
): void {
  const pingCache = root.pingCache;
  if (pingCache !== null) {
    // 清除缓存,因为 Promise 已经 resolve
    pingCache.delete(wakeable);
  }

  // 标记这些 Lanes 已被 ping
  markRootPinged(root, pingedLanes);

  // 触发重新调度
  ensureRootIsScheduled(root);
}

整个流程形成了一个闭环:组件抛出 Promise → React 显示 fallback 并监听 Promise → Promise resolve → ping root → 重新渲染 → 组件正常返回数据 → 显示主内容。

9.3.4 Suspense 在 Transition 中的特殊行为

Suspense 在 Transition 上下文中有一个至关重要的行为差异:它不会立即显示 fallback

function App() {
  const [tab, setTab] = useState('home');

  function switchTab(nextTab: string) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <nav>
        <button onClick={() => switchTab('home')}>Home</button>
        <button onClick={() => switchTab('posts')}>Posts</button>
      </nav>
      <Suspense fallback={<Spinner />}>
        {tab === 'home' ? <Home /> : <Posts />}
      </Suspense>
    </div>
  );
}

当用户点击 "Posts" 时,如果 Posts 组件触发了 Suspense(例如通过 use 加载数据),在 Transition 中 React 不会立即切换到 <Spinner />,而是继续显示当前的 <Home /> 组件,直到新内容准备好。

这个行为的实现在 shouldRemainOnPreviousScreen 中:

// packages/react-reconciler/src/ReactFiberWorkLoop.js(React 19.0.0)
function shouldRemainOnPreviousScreen(): boolean {
  const handler = getSuspenseHandler();
  if (handler === null) {
    // 没有任何 Suspense 边界能提供 fallback —— 只能留在旧画面
    return true;
  }

  if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
    if (getShellBoundary() === null) {
      // 正在 shell 内渲染:激活最近 fallback 会让已可见内容消失
      // 因此挂起 transition、保留之前的画面
      return true;
    } else {
      // 渲染的是先前画面之外的新内容:尽快展示 fallback 更好
      return false;
    }
  }

  if (
    includesOnlyRetries(workInProgressRootRenderLanes) ||
    includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
  ) {
    // Retry / Offscreen:仅当最近 Suspense 就是 shell 边界时才可继续挂起
    return handler === getShellBoundary();
  }

  // 其余 Lane:不要为了等数据而停在旧画面
  return false;
}

深度洞察:这个设计体现了 React 并发模式的核心哲学——在后台准备新 UI,在前台保持旧 UI 的响应性。这类似于现代操作系统中的双缓冲渲染:图形系统在后台缓冲区绘制下一帧画面,只有当新画面完全准备好后,才通过一次原子性的交换操作将其显示给用户。用户永远看不到绘制了一半的画面。React 的 Suspense + Transition 实现了同样的效果:用户永远看不到"加载了一半"的 UI。

9.4 Selective Hydration:服务端渲染的并发策略

9.4.1 传统 Hydration 的瓶颈

服务端渲染(SSR)中的 Hydration 是一个将服务端生成的静态 HTML "激活"为可交互 React 应用的过程。在传统的同步 Hydration 中,整棵组件树必须一次性完成注水:

// 传统 SSR 的问题
// 服务端返回的 HTML 已经包含了完整的页面结构
// 但在 hydrate 完成之前,所有组件都是"死的"——点击按钮没有反应

// 如果页面有一个很大的评论区组件,hydration 会被它阻塞
function Page() {
  return (
    <Layout>
      <NavBar />          {/* 简单组件,快速 hydrate */}
      <Hero />            {/* 简单组件,快速 hydrate */}
      <Comments />        {/* 复杂组件,hydrate 耗时 500ms */}
      <Footer />          {/* 简单组件,但必须等 Comments 完成 */}
    </Layout>
  );
}

在同步 Hydration 中,即使 NavBarHero 只需要 10ms 就能 hydrate,用户也必须等到 Comments 的 500ms 完成后,才能与页面上的任何部分交互。

9.4.2 Selective Hydration 的工作原理

React 18 引入的 Selective Hydration 解决了这个问题。通过将 Suspense 边界与 Hydration 结合,React 可以分段注水

function Page() {
  return (
    <Layout>
      <NavBar />
      <Hero />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
      <Footer />
    </Layout>
  );
}

CommentsSuspense 包裹时,React 不需要等待它的 JavaScript 代码加载完成就可以开始 Hydration。其余的页面会先被 hydrate,Comments 区域会在其代码准备好后单独 hydrate。

在 Fiber 架构中,Selective Hydration 通过特殊的 DehydratedFragment 来实现:

// packages/react-reconciler/src/ReactFiberHydrationContext.js
function tryHydrateSuspense(fiber: Fiber, nextInstance: any): boolean {
  const suspenseInstance = canHydrateSuspenseInstance(nextInstance);
  if (suspenseInstance !== null) {
    // 在 Fiber 上存储 dehydrated 状态
    // retryLane 设为 OffscreenLane:这是最低优先级,
    // 意味着其他更紧急的 hydration 可以插队
    fiber.memoizedState = {
      dehydrated: suspenseInstance,
      treeContext: getSuspenseTreeContext(),
      retryLane: OffscreenLane,
    };

    // 创建一个 dehydrated fragment 作为占位 fiber
    const dehydratedFragment = createFiberFromDehydratedFragment(suspenseInstance);
    dehydratedFragment.return = fiber;
    fiber.child = dehydratedFragment;
    return true;
  }
  return false;
}

9.4.3 用户交互驱动的优先级提升

Selective Hydration 最令人印象深刻的特性是:用户的交互可以提升 Hydration 的优先级。如果用户点击了一个尚未 hydrate 的 Suspense 边界,React 会立即提升该区域的 Hydration 优先级:

// packages/react-reconciler/src/ReactFiberReconciler.js(非 ReactDOMEventListener)
function attemptSynchronousHydration(fiber: Fiber): void {
  switch (fiber.tag) {
    case HostRoot: {
      const root: FiberRoot = fiber.stateNode;
      if (isRootDehydrated(root)) {
        const lanes = getHighestPriorityPendingLanes(root);
        flushRoot(root, lanes);
      }
      break;
    }
    case SuspenseComponent: {
      // 将 Suspense 边界的 hydration 提升到 SyncLane
      const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
      if (root !== null) {
        scheduleUpdateOnFiber(root, fiber, SyncLane);
      }
      // 随后同步 flush,立即完成注水
      break;
    }
  }
}

这个设计意味着在一个包含多个 Suspense 边界的页面中,用户交互会自然地引导 React 优先 hydrate 用户正在交互的区域。如果用户先点击了导航栏,导航栏就先被 hydrate;如果用户先滚动到评论区并尝试点击,评论区就先被 hydrate。

需要澄清一个常见的过时认知:早期的实验版本确实把 click 等离散事件放入队列、等 hydration 完成后重放,但 React 18 正式版已经移除了这一机制(PR #22448 "Don't replay discrete events")。现在离散事件到来时,React 做的是上面 attemptSynchronousHydration 展示的事情——同步提升目标 Suspense 边界的 hydration 优先级、立即完成注水,然后事件沿正常路径派发,不存在"存起来再放一遍"。保留重放的只有 mouseover、pointermove 这类连续事件:它们语义上只关心"最新状态",排队重放最后一个即可:

// packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js
export function queueIfContinuousEvent(blockedOn, domEventName, eventSystemFlags, targetContainer, nativeEvent) {
  switch (domEventName) {
    case 'focusin':
    case 'dragenter':
    case 'mouseover':
    case 'pointerover':
      // ... 只为连续事件排队,且同类事件只保留最新的一个
      // hydration 完成后由 replayUnblockedEvents 重放
      return true;
  }
  return false;
}

为什么区别对待?离散事件(click)每一次都承载独立的用户意图,与其冒着乱序、重复派发的风险去重放,不如同步把目标区域 hydrate 完再正常派发;连续事件天然幂等,丢弃中间态、只重放最后一个既安全又便宜。

9.5 并发模式下的状态一致性保证

9.5.1 Tearing:状态撕裂问题

并发渲染引入了一个在同步渲染中不存在的问题——tearing(状态撕裂)。当一个渲染被中断时,外部状态可能在中断期间发生变化,导致同一次渲染中不同组件看到不同版本的状态:

// 一个外部 store
let externalCounter = 0;

function ComponentA() {
  // 读取外部状态
  const value = externalCounter; // 假设此时是 0
  return <div>A: {value}</div>;
}

function ComponentB() {
  // 在 A 渲染后、B 渲染前,如果渲染被中断
  // 外部代码将 externalCounter 改为了 1
  const value = externalCounter; // 现在是 1
  return <div>B: {value}</div>;
}

// 结果:A 显示 0,B 显示 1——这就是 tearing
// 用户看到的是一个不一致的 UI

Tearing 只在并发渲染中出现,因为同步渲染不会被中断,所有组件在同一个微任务中读取外部状态,看到的一定是同一个值。

9.5.2 useSyncExternalStore:解决方案

React 18 引入了 useSyncExternalStore 来解决 tearing 问题。它的核心策略是:在并发渲染中检测外部状态是否发生了变化,如果发生了变化,强制降级为同步渲染

// packages/react-reconciler/src/ReactFiberHooks.js
function mountSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T
): T {
  const fiber = currentlyRenderingFiber;
  const hook = mountWorkInProgressHook();
  const nextSnapshot = getSnapshot();

  if (!includesBlockingLane(getWorkInProgressRoot(), renderLanes)) {
    // 并发渲染中:注册一致性检查
    // 在渲染完成后,React 会重新调用 getSnapshot()
    // 如果值发生了变化,说明渲染过程中外部状态被修改了
    pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
  }

  hook.memoizedState = nextSnapshot;

  // 订阅外部 store:当 store 变化时触发重新渲染
  mountEffect(subscribeToStore.bind(null, fiber, hook.queue, subscribe), [subscribe]);

  return nextSnapshot;
}

一致性检查发生在渲染完成、提交之前。React 在每个使用了 useSyncExternalStore 的 Fiber 上标记 StoreConsistency flag,并记录渲染时的快照值。当并发渲染完成后,React 会重新调用 getSnapshot() 并与渲染时记录的值比较:

// 渲染完成后的一致性检查
function renderRootConcurrent(root: FiberRoot, lanes: Lanes): RootExitStatus {
  // ... 渲染完成后 ...

  if (workInProgressRootExitStatus === RootCompleted) {
    // 遍历所有标记了 StoreConsistency 的 Fiber
    // 重新调用 getSnapshot() 并与渲染时的值比较
    const storeConsistencyErrors = checkStoreConsistency();
    if (storeConsistencyErrors) {
      // 检测到不一致:丢弃并发渲染结果
      // 切换为同步渲染,从头开始
      exitStatus = renderRootSync(root, lanes);
    }
  }

  return exitStatus;
}

9.5.3 React 内部状态的一致性保证

对于 React 自身管理的状态(useStateuseReducer),tearing 问题并不存在。这是因为 React 的状态更新被存储在 Fiber 的更新队列中,而不是外部变量中。渲染过程中,状态的计算是基于更新队列的,而更新队列在渲染开始时就被"快照"了:

function processUpdateQueue<State>(
  workInProgress: Fiber,
  props: any,
  instance: any,
  renderLanes: Lanes
): void {
  const queue: UpdateQueue<State> = (workInProgress.updateQueue: any);

  // 将 shared.pending 中的更新转移到 baseUpdate 链表
  let pendingQueue = queue.shared.pending;
  if (pendingQueue !== null) {
    queue.shared.pending = null;
    // 拼接到 baseUpdate 链表末尾...
  }

  let newState = queue.baseState;
  let update = queue.firstBaseUpdate;

  do {
    const updateLane = update.lane;

    if (!isSubsetOfLanes(renderLanes, updateLane)) {
      // 优先级不够,跳过这个更新
      // 但保留到 baseUpdate 链表中,下次渲染时处理
      // 关键:记录此刻的 state 作为 newBaseState
      // ...
    } else {
      // 这个更新需要处理
      if (newLastBaseUpdate !== null) {
        // 如果之前有被跳过的更新,后续所有更新都必须保留
        // 标记 lane 为 NoLane,确保下次一定被处理
        const clone = { ...update, lane: NoLane, next: null };
        newLastBaseUpdate = newLastBaseUpdate.next = clone;
      }

      newState = getStateFromUpdate(workInProgress, queue, update, newState, props, instance);
    }

    update = update.next;
  } while (update !== null);

  workInProgress.memoizedState = newState;
}

深度洞察processUpdateQueue 中有一个非常微妙的设计——当一个低优先级更新被跳过时,它之后的所有更新(即使是高优先级的)都会被保留到 baseUpdate 链表中,以便下次低优先级渲染时重新计算。这确保了状态的最终一致性。想象一个场景:状态 0 → +1(低优先级)→ ×2(高优先级)。如果高优先级渲染只处理 ×2,结果是 0。但正确的最终结果应该是 (0+1)×2 = 2。通过保留跳过更新之后的所有更新,React 确保在低优先级渲染中能够重新计算出正确的结果。

9.5.4 并发渲染中的副作用安全

并发渲染还带来了一个重要的约束:渲染阶段不能有副作用。因为渲染可能被中断、可能被丢弃、可能被执行多次,如果渲染阶段包含副作用(如 DOM 操作、网络请求),就会导致不可预测的行为:

// ❌ 危险:渲染阶段的副作用——可能在并发渲染中被执行多次
function BadComponent() {
  fetch('/api/track-view');       // 网络请求可能发送多次
  document.title = 'New Title';  // 渲染被丢弃后留下错误状态
  return <div>Content</div>;
}

// ✅ 安全:副作用放在 effect 中——只在 commit 后执行一次
function GoodComponent() {
  useEffect(() => {
    fetch('/api/track-view');
    document.title = 'New Title';
  }, []);
  return <div>Content</div>;
}

React 的 StrictMode 通过在开发模式下双重调用渲染函数来帮助检测这类问题。在 renderWithHooks 中,当 shouldDoubleRenderDEVtrue 时,组件函数会被调用两次——如果渲染函数包含副作用,两次调用会暴露问题(如网络请求被发送两次)。

9.6 并发特性的协同运作

9.6.1 一个完整的并发渲染场景

让我们通过一个完整的例子来理解所有并发特性如何协同工作:

function App() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  function switchTab(nextTab: string) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <TabBar
        currentTab={tab}
        isPending={isPending}
        onSwitch={switchTab}
      />
      <Suspense fallback={<PageSkeleton />}>
        <TabContent tab={tab} />
      </Suspense>
    </div>
  );
}

function TabContent({ tab }: { tab: string }) {
  const data = use(fetchTabData(tab)); // 可能触发 Suspense
  return <div>{data.content}</div>;
}

当用户从 "home" 切换到 "posts" 时,发生的完整序列是:

  1. 事件触发switchTab('posts') 被调用
  2. isPending 更新先行入队useTransitionstartTransition 首先以当前(较高)优先级派发 isPending = true 的更新——它不属于 Transition,因此会先于 transition 渲染被同步呈现,加载指示器立刻出现
  3. Transition 上下文建立:随后设置 ReactSharedInternals.T
  4. 低优先级更新入队setTab('posts') 被分配 TransitionLane
  5. 调度开始ensureRootIsScheduled 安排一个并发任务
  6. 并发渲染开始renderRootConcurrent 启动,使用 workLoopConcurrent
  7. Suspense 触发TabContent 中的 use(fetchTabData('posts')) 抛出 Promise
  8. 保留旧画面:因为在 Transition 中,React 决定保留 "home" 的 UI(此时 isPending 早已为 true)
  9. Promise 注册attachPingListener 监听 Promise 的 resolve
  10. 数据返回:Promise resolve,pingSuspendedRoot 触发重新渲染
  11. 最终渲染:React 完成渲染,切换到 "posts" 的 UI
  12. isPending 变为 false:加载指示器消失

9.6.2 时间切片的实际效果

让我们量化并发渲染带来的用户体验改善。假设一个列表组件渲染 1000 个子项需要 100ms:

同步渲染:
[===== 100ms 渲染 =====][浏览器绘制]
                          ↑ 用户点击在这里才能被响应

并发渲染(5ms 时间切片):
[5ms][绘][5ms][绘][5ms][绘]...[5ms][绘]
     ↑       ↑       ↑
     每个间隙都能响应用户交互

虽然并发渲染的总耗时可能更长(因为有切换开销),但用户感知到的响应延迟从 100ms 降低到了最多 5ms。这就是并发模式的核心价值:牺牲吞吐量,换取响应性

9.6.3 过期机制:防止饥饿

如果高优先级更新持续不断地到来,低优先级的 Transition 是否会被永远推迟?React 通过过期机制解决了这个问题:

// packages/react-reconciler/src/ReactFiberLane.js
function markStarvedLanesAsExpired(root: FiberRoot, currentTime: number): void {
  const expirationTimes = root.expirationTimes;
  let lanes = root.pendingLanes & ~RetryLanes;

  while (lanes > 0) {
    const index = pickArbitraryLaneIndex(lanes);
    const lane = 1 << index;

    if (expirationTimes[index] === NoTimestamp) {
      // 首次遇到:根据优先级设置过期时间
      // SyncLane/InputContinuousLane → 250ms
      // DefaultLane/TransitionLanes → 5000ms
      // RetryLanes → 永不过期
      expirationTimes[index] = computeExpirationTime(lane, currentTime);
    } else if (expirationTimes[index] <= currentTime) {
      // 已过期:标记为过期,下次渲染时必须同步处理
      root.expiredLanes |= lane;
    }

    lanes &= ~lane;
  }
}

一旦一个 Lane 过期,它会被加入 root.expiredLanes。在 getNextLanes 中,过期的 Lane 会被优先处理,而且会被标记为需要同步渲染(不能被再次中断):

// React 19:合并入口 performWorkOnRoot(18 时代对应 performConcurrentWorkOnRoot)
function performWorkOnRoot(root: FiberRoot, lanes: Lanes, forceSync: boolean) {
  const shouldTimeSlice =
    !forceSync &&
    !includesBlockingLane(lanes) && // 阻塞 Lane(含 DefaultLane)不切片
    !includesExpiredLane(root, lanes); // 过期的 Lane 不允许时间切片

  // 过期 / 强制同步的 Lane 会走同步渲染路径,确保尽快完成
  const exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes, /* ... */);
}

9.6.4 源码核对:并发闭环不是一个函数,而是一组边界

把本章的并发机制落到本地源码,可以看到它由四个边界共同完成,而不是由某个"并发开关"独立完成。

第一层是标记边界packages/react/src/ReactStartTransition.jsstartTransition 保存旧的 batch config,然后把当前 transition 写入 ReactSharedInternals.T,在 finally 中恢复旧值。这说明 startTransition 本身不调度、不渲染,它只是把接下来同步执行的更新包进一个 transition 上下文。真正的优先级分配发生在更新入队时,调用方会根据这个上下文请求 transition lane。

第二层是分配边界ReactFiberRootScheduler.jsrequestTransitionLane 会在同一次事件中缓存第一条 transition lane,所以一个事件处理器里连续发出的 transition 更新会共享稳定输入;ReactFiberLane.jsclaimNextTransitionLane 则在 transition lane 集合里轮转,直到用完后再回到起点。这解释了为什么 React 既能把同一次交互里的更新归为一类,又能让不同 transition 有机会被分开取消、重试和提交。

第三层是执行边界。React 19 的 performWorkOnRoot 根据 blocking lane、expired lane 和 forceSync 决定走 renderRootConcurrent 还是 renderRootSync;进入并发路径后,workLoopConcurrent 只在 !shouldYield() 时持续处理 performUnitOfWork。所以时间切片不是"每个组件固定跑几毫秒",而是 WorkLoop 每处理一个 Fiber 单元就重新询问 Scheduler 是否应该让出主线程。

第四层是一致性边界。外部 store 通过 useSyncExternalStore 参与并发时,在非 blocking lane 中登记一致性检查,并在提交前发现 snapshot 变化时强制重渲染。这一组代码说明 React 并发不是放任视图短暂不一致,而是在可以让步的地方让步,在会破坏一致性的地方退回同步路径。

因此,判断一个更新是否"并发友好",不能只看它是不是包在 startTransition 里。还要看它是否进入了 transition lane、是否被 expired lane 升级、render 阶段是否能在 shouldYield 处暂停、以及外部状态是否能提供稳定 snapshot。任何一环不满足,React 都可能选择同步完成,这不是并发失效,而是保持 UI 一致性的必要代价。

这个闭环也解释了一个常见误解:并发渲染不会让 JavaScript 变成多线程,也不会让一次重计算凭空变少。它改变的是工作可以被拆开、排序和放弃。一个昂贵列表如果写在同步输入更新里,React 为了保持输入反馈会尽快提交;同样的列表如果放入 transition,React 就能在浏览器需要响应点击、键盘或绘制时暂停,并在较空闲的时间继续。开发者要做的不是到处包 startTransition,而是把"用户立刻需要看到的状态"和"可以稍后稳定下来的派生视图"分开建模。

因此,评估并发改造时应先画出更新链路:事件从哪里进入,哪些 setState 属于即时反馈,哪些属于派生计算,哪些组件会读取外部 store,哪些 Suspense 边界可能抛出 thenable。这个链路越清楚,transition lane、过期机制和 store consistency check 的行为越容易预测;链路越混杂,React 就越可能为了正确性回到同步路径。

9.7 本章小结

并发模式是 React 架构中最深层的创新。它不是一个单一的特性,而是一个贯穿整个渲染引擎的设计范式,使得 Transition、Suspense、Selective Hydration 等上层特性成为可能。

关键要点:

  1. 并发 ≠ 多线程:React 的并发是在单线程上通过可中断渲染实现的,核心是 workLoopConcurrent 中的 shouldYield 检查
  2. Lane 模型是优先级引擎:31 位二进制数表示不同优先级,位运算使得集合操作极其高效
  3. Transition 将更新标记为可中断:在 startTransition 上下文中的 setState 会被分配低优先级的 TransitionLane
  4. Entangled transitions 保证一致性:共享更新队列的多个 Transition Lane 会被纠缠在一起,作为整体提交
  5. Suspense 通过 throw Promise 实现挂起:在 Transition 中,Suspense 保留旧画面而不是显示 fallback
  6. Selective Hydration 分段激活页面:用户交互可以动态提升 hydration 优先级
  7. useSyncExternalStore 解决 tearing:在检测到外部状态不一致时,强制回退为同步渲染
  8. 过期机制防止饥饿:长时间未被处理的低优先级 Lane 会被升级,确保最终得到执行

在下一章中,我们将进入 React Compiler——看编译器如何通过静态分析与 useMemoCache 自动完成记忆化,把"编译时承担更多责任"的理念落到管线细节。


思考题

  1. 为什么 React 选择在渲染被中断后从头开始重新渲染,而不是从中断点继续? 构造一个具体场景,说明如果从中断点继续会导致什么样的状态不一致问题。

  2. Transition Lane 有 15 条,为什么需要这么多? 考虑一个页面中有多个独立的 startTransition 同时进行的场景。如果所有 Transition 共享同一条 Lane,在取消和重新渲染时会出现什么问题?

  3. useSyncExternalStore 检测到 tearing 后会强制同步渲染。这意味着如果一个页面中大量组件都使用了外部 store,并发渲染的优势可能被完全抵消。 分析 Redux 和 Zustand 等状态管理库是如何在保持并发兼容性的同时最小化同步渲染回退的。

  4. Selective Hydration 中,如果用户在 hydration 完成前快速连续点击了三个不同的 Suspense 边界区域,React 会如何决定 hydration 的顺序?attemptSynchronousHydrationqueueIfContinuousEvent 的源码出发,追踪离散事件的同步优先级提升与连续事件排队重放的完整流程。