React 19.3.1 源码深度剖析-第 8 章 并发特性(Concurrent Features)(上)
本章回答的问题只有一个,但要把答案钉到源码行上: 当 React 说“并发渲染”时,它到底在并发什么?可中断是怎么实现的?中断之后怎么恢复?以及
startTransition/useDeferredValue/use这些 API,底层到底踩在哪几行代码上?阅读前置:第 3 章(Fiber 架构与双缓冲、Lane 优先级模型)、第 5 章(Scheduler 调度器与时间切片)。 后续章节:第 9 章(Suspense 与懒加载)是本章挂起机制的主要消费方。
8.1 动机与背景:并发要解决的三个具体问题
第 5 章讲过 Scheduler 提供“可中断的能力”。本章讲的是 reconciler 如何使用这个能力,以及把哪些能力暴露给开发者。
具体到场景,并发特性解决三类问题:
问题一:一次大更新阻塞用户输入。
假设你在一个长列表(5000 行)里做筛选,输入框每敲一个字符就触发一次重渲染,每次渲染要 80ms。同步模式下,用户敲下第三个字符时,前两次渲染还没跑完,输入框表现为“卡住不动”。并发的解法是:输入引发的更新是紧急的,筛选引发的更新是可中断的——后者跑到一半可以让出,先响应前者。
问题二:状态更新没有“紧急程度”的概念。
setState 就是 setState,React 不知道这个更新来自用户敲键盘,还是来自一个后台数据预取。Lane 模型给每个更新打上优先级标签(第 3 章),但还需要一层“开发者能表达意图”的 API。这就是 startTransition 和 useDeferredValue。
问题三:异步数据(Promise)无法自然融入同步渲染流程。
组件 render 到一半需要等一个 Promise,怎么办?React 的解法是“抛出一个 thenable,让上层边界捕获,等 resolve 后重试”。这个机制(第 9 章详述)的调度协调就在本章要讲的 renderRootConcurrent 挂起状态机里。
一句话概括本章的心智模型:
并发渲染 = 同步渲染的全部逻辑 + 一套“可中断、可恢复、可让出”的状态机。
renderRootConcurrent和renderRootSync共享同一套 beginWork/completeWork,差别只在于“循环怎么跑、遇到异常怎么处理、跑完之后怎么决定下一步”。
8.2 同步还是并发?shouldTimeSlice 的判定
一切的起点在 performWorkOnRoot 里的三行(ReactFiberWorkLoop.js:1168):
const shouldTimeSlice =
(!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes)) ||
// If we're prerendering, then we should use the concurrent work loop
// even if the lanes are synchronous, so that prerendering never blocks
// the main thread.
checkIfRootIsPrerendering(root, lanes);
let exitStatus: RootExitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes, true);
逐条拆解这个布尔表达式,它决定了 React 的整个执行姿态:
| 条件 | 含义 | 结果 |
|---|---|---|
!forceSync | 不是 flushSync 强制同步 | 为 false 时走同步 |
!includesBlockingLane(lanes) | 不含“阻塞型”lane(SyncLane / InputContinuousLane) | 含则走同步 |
!includesExpiredLane(root, lanes) | 没有 lane 已过期(饥饿保护) | 有则走同步 |
checkIfRootIsPrerendering(root, lanes) | 是否在做预渲染(SSR/Fizz 场景) | 是则强制并发,保证预渲染不阻塞主线程 |
四个条件的语义统一指向一件事:“能不能安全地暂停”。
SyncLane不可暂停:它代表“必须立刻在屏幕上体现的结果”(flushSync、离散事件的同步更新)。暂停它等于用户点了按钮没反应。- 过期 lane 不可暂停:它已经被饿太久,再暂停就永远排不上了。
- 预渲染必须暂停:SSR 时主线程还要服务真实用户,预渲染只是后台准备工作。
注意 renderRootSync(root, lanes, true) 第三个参数是 true,含义是“允许在同步渲染中处理 Suspense/错误边界”,但它依然不会让出主线程。
shouldTimeSlice 的结果还被记下来用于后续判断(ReactFiberWorkLoop.js:1183):
let renderWasConcurrent = shouldTimeSlice;
这个变量在函数末尾决定“渲染完成后要不要进入 commit 的并发路径”(比如是否延迟 passive effect)。
8.2.1 三个输入的真实来源
8.2 的表格给出了四个条件的语义。但要真正“证明”它,得先知道三个输入是谁传进来的、在什么时刻确定的。
输入一:forceSync。 它来自调度入口,有两条来源。并发入口 performWorkOnRootViaSchedulerTask(ReactFiberRootScheduler.js:513)在拿到 Scheduler 回调时这样算:
// ReactFiberRootScheduler.js:586-590
// Enter the work loop.
// TODO: We only check `didTimeout` defensively, to account for a Scheduler
// bug we're still investigating. Once the bug in Scheduler is fixed,
// we can remove this, since we track expiration ourselves.
const forceSync = !disableSchedulerTimeoutInWorkLoop && didTimeout;
performWorkOnRoot(root, lanes, forceSync);
didTimeout 是 Scheduler 传进来的“你这个任务已经超过它自己的过期时间”信号。另一个同步入口 performSyncWorkOnRoot 则永远传 true(ReactFiberRootScheduler.js:608-622):
// ReactFiberRootScheduler.js:608-622(节选)
function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes) {
// This is the entry point for synchronous tasks that don't go
// through Scheduler.
const didFlushPassiveEffects = flushPendingEffects();
if (didFlushPassiveEffects) {
// If passive effects were flushed, exit to the outer work loop in the root
// scheduler, so we can recompute the priority.
return null;
}
if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
syncNestedUpdateFlag();
}
const forceSync = true;
performWorkOnRoot(root, lanes, forceSync);
}
注意这里的 const forceSync = true 是硬编码的:只要走 performSyncWorkOnRoot(也就是 flushSync、离散事件结束时的同步冲刷、flushSyncWorkAcrossRoots_impl 里 includesSyncLane(nextLanes) 为真的分支,ReactFiberRootScheduler.js:232-240),shouldTimeSlice 的第一项必然为 false,直接走同步。disableSchedulerTimeoutInWorkLoop 在 OSS 默认是 false(shared/ReactFeatureFlags.js:36),所以 didTimeout 这条路径是活的。
输入二:lanes。 由 getNextLanes 算出,有两个调用点需要区分:
- 调度时
scheduleTaskForRootDuringMicrotask算一次(ReactFiberRootScheduler.js:406-417),只用来决定“要不要调度、以哪个优先级调度”。 - 真正进入 work loop 前
performWorkOnRootViaSchedulerTask又算一次(ReactFiberRootScheduler.js:571-579)。
源码留了一段注释解释为什么必须算两次(ReactFiberRootScheduler.js:561-570):
// ReactFiberRootScheduler.js:561-570
// Determine the next lanes to work on, using the fields stored on the root.
// TODO: We already called getNextLanes when we scheduled the callback; we
// should be able to avoid calling it again by stashing the result on the
// root object. However, because we always schedule the callback during
// a microtask (scheduleTaskForRootDuringMicrotask), it's possible that
// an update was scheduled earlier during this same browser task (and
// therefore before the microtasks have run). That's because Scheduler batches
// together multiple callbacks into a single browser macrotask, without
// yielding to microtasks in between. We should probably change this to align
// with the postTask behavior (and literally use postTask when
// it's available).
翻译一下:Scheduler 会把多个 callback 合并到同一个宏任务里、中间不让微任务插队,所以“调度时看到的 lane 集合”到“执行时”可能已经过时。这就是并发渲染里“优先级是动态的”最直接的证据——lane 不是一次算完就锁死的,进入 work loop 前的最后一刻还要重新确认。
输入三:root.expiredLanes。 由 markStarvedLanesAsExpired 在每次调度前扫描填充(ReactFiberRootScheduler.js:395-397):
// ReactFiberRootScheduler.js:395-397
// Check if any lanes are being starved by other work. If so, mark them as
// expired so we know to work on those next.
markStarvedLanesAsExpired(root, currentTime);
细节见 8.7.4。
8.2.2 条件一:!forceSync
forceSync 放在表达式最左边不是偶然,它语义上是最“硬”的一条:调用方已经明确要求不能时间切片。它有两个来源,分别对应两种“不许暂停”:
| 来源 | 场景 | 为什么不许暂停 |
|---|---|---|
performSyncWorkOnRoot 的 forceSync = true | flushSync、离散事件收尾、popstate 同步刷新 | 用户就在等这个结果,让出一次就意味着“点了按钮没反应” |
performWorkOnRootViaSchedulerTask 的 didTimeout | Scheduler 报告任务已超时 | 已经拖太久了,再让出会继续拖 |
注意它和 includesExpiredLane 的区别:forceSync 是“外部命令”,includesExpiredLane 是“React 自己算出来的饥饿保护”。两者最终都走 renderRootSync,但触发主体不同(一个是调用者,一个是 reconciler 自己的记账)。源码把它们分开写(ReactFiberWorkLoop.js:1168-1177)而不是合并,就是为了让这个区别在代码里可见。
8.2.3 条件二:!includesBlockingLane(lanes) 与它的“反直觉清单”
这是四个条件里最容易读错的一个。先把真实实现贴出来(ReactFiberLane.js:684-694):
export function includesBlockingLane(lanes: Lanes): boolean {
const SyncDefaultLanes =
SyncHydrationLane |
SyncLane |
InputContinuousHydrationLane |
InputContinuousLane |
DefaultHydrationLane |
DefaultLane |
GestureLane;
return (lanes & SyncDefaultLanes) !== NoLanes;
}
它叫 “blocking”,但成员有 7 个,而且包含 DefaultLane。这一点必须讲透,否则会得出“普通 setState 会被时间切片”的错误结论:
| lane | 位 | 为什么算 blocking |
|---|---|---|
SyncHydrationLane | bit 0 | 注水的同步控制流,暂停会让 hydration 游标错位 |
SyncLane | bit 1 | 离散事件 / flushSync 的“必须立刻上屏” |
InputContinuousHydrationLane | bit 2 | 同上,注水版 |
InputContinuousLane | bit 3 | 滚动、拖拽、mousemove 等连续事件 |
DefaultHydrationLane | bit 4 | 注水版 |
DefaultLane | bit 5 | 普通 setState(await 之后、网络回调、setTimeout 里) |
GestureLane | bit 6 | 手势动画,掉帧就是视觉错误 |
(lane 的位定义见 ReactFiberLane.js:46-59。)
结论一:在 React 19.3.1 中,只有 transition(TransitionUpdateLanes + TransitionDeferredLanes)、retry(RetryLanes)以及 idle 族 lane 才可能被时间切片。 普通 setState 拿到的是 DefaultLane,它虽然会被按 NormalPriority 交给 Scheduler(ReactFiberRootScheduler.js:481-498,lanesToEventPriority(DefaultLane) 返回 DefaultEventPriority,见 ReactEventPriorities.js:55-67),但一旦进了 performWorkOnRoot,includesBlockingLane 为真、shouldTimeSlice 为假,就会走 renderRootSync——而 workLoopSync 里没有任何让出判断(ReactFiberWorkLoop.js:2772-2777):
// ReactFiberWorkLoop.js:2770-2777
// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
结论二:GestureLane 被划进 blocking 是 19.x 新加的。 它对应 enableGestureTransition 分支,说明“手势动画不允许被别的更新打断”,否则会出现视觉撕裂。之所以要显式列出,而不是靠数值大小,是因为它(bit 6)比 transition(bit 8+)数值更小、优先级更高——但“更高”不等于“更不可中断”,必须逐位列出。
反例验证:如果你在
setTimeout里连续setState渲染 5000 个节点(一次DefaultLane更新),主线程会被锁住不撒手。这不是 bug,是includesBlockingLane的既定行为。要拿到时间切片,必须startTransition或useDeferredValue。这条“只有 transition 才被时间切片”的事实,是理解 React 并发最容易被误导的地方。
8.2.4 条件三:!includesExpiredLane(root, lanes)
// ReactFiberLane.js:696-700
export function includesExpiredLane(root: FiberRoot, lanes: Lanes): boolean {
// This is a separate check from includesBlockingLane because a lane can
// expire after a render has already started.
return (lanes & root.expiredLanes) !== NoLanes;
}
注释的第一句就是“为什么要独立成函数”的答案:includesBlockingLane 看的是静态的 lane 类别,在渲染开始前就能判定;includesExpiredLane 看的是动态的 root.expiredLanes,它可能在渲染进行到一半、被中断、下一轮 performWorkOnRoot 重新进来时刚刚被填上。
这解释了一个容易困惑的现象:一次 transition 渲染第一轮是并发的(被切成几段),第二轮却突然变成同步的。原因就是这几段之间主线程被别的任务占住太久,markStarvedLanesAsExpired 把这条 lane 标进了 root.expiredLanes,于是 shouldTimeSlice 翻转为 false。源码注释里那句 “a lane can expire after a render has already started” 说的就是这件事。
8.2.5 条件四:checkIfRootIsPrerendering(root, lanes)
// ReactFiberLane.js:412-425
export function checkIfRootIsPrerendering(
root: FiberRoot,
renderLanes: Lanes,
): boolean {
const pendingLanes = root.pendingLanes;
const suspendedLanes = root.suspendedLanes;
const pingedLanes = root.pingedLanes;
// Remove lanes that are suspended (but not pinged)
const unblockedLanes = pendingLanes & ~(suspendedLanes & ~pingedLanes);
// If there are no unsuspended or pinged lanes, that implies that we're
// performing a prerender.
return (unblockedLanes & renderLanes) === 0;
}
这个函数是 || 的右操作数,所以它是布尔或:前面三个条件只要有一个不满足(本来要同步),预渲染判断就负责把它“掰回”并发。判定依据只有一条:本次要渲染的这些 lane,是不是全都被挂起了(且没被 ping 过)。如果是,说明这次渲染不代表任何用户的真实等待(用户等待的那部分已经挂起、在等数据),那它就是纯粹的“预热/预渲染”,必须让出主线程。
checkIfRootIsPrerendering 在源码里有五处调用,各自语义不同,值得对照:
| 调用点 | 行号 | 作用 |
|---|---|---|
shouldTimeSlice 的 || 右侧 | ReactFiberWorkLoop.js:1177 | 预渲染强制走并发 |
prepareFreshStack 初始化 workInProgressRootIsPrerendering | ReactFiberWorkLoop.js:2262 | 把判定结果存进模块变量 |
| continuation 分支重新判定 | ReactFiberWorkLoop.js:2813 | 让出期间可能来了新数据 |
flushSyncWorkAcrossRoots_impl | ReactFiberRootScheduler.js:235 | 即便有 sync lane 也不立即 flush |
scheduleTaskForRootDuringMicrotask | ReactFiberRootScheduler.js:447 | 预渲染时不走同步快速路径 |
第三行是 8.9.3 里提过的那个细节的“为什么”:恢复被中断的渲染时,预渲染状态要重算,因为让出期间可能收到了新数据,把这个 root 从“纯预渲染”变成了“有真实工作在等”。
8.2.6 判定树的完整流程
flowchart TD
A["performWorkOnRoot(root, lanes, forceSync)"] --> B{"forceSync?"}
B -->|"true(flushSync / didTimeout)"| S["renderRootSync(root, lanes, true)"]
B -->|"false"| C{"includesBlockingLane(lanes)?"}
C -->|"是(Sync / InputContinuous / Default / Gesture / 各注水 lane)"| D{"checkIfRootIsPrerendering?"}
C -->|"否(Transition / Retry / Idle 族)"| E{"includesExpiredLane(root, lanes)?"}
E -->|"是(饥饿保护触发)"| S
E -->|"否"| D
D -->|"是(预渲染)"| K["renderRootConcurrent(root, lanes)"]
D -->|"否"| S
一句话总结这张图:React 默认是同步的,并发是“申请”来的。 只有当你明确用了 transition / retry 一类 lane,或者这是一次预渲染,才会拿到时间切片。这与很多人的直觉(“React 18 之后默认并发”)正好相反,也是本章后面所有“中断、恢复、纠缠”机器得以成立的前提——因为如果没有这条判定,那些机器有一大半根本不会被触发。
8.3 renderRootConcurrent 逐段拆解
renderRootConcurrent(ReactFiberWorkLoop.js:2779)是全章最核心的函数。它和 renderRootSync 共享 beginWork/completeWork,但外层多了一套状态机。
8.3.1 准备阶段:context 与“接着跑”
function renderRootConcurrent(root: FiberRoot, lanes: Lanes): RootExitStatus {
const prevExecutionContext = executionContext;
executionContext |= RenderContext;
const prevDispatcher = pushDispatcher(root.containerInfo);
const prevAsyncDispatcher = pushAsyncDispatcher();
// If the root or lanes have changed, throw out the existing stack
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
// ... DevTools updater tracking ...
workInProgressTransitions = getTransitionsForLanes(root, lanes);
resetRenderTimer();
prepareFreshStack(root, lanes);
} else {
// This is a continuation of an existing work-in-progress.
workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);
}
两个关键点:
workInProgressTransitions = getTransitionsForLanes(root, lanes)。这是useTransition能工作的关键:本次渲染涉及哪些 transition,从 root 上按 lane 查出来,供组件内的useTransition读取。第 8.6 节详述。- continuation 分支。如果
workInProgressRoot === root且 lane 也相同,说明这是上一次被中断的渲染的续跑——prepareFreshStack不会被调用,workInProgress全局变量还停在上次离开的地方,直接往下跑。这就是“中断后恢复”的全部秘密:不需要任何序列化/反序列化,因为状态全在模块级变量里。
flowchart TD
A["renderRootConcurrent(root, lanes)"] --> B{"workInProgressRoot === root<br/>且 lanes 相同?"}
B -->|"否(首次或 lane 变了)"| C["prepareFreshStack<br/>重建 WIP 树根<br/>workInProgress = root.child"]
B -->|"是(中断后续跑)"| D["保留 workInProgress<br/>直接从上次位置继续"]
C --> E["进入 work loop"]
D --> E
8.3.2 挂起状态机:resumeOrUnwind
renderRootConcurrent 的主体是一个 outer: do { try { ... } } while(true) 循环,循环体第一件事是检查 workInProgressSuspendedReason:
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
// The work loop is suspended. We need to either unwind the stack or
// replay the suspended component.
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
resumeOrUnwind: switch (workInProgressSuspendedReason) {
这个 switch 是 React 并发渲染的“心脏”。它有六个分支,对应六种挂起原因。全部列出来(ReactFiberWorkLoop.js:2830-2900+):
| 挂起原因 | 常量 | 处理方式 | 语义 |
|---|---|---|---|
SuspendedOnError | 渲染中抛了普通错误 | throwAndUnwindWorkLoop 然后继续正常 work loop | 向上找 Error Boundary |
SuspendedOnData | 组件抛了未 resolve 的 thenable | 若已 resolve → replaySuspendedUnitOfWork;否则 thenable.then(onResolution) 后 break outer | 等数据,回来后原地重放 |
SuspendedOnAction | Server Action / 异步 action 未完成 | 同上 | 等 action 完成 |
SuspendedOnImmediate | 刚刚挂起,数据可能已缓存 | 置为 SuspendedAndReadyToContinue 后 break outer(让出主线程一次) | 给浏览器一次 ping 的机会 |
SuspendedOnInstance | Suspense 实例(如资源加载)未就绪 | 置为 SuspendedOnInstanceAndReadyToContinue 后 break outer | 让出,等实例就绪 |
SuspendedAndReadyToContinue | 已就绪,可以继续 | 若 thenable 已 resolve → replaySuspendedUnitOfWork;否则 unwind 后继续 | 恢复渲染 |
其中 SuspendedOnData 分支值得逐行读(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.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
// The work loop is suspended on data. We should wait for it to
// resolve before continuing to render.
// TODO: Handle the case where the promise resolves synchronously.
// Usually this is handled when we instrument the promise to add
// a `status` field, but if the promise already has a status, we won't
// have added a listener until right here.
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;
}
这段代码的信息量极大:
isThenableResolved(thenable)是快路径。React 给“ React 创建的 thenable”(比如use()读的、React.cache返回的)打了status字段,所以 resolve 后能同步检测到,直接重放,不用等微任务。onResolution不直接恢复渲染,它只做两件事:把状态改成SuspendedAndReadyToContinue,然后ensureRootIsScheduled(root)。真正的恢复发生在下一次 Scheduler 回调renderRootConcurrent时——那时workInProgressSuspendedReason是SuspendedAndReadyToContinue,走最后一个分支。onResolution同时挂在 fulfill 和 reject 上(thenable.then(onResolution, onResolution))——Promise 失败也要恢复渲染,因为错误要被抛给 Error Boundary,不能永远挂起。break outer直接跳出整个 do-while,把控制权还给 Scheduler。这就是“渲染中途挂起”的实现。
8.3.3 正常 work loop 与退出
} else if (root === workInProgressRoot) {
// 正常路径:有活就接着跑
if (enableUpdaterTracking) { /* DevTools */ }
if (workInProgress !== null) {
// ... act 环境 / throttled scheduling 分支 ...
workLoopConcurrent(); // 或 workLoopSync / workLoopConcurrentByScheduler
}
} else {
// root 变了,本次渲染被废弃
}
} catch (thrownValue) {
// 捕获 thenable 或错误,进入挂起状态机
...
}
} while (true);
三种 work loop 变体(ReactFiberWorkLoop.js):
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent(nonIdle: boolean) {
// We yield every other "frame" when rendering Transition or Retries...
if (workInProgress !== null) {
const yieldAfter = now() + (nonIdle ? 25 : 5);
do {
performUnitOfWork(workInProgress);
} while (workInProgress !== null && now() < yieldAfter);
}
}
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
三个变体的差别就是“让出判据”:
| 变体 | 让出判据 | 用在哪 |
|---|---|---|
workLoopSync | 绝不让出 | renderRootSync;flushSync |
workLoopConcurrent | 自己算时间片:非空闲 25ms / 空闲 5ms | 无法使用 Scheduler 时的兜底(如 act 环境) |
workLoopConcurrentByScheduler | 问 Scheduler:shouldYield() | 默认并发路径,时间片由 Scheduler 决定(默认 5ms,见第 5 章) |
注意 workLoopConcurrent 的 25ms vs 5ms 差异:注释说“渲染 Transition 或 Retry 时每隔一帧才让出”。这是因为 Transition 本身已经是低优先级,没必要频繁让出;而 5ms 是给“默认 lane 的并发渲染”用的。
shouldYield() 是 Scheduler 的 shouldYieldToHost,它综合判断:已占用时间是否超过一帧(frameInterval = 5ms)、浏览器是否即将绘制(needsPaint)、当前任务是否已过期(过期不让出)。
8.4 渲染的六种结局:RootExitStatus
work loop 跑完(或被中断)后,会返回一个 RootExitStatus。定义在 ReactFiberWorkLoop.js:430-434:
const RootErrored = 2;
const RootSuspended = 3;
const RootSuspendedWithDelay = 4;
const RootSuspendedAtTheShell = 6;
const RootCompleted = 5;
这五个(外加 RootInProgress / RootFatalErrored)值决定了“接下来干什么”。处理它们的是 finishConcurrentRender(ReactFiberWorkLoop.js:1430 起的 switch)。
| 退出状态 | 含义 | finishConcurrentRender 的处理 |
|---|---|---|
RootCompleted | 整棵树渲染完成 | 直接 break,进入 completeRoot 提交 |
RootSuspended | 渲染被 Suspense 边界挂起 | break,走“提交 fallback 或节流重试”逻辑 |
RootSuspendedWithDelay | 挂起,且“再等一下可能就好了” | 若不只是 transitions/retries(即含同步或默认更新)→ 提交占位;否则 fallthrough 到 RootSuspendedAtTheShell |
RootSuspendedAtTheShell | 连最外层 shell 都没渲染出来 | markRootSuspended(...) 后直接 return,不提交任何东西,无限期等数据 |
RootErrored | 渲染出错 | 清空 recoverable errors,break,按错误路径提交 |
RootInProgress / RootFatalErrored | 不应该出现在这里 | throw new Error('Root did not complete. This is a bug in React.') |
RootSuspendedWithDelay 的判断特别能体现设计考量(ReactFiberWorkLoop.js:1435-1441):
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.
...
markRootSuspended(root, lanes, workInProgressDeferredLane, didAttemptEntireTree);
return;
}
翻译成产品行为:
- 如果一个更新是 transition(低优先级、可以等),挂起时 React 不会先闪一个 loading 占位,而是“无限期等待数据到齐再一次性渲染”。这就是
useTransition能做到“不出现加载态”的原因。 - 如果更新里混了非 transition 的 lane(同步或默认),则必须先提交一个占位(fallback),保证用户看到响应。
另一个精巧之处是 retry 节流(ReactFiberWorkLoop.js:1503-1535):
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) {
// ... markRootSuspended ...
// 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.
为什么要节流 retry? 假设有 20 个 Suspense 边界的数据在 100ms 内陆续 resolve,如果没有节流,用户会看到 loading 闪烁 20 次。加上 FALLBACK_THROTTLE_MS 后,React 会把这些 resolve 合并到一次提交里。这是“并发渲染改善用户体验”的一个非常具体的例证。
8.5 startTransition:把一次更新标记为“可以等”
8.5.1 API 与语义
startTransition(() => {
setTab('comments');
});
语义:这个回调里的所有更新,都被标记为 TransitionLane(低优先级),可以被更高优先级的更新打断,也可以在数据没到时不显示 loading 而无限期等待。
8.5.2 源码实现
startTransition 在 packages/react/src/ReactStartTransition.js,最终走到 reconciler 的 startTransition(ReactFiberWorkLoop.js)。它做两件事:
- 申请一条 transition lane:
claimNextTransitionUpdateLane()(ReactFiberLane.js:726)。React 准备了TransitionLane1到TransitionLane10共 10 条,轮转分配:
// ReactFiberLane.js(概念示意)
export function claimNextTransitionUpdateLane(): Lane {
// 在 TransitionLane1..10 之间循环左移
...
}
为什么要 10 条而不是 1 条?因为多个 transition 可能同时存在(比如一个页面里两个独立的 tab 切换)。如果只有一条 lane,第二个 transition 的更新会和第一个混在一起,导致“我以为我在等 A,其实 B 也被延迟了”。10 条 lane 让并发中的多个 transition 互不干扰,而 getHighestPriorityLanes 又可以把“同一优先级簇”的 lane 合并成一次渲染(第 3 章讲过)。
- 把当前 lane 设置到 React 的“当前更新优先级”上下文里。这样回调内的
setState会自动带上这条 lane。
8.5.3 transition 与 useTransition
useTransition 返回 [isPending, startTransition]。isPending 的实现很“骗人”——它就是一个普通的 state hook(ReactFiberHooks.js:3425):
// 概念示意(ReactFiberHooks.js mountTransition)
function mountTransition(): [boolean, (callback) => void] {
const [isPending, setPending] = mountStateImpl(false);
const start = startTransition.bind(null, setPending);
const hook = mountWorkInProgressHook();
hook.memoizedState = start;
return [isPending, start];
}
也就是说,startTransition 内部会先把 isPending 置 true,跑完回调后再置回 false。这两个 state 更新本身走的是默认或同步优先级(它们要立刻反映到 UI 上),而被包裹的业务更新走 transition lane。
React 19 还让 startTransition 支持异步 action:如果回调返回一个 Promise,React 会自动保持 isPending 为 true 直到 Promise settle(第 13 章详述)。这是通过 useThenable 实现的,与第 9 章的 Suspense 机制同源。
8.6 useDeferredValue:让一个值“慢半拍”
8.6.1 API 与语义
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
语义:deferredQuery 会在紧急更新(用户输入)完成后,才以低优先级更新到最新值。典型用法是“输入框立即响应,列表延迟渲染”。
8.6.2 源码实现
useDeferredValue 的实现比 startTransition 更“底层直接”——它直接操作 lane(ReactFiberHooks.js:3028 起)。
mountDeferredValueImpl 的关键逻辑:
// 概念示意(ReactFiberHooks.js mountDeferredValueImpl)
function mountDeferredValueImpl<T>(value: T, initialValue?: T): T {
let hook;
if (initialValue !== undefined) {
// 有初始值:先渲染 initialValue,再排一次 deferred 渲染
hook = mountStateImpl(initialValue);
requestDeferredLane(); // 申请一条 TransitionDeferredLane
} else {
hook = mountStateImpl(value);
...
}
return hook.memoizedState;
}
updateDeferredValueImpl 是核心(ReactFiberHooks.js:3056 起):
// 概念示意(ReactFiberHooks.js updateDeferredValueImpl)
function updateDeferredValueImpl<T>(value: T, initialValue?: T): T {
const hook = updateWorkInProgressHook();
const prevValue = hook.memoizedState;
if (is(value, prevValue)) {
return prevValue; // 值没变,直接复用
}
// 值变了:
if (includesOnlyLanes(currentRenderLanes, TransitionLanes)) {
// 当前正在渲染的是 transition lane(低优先级):
// 说明这次渲染就是“追赶上”的那一次,直接用新值
markWorkInProgressReceivedUpdate();
hook.memoizedState = value;
return value;
}
// 当前是紧急渲染:
// 保留旧值,但排一个 deferred lane 的后续渲染去追赶
requestDeferredLane();
return prevValue; // ← 这就是“慢半拍”
}
这段代码是“并发特性如何体现在日常 API 里”的最佳范本。注意 requestDeferredLane() 申请的是 TransitionDeferredLanes(第 3 章的 bit 18-21),它和 TransitionUpdateLanes(bit 8-17)是分开的:
| lane 组 | 用途 |
|---|---|
TransitionUpdateLanes(TransitionLane1..10) | startTransition 里的普通低优先级更新 |
TransitionDeferredLanes(TransitionLane11..14) | useDeferredValue 派生出的“追赶”渲染 |
分开的原因是:deferred 渲染必须能被紧急更新打断。如果两者共用 lane,一次紧急输入可能和一次 deferred 追赶被合并,就失去了“慢半拍”的意义。
8.6.3 useDeferredValue 与 Suspense 的关系
useDeferredValue 还有一个可选能力:如果新值会导致组件挂起(抛 thenable),React 可以回退到旧值继续渲染,避免闪 loading。这个能力依赖 workInProgressDeferredLane——finishConcurrentRender 里传给 markRootSuspended 的正是它(ReactFiberWorkLoop.js:1461)。
8.7 中断保护与饥饿:并发不会把低优先级饿死
可中断带来一个必须回答的问题:如果一个低优先级更新每次刚跑两步就被高优先级更新打断,它岂不是永远渲染不完?
React 用三层机制回答(前两层在第 3 章讲过,这里从“并发执行”角度复盘):
8.7.1 第一层:只有“严格更高优先级”才能打断
getNextLanes 的结尾有段中断保护(ReactFiberLane.js:337-357):
if (
wipLanes !== NoLanes &&
wipLanes !== nextLanes &&
(wipLanes & suspendedLanes) === NoLanes
) {
const nextLane = getHighestPriorityLane(nextLanes);
const wipLane = getHighestPriorityLane(wipLanes);
if (
nextLane >= wipLane ||
(nextLane === DefaultLane && (wipLanes & TransitionLanes) !== NoLanes)
) {
// Keep working on the existing in-progress tree. Do not interrupt.
return wipLanes;
}
}
两个推论:
- 新 lane 必须严格更紧急(数值更小)才能打断当前渲染。
DefaultLane不能打断TransitionLanes。这一条是刻意设计的:普通setState不应该抢占正在进行的过渡更新。否则用户输入触发的 transition 会永远被其他普通更新反复重启。
8.7.2 第二层:过期(expiration)强制同步
即使有中断保护,理论上仍可能被连续的高优先级更新饿死。所以每条 lane 都有“过期时间”(computeExpirationTime,ReactFiberLane.js:477):
// ReactFiberLane.js:581(markStarvedLanesAsExpired 内)
} else if (expirationTime <= currentTime) {
// This lane expired
root.expiredLanes |= lane;
}
一旦过期,performWorkOnRoot 的 shouldTimeSlice 判定里 !includesExpiredLane(root, lanes) 为 false,于是强制走 renderRootSync——不让出、不中断,一口气跑完。
| lane | 过期时长 |
|---|---|
| Sync / InputContinuous / Gesture | syncLaneExpirationMs |
| Default / Transition | transitionLaneExpirationMs |
| Retry / Idle / Offscreen / Deferred | 不过期 |
8.7.3 第三层:retry lane 的特殊地位
RetryLanes(bit 22-25)专门给 Suspense 重试用,源码注释明确说“必须保持时间切片以解开未缓存 promise”。它们的过期受 enableRetryLaneExpiration 开关控制,默认不过期——因为重试的时机由数据到达决定,不是由时间决定。
8.8 use 原语:并发时代的资源读取方式
React 19 引入 use,它把“读 Context”和“读 Promise”统一成一个入口(ReactFiberHooks.js:1159):
// 概念示意(ReactFiberHooks.js use)
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
if (typeof usable.then === 'function') {
return useThenable(thenable); // 读 Promise → 可挂起
} else if (usable.$$typeof === REACT_RECOVERABLE_TYPE) {
return undefined; // 可恢复错误,向下穿透
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
const context = usable;
return readContext(context); // 读 Context,与 useContext 等价
}
}
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}
use 与并发特性的连接点在 useThenable(ReactFiberHooks.js:1098):它把 Promise 的 status/value/reason 跟踪到 hook 上,如果 Promise 未 resolve,就抛出它——正是这个抛出,被 renderRootConcurrent 的 try/catch 捕获,进入 8.3.2 讲的挂起状态机。
所以:
- 第 9 章的 Suspense 是“捕获挂起 + 显示 fallback”的边界机制;
- 本章的挂起状态机是“如何在渲染中途停下来、之后怎么恢复”的调度机制;
use是“如何优雅地抛出一个 thenable”的读取原语。
三者合起来才是完整的并发数据流。
8.9 中断的保存与恢复到底存了什么
8.3.1 说过一句话:“不需要任何序列化/反序列化,因为状态全在模块级变量里。”这句话值得展开成完整的一节——因为它是整个并发架构里最容易被误解、又最“取巧”的设计。
8.9.1 先回答:为什么不需要序列化
要理解这一点,先想清楚“序列化”在别的系统里是为什么存在的:
- 多线程系统(如浏览器 Worker)需要把执行状态从线程 A 搬到线程 B,所以必须序列化调用栈;
- 可恢复的生成器(generator)需要把栈帧存下来,以便稍后恢复;
- 服务端渲染的流式恢复,需要把已渲染的 HTML 边界存下来。
React 的并发渲染一条都不占:它从头到尾跑在同一个主线程、同一个 JS 调用栈里(只是被切成多段),“中断”不是冻结栈帧,而是从 performWorkOnRootViaSchedulerTask 正常返回,让出事件循环。等下一次 Scheduler 回调进来时,重新从模块级变量里捡起状态继续跑。
三个事实支撑这个设计:
- JavaScript 是单线程的。不存在“渲染进行到一半,另一个线程要读状态”的问题。中断期间没有任何代码在动这些变量。
- Fiber 树本身就在堆上。
workInProgress指向的 Fiber 节点、它的return/child/sibling指针、memoizedState/pendingProps,全都是普通对象字段,中断后原样保留。栈上唯一需要保留的“进度”只是一个指针——当前干到哪个 Fiber 了。 - 双缓冲保证 current 树不被打断的渲染污染(第 3 章):
prepareFreshStack通过createWorkInProgress(root.current, null)把 current 树克隆一份作为 WIP 树,渲染只写 WIP。中断时 WIP 树留在堆上等下一次继续,current 树始终是上一帧的完整状态。
所以 React 的“序列化”本质上是零成本的:中断 = 函数返回,恢复 = 函数重入。这也是为什么 React 敢于对每个 Fiber 都做可中断渲染——序列化一份执行状态的开销是不可想象的,而保留一堆模块级变量的开销是零。
8.9.2 模块级变量清单:一次并发渲染的“栈外栈”
以下变量全部定义在 ReactFiberWorkLoop.js 的模块作用域里。它们合起来,就是一次被中断的并发渲染所需的全部“栈帧”。逐个说明(行号均为 ReactFiberWorkLoop.js):
// 439
let workInProgressRoot: FiberRoot | null = null;
// 441
let workInProgress: Fiber | null = null;
// 443
let workInProgressRootRenderLanes: Lanes = NoLanes;
| 变量 | 行号 | 作用 | 何时赋值 |
|---|---|---|---|
workInProgressRoot | :439 | 正在渲染哪个 root。renderRootConcurrent 的 continuation 判断第一项就是它 | prepareFreshStack(:2255) |
workInProgress | :441 | 当前干到哪个 Fiber。这是“执行进度”本身——恢复时从这里接着 beginWork | prepareFreshStack(:2257)、performUnitOfWork(:3121)、completeUnitOfWork、unwindUnitOfWork 里推进 |
workInProgressRootRenderLanes | :443 | 本次渲染处理哪些 lane。恢复时必须“lane 也相同”才算 continuation(:2787) | prepareFreshStack(:2258) |
// 446-455
const NotSuspended: SuspendedReason = 0;
const SuspendedOnError: SuspendedReason = 1;
const SuspendedOnData: SuspendedReason = 2;
const SuspendedOnImmediate: SuspendedReason = 3;
const SuspendedOnInstance: SuspendedReason = 4;
const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
const SuspendedAndReadyToContinue: SuspendedReason = 7;
const SuspendedOnHydration: SuspendedReason = 8;
const SuspendedOnAction: SuspendedReason = 9;
| 变量 | 行号 | 作用 |
|---|---|---|
workInProgressSuspendedReason | :461 | 挂起原因。0 表示没挂起;1~9 表示因何而挂(详见 8.10)。恢复时 renderRootConcurrent 的 resumeOrUnwind switch 就是读它决定“重放还是展开” |
workInProgressThrownValue | :462 | 被抛出的 thenable 或 error 本身。数据 resolve 后要 replaySuspendedUnitOfWork(unitOfWork),重放时还得靠这个 thenable 的 status 判断是否真的好了 |
// 467
let workInProgressRootDidSkipSuspendedSiblings: boolean = false;
// 471
let workInProgressRootIsPrerendering: boolean = false;
// 476
let workInProgressRootDidAttachPingListener: boolean = false;
| 变量 | 行号 | 作用 |
|---|---|---|
workInProgressRootDidSkipSuspendedSiblings | :467 | unwind 时是否跳过了兄弟子树。跳过就意味着要另排一次预渲染(prewarm),finishConcurrentRender 用它算 didAttemptEntireTree(:1457) |
workInProgressRootIsPrerendering | :471 | 本次渲染是不是“预渲染/预热”。它决定 unwind 时跳不跳过兄弟(throwAndUnwindWorkLoop:3289-3325),以及同步渲染中要不要让出主线程(:2701-2712) |
workInProgressRootDidAttachPingListener | :476 | 渲染过程中是否给 thenable 挂过 ping 监听。recoverFromConcurrentError 用它识别“未缓存 promise”场景(:1366) |
// 486
export let entangledRenderLanes: Lanes = NoLanes;
// 489
let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
| 变量 | 行号 | 作用 |
|---|---|---|
entangledRenderLanes | :486 | workInProgressRootRenderLanes 的“上下文版”超集:进入被隐藏的子树(HiddenContext)时把本该提交的 lane 加进来。begin/complete 阶段读它而不是 root render lanes |
workInProgressRootExitStatus | :489 | 渲染的实时结局。RootInProgress(0) → RootSuspended(3) / RootSuspendedWithDelay(4) / RootErrored(2) / RootCompleted(5)。它在渲染中途就可能被改写(renderDidSuspend:2546、renderDidSuspendDelayIfPossible:2552、renderDidError:2598),是“边跑边记的账” |
// 492-503
let workInProgressRootSkippedLanes: Lanes = NoLanes;
let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes;
let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes;
let workInProgressRootPingedLanes: Lanes = NoLanes;
let workInProgressDeferredLane: Lane = NoLane;
let workInProgressSuspendedRetryLanes: Lanes = NoLanes;
| 变量 | 行号 | 作用 |
|---|---|---|
workInProgressRootSkippedLanes | :492 | 渲染中因 bailout 而没处理的更新。renderDidSuspendDelayIfPossible 检查它决定是否立即 markRootSuspended(:2574-2587) |
workInProgressRootInterleavedUpdatedLanes | :494 | 渲染让出期间从外部事件插进来的更新。这是“并发”最直接的痕迹——没有时间切片就没有这个变量存在的意义 |
workInProgressRootRenderPhaseUpdatedLanes | :496 | 渲染阶段内部(render phase update)产生的更新 |
workInProgressRootPingedLanes | :498 | 渲染让出期间 ping 到的 lane。markRootSuspended 会把这些 lane 从“挂起集合”里剔除(:1815)——刚 ping 过的当然不能再算挂起 |
workInProgressDeferredLane | :500 | useDeferredValue 本次渲染派生出的 deferred lane。finishConcurrentRender 把它传给 markRootSuspended(:1461) |
workInProgressSuspendedRetryLanes | :503 | 本次渲染 spawn 出来、尚未 ping 的 retry lane。ping 到来时会清零(:5090-5092) |
// 505-510
let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null = null;
let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null = null;
| 变量 | 行号 | 作用 |
|---|---|---|
workInProgressRootConcurrentErrors | :505 | 渲染中收集的“硬错误”(没有边界能处理的走向)。RootErrored 时它非空 |
workInProgressRootRecoverableErrors | :509 | 被恢复的错误(如客户端渲染覆盖 SSR 错误)。提交时才 log,不让用户看到(:1466-1472) |
最后还有三个“杂项”:
| 变量 | 行号 | 作用 |
|---|---|---|
workInProgressRootDidIncludeRecursiveRenderUpdate | :513 | 渲染中是否又触发了更新(无限循环检测用,enableInfiniteRenderLoopDetection) |
workInProgressRootRenderTargetTime | :532 | 渲染目标时间线(CPU suspense 启发式用,RENDER_TIMEOUT_MS = 500,:535) |
workInProgressTransitions | :537 | 本次渲染涉及的 transition 对象数组,useTransition 的 isPending 回调链靠它(getWorkInProgressTransitions:538) |
workInProgressUpdateTask | :543 | 性能面板用的 ConsoleTask 句柄 |
关键点:上面每一个变量都会在 prepareFreshStack 里被显式重置(:2254-2273)。也就是说,“从头开始一次渲染” = 把这一整套模块级变量初始化一遍;“恢复一次被中断的渲染” = 一个都不重置,直接往下跑。这就是 8.3.1 里 workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes 判断的全部含义。
8.9.3 prepareFreshStack 与 continuation 的对比
// ReactFiberWorkLoop.js:2254-2273(prepareFreshStack 尾部,节选)
resetWorkInProgressStack();
workInProgressRoot = root;
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
workInProgressRootRenderLanes = lanes;
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
workInProgressRootDidSkipSuspendedSiblings = false;
workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);
workInProgressRootDidAttachPingListener = false;
workInProgressRootExitStatus = RootInProgress;
workInProgressRootSkippedLanes = NoLanes;
workInProgressRootInterleavedUpdatedLanes = NoLanes;
workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
workInProgressRootPingedLanes = NoLanes;
workInProgressDeferredLane = NoLane;
workInProgressSuspendedRetryLanes = NoLanes;
workInProgressRootConcurrentErrors = null;
workInProgressRootRecoverableErrors = null;
workInProgressRootDidIncludeRecursiveRenderUpdate = false;
对比表:
| 维度 | prepareFreshStack(新渲染) | continuation(中断后续跑) |
|---|---|---|
| 触发条件 | workInProgressRoot !== root 或 lane 变了(:2787) | root 和 lane 都相同(:2808) |
| WIP 树 | createWorkInProgress(root.current, null) 全新克隆 | 保留,节点上的 memoizedState 等半成品状态原样可用 |
workInProgress | 设为根节点 | 停在离开时的 Fiber 上 |
| 模块级变量 | 全部重置(上面 20 个) | 全部保留 |
workInProgressTransitions | 重新 getTransitionsForLanes(:2804) | 保留(不再查) |
| prerendering 标志 | 由 checkIfRootIsPrerendering 现算(:2262) | 在 continuation 分支里重新检查一次(:2813)——因为让出期间可能来了新数据 |
| 副作用 | 取消挂起的 timeout/cancelPendingCommit(:2238-2250) | 无 |
注意 continuation 分支里那行 workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes)(:2813)的注释很说明问题:“If we were previously in prerendering mode, check if we received any new data during an interleaved event.”——恢复时不只恢复“进度”,还重新评估“这次渲染还算不算预渲染”。
8.9.4 另一个方向的“恢复”:resetWorkInProgressStack
有保存就有丢弃。resetWorkInProgressStack(:1979-2002)是把“半成品”扔掉、回到 current 树的入口:
function resetWorkInProgressStack() {
if (workInProgress === null) return;
let interruptedWork;
if (workInProgressSuspendedReason === NotSuspended) {
// Normal case. Work-in-progress hasn't started yet. Unwind all
// its parents.
interruptedWork = workInProgress.return;
} else {
// Work-in-progress is in suspended state. Reset the work loop and unwind
// both the suspended fiber and all its parents.
resetSuspendedWorkLoopOnUnwind(workInProgress);
interruptedWork = workInProgress;
}
while (interruptedWork !== null) {
const current = interruptedWork.alternate;
unwindInterruptedWork(
current,
interruptedWork,
workInProgressRootRenderLanes,
);
interruptedWork = interruptedWork.return;
}
workInProgress = null;
}
它做的三件事:
- 区分“还没开始渲染”和“挂在半路”:前者只需要 unwind 父链(因为
workInProgress本身没被 begin 过);后者要先resetSuspendedWorkLoopOnUnwind(:2295,重置 hooks 的 re-render 状态、context 依赖、child reconciler 的 cursor),再连它自己一起 unwind。 - 逐级调用
unwindInterruptedWork(current, interruptedWork, lanes):这是“回滚”的核心——把 WIP 树上每个被打断的 Fiber 与它的 current 对照,恢复那些被 render 过程弄脏的状态。ReactFiberUnwindWork.js 负责各类 Fiber 的回滚语义。 workInProgress = null:进度归零。
调用点有三个,恰好对应三种“放弃当前渲染”的场景:
| 调用点 | 行号 | 场景 |
|---|---|---|
prepareFreshStack 开头 | :2254 | 新渲染开始,丢弃上一次中断的渲染(最常见) |
pingSuspendedRoot | :5060 | 挂起中收到同优先级 ping,强制从根重启(prepareFreshStack(root, NoLanes)) |
renderRootConcurrent 的 SuspendedOnHydration 分支 | :2995 | 选择性注水要打断当前渲染,切到 hydration lane |
8.9.5 一个思想实验:如果不用模块级变量
假设把这份状态做成显式的 RenderState 对象传来传去,会发生什么:每个 hook、每个 beginWork/completeWork 函数签名都要多带一个参数;performUnitOfWork(:3081)这种每 Fiber 调一次的热路径会多一次指针解引用;更麻烦的是,ReactFiberThrow/ReactFiberHooks/ReactFiberBeginWork 等十几个模块都要 import 它。React 的选择(模块级私有变量 + 少量 getter 导出,如 getWorkInProgressRoot:768、getWorkInProgressRootRenderLanes:776)是用“隐式全局”换“热路径零成本”与“模块间零耦合”。这是典型的务实工程折中:并发渲染在同一时刻全局只可能有一个(多 root 也只是轮流跑,performWorkOnRoot 开头就断言 (executionContext & (RenderContext | CommitContext)) !== NoContext,:1142),所以“全局单例”语义上完全正确。
8.10 renderRootConcurrent 的 catch 分支逐行讲
8.3.2 给出了挂起状态机的六个分支,但漏掉了两个“非主流”分支和 catch 本身的细节。本节补齐,并把 handleThrow(:2302)——catch 的唯一处理函数——完整拆开。
8.10.1 十种 SuspendedReason 的全家福
先看常量定义(ReactFiberWorkLoop.js:446-455),一共 10 个(NotSuspended 加 9 个原因):
const NotSuspended: SuspendedReason = 0;
const SuspendedOnError: SuspendedReason = 1;
const SuspendedOnData: SuspendedReason = 2;
const SuspendedOnImmediate: SuspendedReason = 3;
const SuspendedOnInstance: SuspendedReason = 4;
const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
const SuspendedAndReadyToContinue: SuspendedReason = 7;
const SuspendedOnHydration: SuspendedReason = 8;
const SuspendedOnAction: SuspendedReason = 9;
| 常量 | 谁设置的 | 语义 | renderRootConcurrent 的处理 |
|---|---|---|---|
NotSuspended | 各处重置 | 没挂起,正常跑 work loop | 不进 switch |
SuspendedOnError | handleThrow(:2367) | 普通 JS 错误 | throwAndUnwindWorkLoop(..., SuspendedOnError) 后继续(:2831-2842) |
SuspendedOnData | use 抛出 thenable(经 SuspenseException 路径,:2339 先置 SuspendedOnImmediate,resolve 回调后转 SuspendedAndReadyToContinue;直接抛 thenable 时由 useThenable/trackUsedThenable 路径置位) | 等数据 | 已 resolve → 重放;否则挂 .then 后 break outer(:2843-2876) |
SuspendedOnAction | useActionState 等(SuspenseActionException,:2325) | 等异步 action | 同 SuspendedOnData |
SuspendedOnImmediate | handleThrow(:2339),use() 抛 SuspenseException | “刚刚挂起,数据可能已在微任务里 resolve” | 转 SuspendedAndReadyToContinue 后 break outer,让出一次(:2877-2883) |
SuspendedOnInstance | handleThrow(:2342),SuspenseyCommitException | 等宿主资源(样式表/图片)就绪才能提交 | 转 SuspendedOnInstanceAndReadyToContinue 后 break outer(:2884-2888) |
SuspendedOnInstanceAndReadyToContinue | 上一行转来的 | 再给宿主资源一次机会 | 重试 preloadInstance,成功则原地续跑,失败 unwind(:2909-2975) |
SuspendedOnDeprecatedThrowPromise | handleThrow(:2364),legacy 代码直接 throw promise | 老的“抛 Promise”模式 | 永远 unwind,不 replay(:2976-2990),源码注释说明理由:replay 会导致无限 ping 循环 |
SuspendedAndReadyToContinue | onResolution 回调(:2867)或 SuspendedOnImmediate 转来 | 数据已就绪 | 已 resolve → replaySuspendedUnitOfWork;否则 unwind 后继续(:2889-2908) |
SuspendedOnHydration | handleThrow(:2353),SelectiveHydrationException | 选择性注水要打断当前渲染 | resetWorkInProgressStack + 置 RootSuspendedAtTheShell,break outer(:2991-2998) |
注意 8.3.2 的表只列了 6 个,漏了 SuspendedOnInstanceAndReadyToContinue、SuspendedOnDeprecatedThrowPromise、SuspendedOnHydration 三个——其中后两个在源码里有明确注释解释设计动机,值得单独说。
8.10.2 handleThrow 全函数:catch 里到底发生了什么
// ReactFiberWorkLoop.js:2302-2430(有删节,注释保留原文)
function handleThrow(root: FiberRoot, thrownValue: any): void {
// A component threw an exception. Usually this is because it suspended, but
// it also includes regular program errors.
//
// We're either going to unwind the stack to show a Suspense or error
// boundary, or we're going to replay the component again. Like after a
// promise resolves.
//
// Until we decide whether we're going to unwind or replay, we should preserve
// the current state of the work loop without resetting anything.
//
// If we do decide to unwind the stack, module-level variables will be reset
// in resetSuspendedWorkLoopOnUnwind.
// These should be reset immediately because they're only supposed to be set
// when React is executing user code.
resetHooksAfterThrow();
if (__DEV__) {
resetCurrentFiber();
}
if (
thrownValue === SuspenseException ||
thrownValue === SuspenseActionException
) {
// This is a special type of exception used for Suspense. ...
thrownValue = getSuspendedThenable();
workInProgressSuspendedReason = SuspendedOnImmediate;
} else if (thrownValue === SuspenseyCommitException) {
thrownValue = getSuspendedThenable();
workInProgressSuspendedReason = SuspendedOnInstance;
} else if (thrownValue === SelectiveHydrationException) {
// An update flowed into a dehydrated boundary. ...
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. If something earlier in the component already
// suspended, we must clear the thenable state to unblock the work loop.
SuspendedOnError;
}
workInProgressThrownValue = thrownValue;
const erroredWork = workInProgress;
if (erroredWork === null) {
// This is a fatal error
workInProgressRootExitStatus = RootFatalErrored;
logUncaughtError(
root,
createCapturedValueAtFiber(thrownValue, root.current),
);
return;
}
// ...(enableSchedulingProfiler 的 markComponentErrored/markComponentSuspended,见 :2390-2429)
}
逐段解读:
第一段:resetHooksAfterThrow()。 组件抛异常时,它内部的 hooks 链表处于半初始化状态(比如 use() 之后的 hook 还没执行)。resetHooksAfterThrow 把 workInProgressHook、thenableState 等渲染期游标复位,否则 replay 时会从错的 hook 开始。
第二段:四个特殊值的识别。 React 用身份比较(===)识别四个内部哨兵对象(定义在 ReactFiberThenable.js):
// ReactFiberThenable.js:57
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. ...'
);
// :67
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.",
);
// :72
export const SuspenseActionException: mixed = new Error(
"Suspense Exception: This is not a real error! It's an implementation " +
'detail of `useActionState` to interrupt the current render. ...'
);
use() 抛的不是 thenable 本身,而是 SuspenseException 这个 Error——真实的 thenable 通过 getSuspendedThenable()(ReactFiberThenable.js:378)从线程局部状态里取出来。为什么要绕这一圈?因为如果直接抛 thenable,用户的 try/catch 会把它当普通值捕获,Suspense 就失效了;而抛一个 Error 会在用户忘记 rethrow 时给出那条著名警告("You must either rethrow it immediately, or move the use call outside of the try/catch block")。
第三段:legacy thenable 与新机制的分野。 用户代码直接 throw promise(React 16 时代的模式)走到 else 分支,isWakeable 为 true → SuspendedOnDeprecatedThrowPromise。它与 SuspendedOnData 的处理差异只在 renderRootConcurrent 的一个分支里:
// ReactFiberWorkLoop.js:2976-2990
case SuspendedOnDeprecatedThrowPromise: {
// Suspended by an old implementation that uses the `throw promise`
// pattern. The newer replaying behavior can cause subtle issues
// like infinite ping loops. So we maintain the old behavior and
// always unwind.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(
root,
unitOfWork,
thrownValue,
SuspendedOnDeprecatedThrowPromise,
);
break;
}
SuspendedOnData 会先检查 isThenableResolved 尝试原地重放,SuspendedOnDeprecatedThrowPromise 无条件 unwind。这是版本兼容性的活化石:新 replay 机制依赖“thenable 被 React 包装过、带 status 字段”,legacy 裸 Promise 没有这个字段,replay 时无法判断 resolved,可能陷入“ping→重放→再挂→再 ping”的循环,所以干脆走老路。
第四段:erroredWork === null 的致命错误。 workInProgress 为 null 说明错误发生在渲染范围之外(或栈已被清空),此时直接 RootFatalErrored + logUncaughtError,交给 performWorkOnRoot 的 RootFatalErrored 分支(:1292-1308)卸载整棵树。这就是“没有 Error Boundary 包裹时整个根被卸载”的代码路径。
8.10.3 catch 之后的去向:replay 还是 unwind
handleThrow 只负责“记账”(置 reason + 存 thrownValue)。真正的决策在 outer do-while 的下一轮循环开头。把决策逻辑画成图:
flowchart TD
A["work loop 中抛异常"] --> B["catch → handleThrow<br/>置 workInProgressSuspendedReason<br/>存 workInProgressThrownValue"]
B --> C["break 到外层 do-while 下一轮"]
C --> D{"switch(reason)"}
D -->|"SuspendedOnData / OnAction"| E{"isThenableResolved?"}
D -->|"SuspendedOnImmediate"| F["转 AndReadyToContinue<br/>break outer 让出一次"]
D -->|"SuspendedOnInstance"| G["转 InstanceAndReadyToContinue<br/>break outer 让出一次"]
D -->|"SuspendedAndReadyToContinue"| H{"isThenableResolved?"}
D -->|"SuspendedOnError"| I["throwAndUnwindWorkLoop"]
D -->|"SuspendedOnDeprecatedThrowPromise"| I
D -->|"SuspendedOnHydration"| J["resetWorkInProgressStack<br/>RootSuspendedAtTheShell"]
E -->|"是"| K["replaySuspendedUnitOfWork<br/>原地重放该 Fiber"]
E -->|"否"| L["thenable.then(onResolution)<br/>break outer 让出主线程"]
H -->|"是"| K
H -->|"否"| I
F --> M["Scheduler 下次回调进来<br/>进入 AndReadyToContinue 分支"]
G --> N["preloadInstance 再试一次<br/>成功原地续跑 / 失败 unwind"]
三个值得注意的设计:
isThenableResolved是快路径(ReactFiberThenable.js:108):
export function isThenableResolved(thenable: Thenable<mixed>): boolean {
const status = thenable.status;
return status === 'fulfilled' || status === 'rejected';
}
React 给所有经手的 thenable(use() 读的、React.cache 产的、async action 的)都加了 status 字段。如果数据在同步阶段或微任务里就好了,重入时能同步检测到,连一次微任务都不用等,直接 replaySuspendedUnitOfWork。源码注释(:2855-2858)也坦言这是 TODO 的补丁:如果 Promise 自带 status(非 React 包装),要到这里才挂监听。
-
onResolution同时挂 fulfill 和 reject(:2874thenable.then(onResolution, onResolution))。Promise reject 也必须恢复渲染——因为错误要抛给 Error Boundary,永远挂起等于 UI 死锁。 -
replaySuspendedUnitOfWork与throwAndUnwindWorkLoop是performUnitOfWork的两个分叉(:3125、:3234)。replay 保留 Fiber 与已 reconcile 的 promise(replayBeginWork的 default 分支注释明说:“we're going to reuse the promises that were reconciled previously. So it's intentional that we don't call resetSuspendedWorkLoopOnUnwind here”,:3213-3217);unwind 则把 Fiber 标Incomplete(throwException里,ReactFiberThrow.js:372),一路向上找 Suspense/Error 边界。
8.10.4 renderRootSync 的 catch:同一场捕获,两种哲学
对比着看 renderRootSync(:2623-2768)会很有意思。它的 catch 一字不差(:2728-2730),但挂起处理完全另一套:
// ReactFiberWorkLoop.js:2664-2723(节选)
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
// The work loop is suspended. During a synchronous render, we don't
// yield to the main thread. Immediately unwind the stack. This will
// trigger either a fallback or an error boundary.
// TODO: For discrete and "default" updates (anything that's not
// flushSync), we want to wait for the microtasks the flush before
// unwinding. Will probably implement this using renderRootConcurrent,
// or merge renderRootSync and renderRootConcurrent into the same
// function and fork the behavior some other way.
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
switch (workInProgressSuspendedReason) {
case SuspendedOnHydration: {
resetWorkInProgressStack();
exitStatus = RootSuspendedAtTheShell;
break outer;
}
case SuspendedOnImmediate:
case SuspendedOnData:
case SuspendedOnAction:
case SuspendedOnDeprecatedThrowPromise: {
if (getSuspenseHandler() === null) {
didSuspendInShell = true;
}
const reason = workInProgressSuspendedReason;
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(root, unitOfWork, thrownValue, reason);
if (
shouldYieldForPrerendering &&
workInProgressRootIsPrerendering
) {
// We've switched into prerendering mode. ...
exitStatus = RootInProgress;
break outer;
}
break;
}
default: {
// Unwind then continue with the normal work loop.
...
}
}
}
workLoopSync();
exitStatus = workInProgressRootExitStatus;
break;
差异一览:
| 维度 | renderRootConcurrent | renderRootSync |
|---|---|---|
| 挂起后是否让出 | 是(break outer 返回 Scheduler) | 否,立即 unwind |
SuspendedOnData 是否 replay | 是(isThenableResolved 时) | 否,直接 unwind 显示 fallback |
SuspendedOnError 单独分支 | 有(:2831) | 归入 default(同样 unwind) |
didSuspendInShell 计数 | 无 | 有(:2660、:2739-2741),用于无限 ping 循环检测 |
| prerendering 交叉 | 只读标志 | shouldYieldForPrerendering 参数控制:同步渲染中若切进预渲染模式,立刻 RootInProgress 让出(:2701-2712) |
源码里那句 TODO(:2671-2675)坦白了动机:连离散事件/默认优先级的同步更新,理想行为也应该是“先等一个微任务看看数据是否 resolve,不行再 unwind”——目前只有并发路径享受这个待遇。
8.11 三种 work loop 的完整对比与选择逻辑
8.3.3 列出了三种 work loop 的让出判据,本节补齐第四个变体的缺失、选择逻辑的分支树、以及 25ms/5ms 的设计理由。
8.11.1 四个函数,一张表
// ReactFiberWorkLoop.js:2770-2777
// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// ReactFiberWorkLoop.js:3055-3069
/** @noinline */
function workLoopConcurrent(nonIdle: boolean) {
// We yield every other "frame" when rendering Transition or Retries. Those are blocking
// revealing new content. The purpose of this yield is not to avoid the overhead of yielding,
// which is very low, but rather to intentionally block any frequently occurring other main
// thread work like animations from starving our work. In other words, the purpose of this
// is to reduce the framerate of animations to 30 frames per second.
// For Idle work we yield every 5ms to keep animations going smooth.
if (workInProgress !== null) {
const yieldAfter = now() + (nonIdle ? 25 : 5);
do {
performUnitOfWork(workInProgress);
} while (workInProgress !== null && now() < yieldAfter);
}
}
// ReactFiberWorkLoop.js:3073-3079
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
| 变体 | 行号 | 让出判据 | 时间感知 | 用在哪 |
|---|---|---|---|---|
workLoopSync | :2772 | 绝不让出 | 无 | renderRootSync(:2725);renderRootConcurrent 的 act 环境分支(:3013) |
workLoopConcurrent(nonIdle) | :3056 | now() < yieldAfter,25ms(非空闲)/ 5ms(空闲) | 自己读时钟 | renderRootConcurrent 的 enableThrottledScheduling 分支(:3015) |
workLoopConcurrentByScheduler | :3073 | !shouldYield() | 委托 Scheduler | renderRootConcurrent 的默认分支(:3017) |
选择逻辑在 renderRootConcurrent 的三行分支里(:3007-3018):
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
// `act` special case: If we're inside an `act` scope, don't consult
// `shouldYield`. Always keep working until the render is complete.
// This is not just an optimization: in a unit test environment, we
// can't trust the result of `shouldYield`, because the host I/O is
// likely mocked.
workLoopSync();
} else if (enableThrottledScheduling) {
workLoopConcurrent(includesNonIdleWork(lanes));
} else {
workLoopConcurrentByScheduler();
}
为什么 act 环境必须 workLoopSync? 注释说得很直白:不只是优化,而是测试环境里 shouldYield() 的结果不可信( mocked I/O 下时钟可能不动,导致“永远让出”或“永不让出”的怪异行为)。act 的语义本来就是“把渲染和 effect 全部跑完再断言”,可中断与它天然冲突。
enableThrottledScheduling 目前是 false(shared/ReactFeatureFlags.js:76)。也就是说 19.3.1 正式路径上,workLoopConcurrent 这个“自己算 25ms”的变体实际处于休眠状态,默认永远走 workLoopConcurrentByScheduler。它存在的意义是:万一某个宿主环境无法使用 Scheduler 的 MessageChannel 实现(比如老 WebView),reconciler 仍有一个不依赖 Scheduler 时钟的兜底。
8.11.2 25ms vs 5ms 的设计理由
注释里的关键句:“the purpose of this yield is not to avoid the overhead of yielding, which is very low, but rather to intentionally block any frequently occurring other main thread work like animations from starving our work. In other words, the purpose of this is to reduce the framerate of animations to 30 frames per second.”
翻译成设计语言:
- 5ms(Idle 工作):与 Scheduler 的
frameInterval默认值对齐(scheduler/src/forks/Scheduler.js:456,frameYieldMs)。空闲工作频繁让出,保证动画流畅。 - 25ms(Transition/Retry 等 non-idle 工作):故意把渲染拉到约 40fps 的节奏。为什么?因为这些渲染是在“揭示新内容”,用户注意力在内容上而非动画上;而让出主线程太频繁,频繁触发的动画回调反而会饿死渲染本身(注释原话 "starving our work")。25ms ≈ 40fps,是一个“动画还能动、渲染不停饿”的折中。
nonIdle 的判据是 includesNonIdleWork(lanes)(ReactFiberLane.js:619):(lanes & NonIdleLanes) !== NoLanes,即 lane 集合里除了 Idle/Offscreen/Deferred 之外还有别的。
8.11.3 shouldYield() 到底问什么
workLoopConcurrentByScheduler 调的 shouldYield 最终落到 Scheduler 的 shouldYieldToHost(scheduler/src/forks/Scheduler.js:459):
let frameInterval: number = frameYieldMs;
function shouldYieldToHost(): boolean {
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
// Yield now.
return true;
}
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}
三个输入:
needsPaint:enableRequestPaint打开时,浏览器在帧边界通过requestPaint置位(:474-479),Scheduler 立即让出。React 把它关掉了(enableRequestPaint = false),所以这条不生效——React 选择“每 5ms 让一次”而不是“严格对齐帧”,注释在 :452-455:大多数任务不需要帧对齐,需要的请自己用requestAnimationFrame。timeElapsedvsframeInterval:单帧 5ms 内不让出,超过就让出。- 过期不让出:这一条在 Scheduler 的任务循环里(:199
currentTask.expirationTime > currentTime && shouldYieldToHost())——任务已过期时不再询问 shouldYield,必须跑完。
注意 workLoopConcurrent 与 workLoopConcurrentByScheduler 有个微妙差别:前者在循环条件里先执行再判断(do-while),后者先判断再执行(while)。对第一个 Fiber 来说,workLoopConcurrent 保证至少干一个单元的活,workLoopConcurrentByScheduler 可能在 shouldYield() 为 true 时一个都不干就返回——由外层 do-while 再入时补上。这是死循环防护与响应性之间的细微分寸。
8.11.4 performUnitOfWork:所有变体共同的“一步”
// ReactFiberWorkLoop.js:3081-3123(节选)
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
// ... profiling 分支略 ...
next = beginWork(current, unitOfWork, entangledRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
三个 work loop 变体全部只做一件事:调 performUnitOfWork(workInProgress)。区别仅在“调多少次、什么时候停”。所以 8.1 的心智模型——"并发渲染 = 同步渲染的全部逻辑 + 一套可中断可恢复的状态机"——在代码层面的落地就是:beginWork/completeWork 完全不知道自己在哪种 loop 里跑。这种“策略与机制分离”是 Fiber 架构最优雅的地方:切换并发/同步只改外层 20 行,不碰内层 3000 行。
此外还有两个非主循环的“单步”函数:
replaySuspendedUnitOfWork(:3125):重放一个刚挂过的 Fiber,内部走replayBeginWork(:3144)——beginWork 的裁剪版,只处理 SimpleMemoComponent/FunctionComponent/ForwardRef/HostComponent/default 五类 tag。throwAndUnwindWorkLoop(:3234):展开一个抛异常的 Fiber,内部throwException+unwindUnitOfWork(:3433)。
8.12 renderRootSync 与 renderRootConcurrent 的逐点对比
源码在 :2620-2622 留了一条 TODO:“Over time, this function and renderRootConcurrent have become more and more similar. Not sure it makes sense to maintain forked paths. Consider unifying them again.”——这条注释本身就是最好的对比引言:两者已经相似到作者都想合并了,但差异点依然存在。
// ReactFiberWorkLoop.js:2623-2627
function renderRootSync(
root: FiberRoot,
lanes: Lanes,
shouldYieldForPrerendering: boolean,
): RootExitStatus {
逐点对比表:
| 维度 | renderRootConcurrent(:2779) | renderRootSync(:2623) |
|---|---|---|
| 签名 | (root, lanes) | (root, lanes, shouldYieldForPrerendering) |
| 调用者 | performWorkOnRoot 的 shouldTimeSlice 为真时(:1180) | shouldTimeSlice 为假时(:1181);recoverFromConcurrentError(:1357);store 不一致时同步重渲(:1238) |
| 参数含义 | — | true = “允许在同步渲染中处理 Suspense/错误边界”;recoverFromConcurrentError 传 false = 错误恢复渲染,不能再挂起 |
| executionContext / dispatcher | 都设 RenderContext、push 普通 + async dispatcher(:2780-2783) | 完全一样(:2628-2631) |
| continuation 判断 | workInProgressRoot !== root || lanes 不同 → prepareFreshStack(:2787) | 同样的判断(:2635) |
resetRenderTimer() | 有(:2806) | 没有 |
| work loop | 三选一(act / throttled / scheduler,:3007-3018) | 只有 workLoopSync()(:2725) |
| 挂起处理 | 六+分支状态机,可能 replay、可能让出 | 立即 unwind;SuspendedOnHydration 单独处理;prerendering 时可提前 RootInProgress 让出(:2701-2712) |
| 循环结构 | outer: do { try {...} } while(true),break outer 让出 | 同构,但正常路径是 workLoopSync(); exitStatus = ...; break(:2725-2727) |
exitStatus 来源 | 函数末尾统一 return workInProgressRootExitStatus(:3051) | 循环内显式赋值(:2661、:2687、:2710、:2726) |
didSuspendInShell | 无 | 有(:2660、:2739-2741),root.shellSuspendCounter++ 用于无限 ping 循环检测 |
| 收尾清理 | resetContextDependencies、pop dispatcher、finishQueueingConcurrentUpdates(:3024-3048) | 一模一样(:2743-2764) |
| scheduling profiler | markRenderStarted/markRenderYielded/markRenderStopped(:2816、:3034、:3040) | 只有 started/stopped(:2657、:2750),没有 yielded |
两个值得注意的非对称:
-
resetRenderTimer只在并发路径调用(:2806)。它重置workInProgressRootRenderTargetTime = Infinity(getRenderTargetTime:710),这个定时器驱动“CPU suspense 启发式”(渲染超过RENDER_TIMEOUT_MS = 500ms就放弃继续渲染更多内容)。同步渲染反正一口气跑完,计时没有意义。 -
renderRootSync的 exit status 是“边跑边设”:进入时先继承workInProgressRootExitStatus(:2661),挂起时改写,workLoopSync返回后再读一次。而renderRootConcurrent是在函数末尾统一读。这反映了两种循环结构差异:同步路径的循环只会因异常回到循环头,并发路径的循环会因“让出后重入”回到循环头,后者的状态更需要在入口重新评估。
还有一个隐藏协同点:performWorkOnRoot 在 store 一致性检查失败时,会用 renderRootSync(root, lanes, false) 同步重渲一遍(:1236-1238),并把 renderWasConcurrent 置 false。也就是说并发渲染完成后还有一个“同步验证”环节——因为并发渲染让出期间,外部 store 可能被并发事件改动,导致渲染结果与 store 不一致。这是 useSyncExternalStore 正确性的基石,第 12 章会展开。
8.13 transition 的纠缠(entangled transitions)
第 3 章讲过 lane 的位运算,本节讲 lane 之间最反直觉的一层关系:纠缠(entanglement)——两条 lane 一旦纠缠,就必须在同一个渲染批次里出现,否则谁也不能单独提交。
8.13.1 问题:两个 transition 为什么需要纠缠
考虑这个场景:
function Tabs() {
const [tab, setTab] = useState('a');
const [page, setPage] = useState(1);
const startNav = () => {
startTransition(() => setTab('b')); // transition A
startTransition(() => setPage(0)); // transition B
};
// ...
}
claimNextTransitionUpdateLane(ReactFiberLane.js:726)会给 A、B 分配不同的 lane(比如 TransitionLane1、TransitionLane2)。如果两条 lane 各自渲染、各自提交,用户会先看到 "tab=b, page=1",再看到 "tab=b, page=0"——中间态被提交了。A 和 B 来自同一次用户操作,本该是一个原子更新。
getHighestPriorityLanes 会把同一优先级簇的 lane 合并成一次渲染(第 3 章),但那只是“碰巧同优先级时的合并”,不是保证。纠缠是把“保证”写进数据结构:markRootEntangled(ReactFiberLane.js:1028):
export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
// In addition to entangling each of the given lanes with each other, we also
// have to consider _transitive_ entanglements. For each lane that is already
// entangled with *any* of the given lanes, that lane is now transitively
// entangled with *all* of the given lanes.
//
// Translated: If C is entangled with A, then entangling A with B also
// entangles C with B.
//
// If this is hard to grasp, it might help to intentionally break this
// function and look at the tests that fail in ReactTransition-test.js. Try
// commenting out one of the conditions below.
const rootEntangledLanes = (root.entangledLanes |= entangledLanes);
const entanglements = root.entanglements;
let lanes = rootEntangledLanes;
while (lanes) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
if (
// Is this one of the newly entangled lanes?
(lane & entangledLanes) |
// Is this lane transitively entangled with the newly entangled lanes?
(entanglements[index] & entangledLanes)
) {
entanglements[index] |= entangledLanes;
}
lanes &= ~lane;
}
}
root 上有两个字段配合:entangledLanes(位图,标记哪些 lane 参与了纠缠)和 entanglements(数组,entanglements[i] 是与第 i 条 lane 纠缠的所有 lane)。注意那个传递闭包:如果 CA,现在 AB,那么 C 也必须 ~B。源码注释甚至建议你“故意破坏这个函数,去看 ReactTransition-test.js 里哪些测试挂”——传递性不是理论洁癖,是实测出来的需求。
8.13.2 纠缠在何时被读取:getEntangledLanes
纠缠的消费方是 getEntangledLanes(ReactFiberLane.js:427),它在两个关键位置被调用:
// ReactFiberLane.js:427-475(节选)
export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
let entangledLanes = renderLanes;
if ((entangledLanes & InputContinuousLane) !== NoLanes) {
// When updates are sync by default, we entangle continuous priority updates
// and default updates, so they render in the same batch. ...
entangledLanes |= entangledLanes & DefaultLane;
}
// Check for entangled lanes and add them to the batch.
// ...
// Note that we apply entanglements *after* checking for partial work above.
// This means that if a lane is entangled during an interleaved event while
// it's already rendering, we won't interrupt it. This is intentional, since
// entanglement is usually "best effort": we'll try our best to render the
// lanes in the same batch, but it's not worth throwing out partially
// completed work in order to do it.
const allEntangledLanes = root.entangledLanes;
if (allEntangledLanes !== NoLanes) {
const entanglements = root.entanglements;
let lanes = entangledLanes & allEntangledLanes;
while (lanes > 0) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
entangledLanes |= entanglements[index];
lanes &= ~lane;
}
}
return entangledLanes;
}
调用点一:prepareFreshStack 的 entangledRenderLanes = getEntangledLanes(root, lanes)(ReactFiberWorkLoop.js:2282)。beginWork/completeWork 读的 entangledRenderLanes 因此可能比 workInProgressRootRenderLanes 更大——渲染的“任务优先级”与“渲染范围”被刻意分开。源码注释(:2275-2281)给了完美例子:“a transition may not be allowed to finish unless it includes the Sync lane, which is currently suspended. We should be able to render the Transition and Sync lane in the same batch, but at Transition priority”。
调用点二:markRootSuspended(ReactFiberWorkLoop.js:1808-1811,受 enableParallelTransitions 控制,该 flag 在 19.3.1 为 true,shared/ReactFeatureFlags.js:235):
function markRootSuspended(root, suspendedLanes, spawnedLane, didAttemptEntireTree) {
if (enableParallelTransitions) {
// When suspending, we should always mark the entangled lanes as suspended.
suspendedLanes = getEntangledLanes(root, suspendedLanes);
}
// When suspending, we should always exclude lanes that were pinged or ...
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
suspendedLanes = removeLanes(suspendedLanes, workInProgressRootInterleavedUpdatedLanes);
_markRootSuspended(root, suspendedLanes, spawnedLane, didAttemptEntireTree);
}
挂起时也要把纠缠 lane 一起挂起——否则 transition A 挂起等数据、transition B 继续跑并提交,又出现中间态。
调用点三:upgradePendingLanesToSync(ReactFiberLane.js:1059)展示了纠缠的另一用途——把任意 lane 簇整体升级为 Sync:
export function upgradePendingLanesToSync(root: FiberRoot, lanesToUpgrade: Lanes) {
root.pendingLanes |= SyncLane;
root.entangledLanes |= SyncLane;
let lanes = lanesToUpgrade;
while (lanes) {
const index = pickArbitraryLaneIndex(lanes);
const lane = 1 << index;
root.entanglements[SyncLaneIndex] |= lane;
lanes &= ~lane;
}
}
flushRoot(:1823)和 popstate 的同步刷新(getNextLanesToFlushSync:363)都靠它:一次交互要同步呈现的不仅是它自己,还有所有与它纠缠的 lane。
8.13.3 另一种纠缠:async action scope
ReactFiberAsyncAction.js 把“纠缠”用在了异步 action 上(模块注释,:37-43):
// If there are multiple, concurrent async actions, they are entangled. All
// transition updates that occur while the async action is still in progress
// are treated as part of the action.
//
// The ideal behavior would be to treat each async function as an independent
// action. However, without a mechanism like AsyncContext, we can't tell which
// action an update corresponds to. So instead, we entangle them all into one.
实现是 entangleAsyncAction(:71-101):维护一个 currentEntangledLane、一个 currentEntangledActionThenable、一个监听者数组。所有并发 async action 共享同一条 transition lane(requestTransitionLane 里通过 peekEntangledActionLane() 复用,ReactFiberRootScheduler.js:713-717),全部 settle 后统一 ping。详见 8.19。
8.13.4 transition 对象本身:getTransitionsForLanes
纠缠解决“哪些 lane 一起渲染”,getTransitionsForLanes(ReactFiberLane.js:1228)解决“这次渲染对应哪些 transition 对象”:
export function getTransitionsForLanes(
root: FiberRoot,
lanes: Lane | Lanes,
): Array<Transition> | null {
if (!enableTransitionTracing) {
return null;
}
const transitionsForLanes = [];
while (lanes > 0) {
const index = laneToIndex(lanes);
const lane = 1 << index;
const transitions = root.transitionLanes[index];
if (transitions !== null) {
transitions.forEach(transition => {
transitionsForLanes.push(transition);
});
}
lanes &= ~lane;
}
if (transitionsForLanes.length === 0) {
return null;
}
return transitionsForLanes;
}
root 上有一个 transitionLanes: LaneMap<Set<Transition>>,addTransitionToLanesMap(:1210)在每次 transition 更新时登记。renderRootConcurrent 入口处 workInProgressTransitions = getTransitionsForLanes(root, lanes)(:2804)把它查出来,最终随 completeRoot 一路传到 commit,用于驱动 transition 的 start/complete 回调(addTransitionStartCallbackToPendingTransition:548 等)。
注意 enableTransitionTracing 在 19.3.1 默认 false(shared/ReactFeatureFlags.js:117),所以这条链路在正式构建里休眠——但数据结构都在,打开 flag 即可用。useTransition 的 isPending 并不依赖它(走的是 8.18 讲的 state hook 路径),受影响的是 <ViewTransition> 的 class 解析和 DevTools 的 transition 追踪。
至此,transition 的三层结构完整了:
flowchart LR
subgraph lane层["Lane 层(位运算)"]
A["TransitionLane1..14<br/>claimNextTransitionUpdateLane(:726)"]
B["entanglements 矩阵<br/>markRootEntangled(:1028)"]
end
subgraph 对象层["Transition 对象层"]
C["Transition {types, gesture, name, startTime}"]
D["root.transitionLanes<br/>addTransitionToLanesMap(:1210)"]
end
subgraph 调度层["调度层"]
E["workInProgressTransitions(:537)<br/>getTransitionsForLanes(:1228)"]
F["useTransition isPending<br/>state hook + useThenable"]
end
A --> B
A --> D
D --> E
E --> F
8.13.5 async action scope 深挖:引用计数、纠缠与超时兜底
ReactFiberAsyncAction.js 一共 263 行,模块级状态只有 4 个(:45-54):
// ReactFiberAsyncAction.js:45-54
// The listeners to notify once the entangled scope completes.
let currentEntangledListeners: Array<() => mixed> | null = null;
// The number of pending async actions in the entangled scope.
let currentEntangledPendingCount: number = 0;
// The transition lane shared by all updates in the entangled scope.
let currentEntangledLane: Lane = NoLane;
// A thenable that resolves when the entangled scope completes. ...
let currentEntangledActionThenable: Thenable<void> | null = null;
这里没有 Map、没有栈、没有 AsyncContext——整套 async action 的“作用域”就是四个模块级变量。这个设计点值得停一下:React 的并发子系统(8.13 的 transition、8.10 的挂起状态机、本节的 async scope)反复选择“模块级单例 + 显式清理”,而不是“对象 + 栈”。原因是这些状态只在“从一个更新被调度到它被提交”这一小段窗口内有意义,而这个窗口内 React 保证是单线程、不重入的(executionContext 是唯一的重入闸门)。用对象传递会污染大量函数签名——requestTransitionLane、scheduleTaskForRootDuringMicrotask、useThenable 都要多带一个参数——而收益为零。
创建 scope 的精确时机
entangleAsyncAction(:71-101)只在第一个 async action 进入时建 scope:
// ReactFiberAsyncAction.js:78-101
if (currentEntangledListeners === null) {
// There's no outer async action scope. Create a new one.
const entangledListeners = (currentEntangledListeners = []);
currentEntangledPendingCount = 0;
currentEntangledLane = requestTransitionLane(transition);
const entangledThenable: Thenable<void> = {
status: 'pending',
value: undefined,
then(resolve: void => mixed) {
entangledListeners.push(resolve);
},
};
currentEntangledActionThenable = entangledThenable;
if (enableDefaultTransitionIndicator) {
needsIsomorphicIndicator = true;
ensureScheduleIsScheduled();
}
}
currentEntangledPendingCount++;
thenable.then(pingEngtangledActionScope, pingEngtangledActionScope);
return thenable;
逐点:
currentEntangledLane = requestTransitionLane(transition)—— 这就是那条共享 transition lane。它调用的正是ReactFiberRootScheduler.js:698的requestTransitionLane,而后者内部有currentEventTransitionLane缓存(:711-722)。两者的顺序很关键:entangleAsyncAction先申请 lane 并写进currentEntangledLane,之后同一个事件里的后续 transition 更新会通过peekEntangledActionLane()(ReactFiberRootScheduler.js:713)直接复用这条 lane,而不是各拿一条。这就是“同一批异步工作原子提交”在 lane 层的落点。entangledThenable是一个手写的 thenable,不是Promise。它的then只做一件事:把 resolve 回调塞进监听者数组。这正是 8.18.3 里说的“React 只需要 thenable 协议,不需要真正的 Promise”——chainThenableValue的注释(:145-150)明说了原因:“We don't technically require promise support on the client yet”。thenable.then(pingEngtangledActionScope, pingEngtangledActionScope)—— 成功和失败挂同一个回调。因为这里只关心“计数归零”,不关心 action 的成败;错误由chainThenableValue的 reject 分支(:171-184)单独转成 state,最终由useThenable抛出。enableDefaultTransitionIndicator分支(实验特性,shared/ReactFeatureFlags.js:108默认__EXPERIMENTAL__)会强制排一次调度(ensureScheduleIsScheduled),目的是让“默认 loading 指示器”有机会先绘制出来。
引用计数归零时的六件事
pingEngtangledActionScope(:103-139)的入口是 if (--currentEntangledPendingCount === 0)。计数归零意味着所有并发 action 都 settle 了,于是按顺序做六件事:
// ReactFiberAsyncAction.js:104-138
if (--currentEntangledPendingCount === 0) {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (!hasScheduledTransitionWork()) {
// If we have received no updates since we started the entangled Actions
// ... clear the timer ...
clearAsyncTransitionTimer();
}
}
clearEntangledAsyncTransitionTypes();
if (pendingEntangledRoots === 0) {
stopIsomorphicDefaultIndicator();
}
if (currentEntangledListeners !== null) {
// All the actions have finished. Close the entangled async action scope
// and notify all the listeners.
if (currentEntangledActionThenable !== null) {
const fulfilledThenable: FulfilledThenable<void> =
currentEntangledActionThenable as any;
fulfilledThenable.status = 'fulfilled';
}
const listeners = currentEntangledListeners;
currentEntangledListeners = null;
currentEntangledLane = NoLane;
currentEntangledActionThenable = null;
needsIsomorphicIndicator = false;
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
}
}
}
(1)清理性能计时器:hasScheduledTransitionWork()(ReactProfilerTimer.js:319)判定这次 async action 期间是否真的产生过 transition 更新。如果没有(比如 action 里压根没 setState),就 clearAsyncTransitionTimer() 丢掉这段计时,避免 DevTools 上出现一条“什么都没渲染却一直显示 Blocked”的假轨道。
(2)清空纠缠的 transition types:clearEntangledAsyncTransitionTypes()(ReactFiberTransitionTypes.js:59)把 entangledTransitionTypes 置空。这是 8.14.4 讲的 addTransitionType 在异步空档期调用时暂存的类型集合——action 全部结束后必须清掉,否则类型会“泄漏”到下一次无关的 transition 上。
(3)关闭默认指示器:stopIsomorphicDefaultIndicator() 只在没有其他 root 还在等(pendingEntangledRoots === 0)时执行。retainIsomorphicIndicator(:256)/releaseIsomorphicIndicator(:246)是一组引用计数,用于跨 root 共享同一个全局 loading 条。
(4)标记聚合 thenable 为 fulfilled:注意这里只改 status,不调用监听者——真正叫醒等待者的代码是(6)。
(5)清空 4 个模块级变量并快照 listeners:currentEntangledListeners = null、currentEntangledLane = NoLane、currentEntangledActionThenable = null、needsIsomorphicIndicator = false。先把 currentEntangledListeners 存进局部变量 listeners 再置 null,是为了让监听者回调里如果又启动一个新的 async action(完全合法),能建出一个干净的新 scope,而不是污染正在通知的这一批。
(6)依次调用监听者:每个 listener 是某个 useThenable 调用注册的 resolve 函数,最终会触发 8.10 讲的 SuspendedAndReadyToContinue → replaySuspendedUnitOfWork。
消费端:挂起的真正位置在 updateQueue
很多人以为 useTransition 的 isPending 是在 ReactFiberAsyncAction.js 里保持的。其实不然——isPending 的 state 值是一个 thenable(8.18.1),而**“挂起”这件事发生在 updateQueue 的 reducer 里**,早于 useThenable。updateReducerImpl(ReactFiberHooks.js)里有这么一段(:1471-1476):
// ReactFiberHooks.js:1471-1476
// Check if this update is part of a pending async action. If so,
// we'll need to suspend until the action has finished, so that it's
// batched together with future updates in the same action.
if (updateLane === peekEntangledActionLane()) {
didReadFromEntangledAsyncAction = true;
}
以及循环末尾(:1552-1572,节选):
// ReactFiberHooks.js:1552-1571(节选)
// Mark that the fiber performed work, but only if the new state is
// different from the current state.
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
// ...
if (didReadFromEntangledAsyncAction) {
const entangledActionThenable = peekEntangledActionThenable();
if (entangledActionThenable !== null) {
// TODO: Instead of the throwing the thenable directly, throw a
// special object like `use` does so we can detect if it's captured
// by userspace.
throw entangledActionThenable;
}
}
}
这条链路的语义非常精确:只有当一次 updateQueue 的归约真的读了“属于当前 entangled action lane 的更新”、并且最终 state 真的变了,才把聚合 thenable 抛出去挂起。三个条件缺一不可:
- 按 lane 命中(
updateLane === peekEntangledActionLane()); didReadFromEntangledAsyncAction为真(本轮确实读到过);!is(newState, hook.memoizedState)(state 真的变了,否则整段 reducer 都会 bailout)。
第三个条件是性能护城河:如果是无关这次 action 的渲染,peekEntangledActionThenable() 返回 null,或者 state 没变,就根本不会挂起。ReactFiberClassUpdateQueue.js:477 里也有对称的一段用于 class 组件。
把这段和 8.18.1 的 useThenable 放一起看,会得到一条“挂起位置可选”的链路:
flowchart TD
A["startTransition(async () => {...})"] --> B["dispatchOptimisticSetState(isPending=true)"]
B --> C["action 返回 Promise → chainThenableValue(P, false)"]
C --> D["dispatchSetState(state = thenableForFinishedState)"]
D --> E["下次渲染:updateReducerImpl 归约 state"]
E --> F{"读到的 update lane<br/>=== peekEntangledActionLane()?"}
F -->|否| G["正常渲染,isPending 由 useThenable 解出"]
F -->|是| H["didReadFromEntangledAsyncAction = true"]
H --> I{"newState !== memoizedState?"}
I -->|否| G
I -->|是| J["throw entangledActionThenable(:1569)"]
J --> K["handleThrow → SuspendedOnAction<br/>(或走 useThenable 的 SuspendedOnData)"]
K --> L["等待聚合 thenable settle"]
L --> M["pingEngtangledActionScope → 通知监听者"]
M --> N["ensureRootIsScheduled → replaySuspendedUnitOfWork"]
N --> O["state 变成 false,isPending = false"]
注意图中 SuspendedOnAction 与 SuspendedOnData 的区别:handleThrow 里抛出的若是 SuspenseActionException,会归类为 SuspendedOnAction(ReactFiberWorkLoop.js:2323-2339);而抛出的若是普通 thenable,则视 isWakeable 归为 SuspendedOnDeprecatedThrowPromise(:2361-2364)。本章 8.10 的十种挂起原因里,SuspendedOnAction 就是给这条链路准备的。
超时兜底在哪里
ReactFiberAsyncAction.js 本身没有超时逻辑——这是刻意的。async action 的“pending”语义完全依赖用户 Promise settle;如果用户的 Promise 永远不 settle,React 也永远不会恢复渲染,这是正确的行为(和 await 一个永不 resolve 的 promise 一样)。
真正的“兜底”发生在三个别的地方:
- DEV 警告:
ReactSharedInternals.asyncTransitions计数器(ReactFiberHooks.js:3185-3189、ReactStartTransition.js:87-91)在 action 未结束时非零,addTransitionType会据此给出“必须包在 startTransition 里”的错误(ReactTransitionType.js:31-46)。它只提示,不打断。 - 测试兜底:
act环境(ReactFiberRootScheduler.js:630)会把所有调度塞进actQueue,await act(async () => …)结束时若仍有未 settle 的 action,测试库会超时报错——这是测试框架的兜底,不是 React 的。 - 提交挂起超时:唯一带时间语义的是
completeRootWhenReady里给 suspensey commit 的timeoutOffset(ReactFiberWorkLoop.js:1631-1638),以及 retry 的FALLBACK_THROTTLE_MS(:528,300ms)。它们管的是“资源什么时候可见”,与 async action 的 pending 无关。
所以当你在生产里看到“transition 永远 pending”,99% 是用户的 Promise 没有 settle,或者未缓存 promise 反复重挂(shellSuspendCounter,:2739)。React 不会替你超时——那是业务语义,不是框架语义。
8.14 View Transition 与并发
React 19 把浏览器的 View Transition API 接进了并发调度:<ViewTransition> 组件、addTransitionType、startGestureTransition 都建立在本章的挂起/恢复机制之上。这一节看它们与并发渲染的接合点。
8.14.1 门槛:只有特定 lane 有资格启动 View Transition
completeRootWhenReady(ReactFiberWorkLoop.js:1578)在提交前做资格判断(:1600-1602):
const isViewTransitionEligible =
enableViewTransition && includesOnlyViewTransitionEligibleLanes(lanes); // TODO: Use a subtreeFlag to optimize.
const isGestureTransition = enableGestureTransition && isGestureRender(lanes);
const maySuspendCommit =
subtreeFlags & ShouldSuspendCommit ||
(subtreeFlags & BothVisibilityAndMaySuspendCommit) ===
BothVisibilityAndMaySusenseCommit;
includesOnlyViewTransitionEligibleLanes(ReactFiberLane.js:668):
export function includesOnlyViewTransitionEligibleLanes(lanes: Lanes): boolean {
return (lanes & (TransitionLanes | RetryLanes | IdleLane)) === lanes;
}
只有 Transition / Retry / Idle lane 有资格。同步更新(SyncLane/DefaultLane)直接排除——理由在 8.2 讲过:同步更新的语义是“立刻看到结果”,而 View Transition 要等 .ready 回调、可能延迟提交,两者矛盾。这是“并发特性只服务低优先级更新”的又一例证。
8.14.2 提交也可以被挂起:completeRootWhenReady
// ReactFiberWorkLoop.js:1607-1681(节选)
let suspendedState: null | SuspendedState = null;
if (isViewTransitionEligible || maySuspendCommit || isGestureTransition) {
// Before committing, ask the renderer whether the host tree is ready.
// If it's not, we'll wait until it notifies us.
suspendedState = startSuspendingCommit();
// This will walk the completed fiber tree and attach listeners to all
// the suspensey resources. ...
// This will also track any newly added or appearing ViewTransition
// components for the purposes of forming pairs.
accumulateSuspenseyCommit(finishedWork, lanes, suspendedState);
if (
isViewTransitionEligible ||
(isGestureTransition && root.pendingGestures !== null && ...)
) {
// Wait for any pending View Transition (including gestures) to finish.
suspendOnActiveViewTransition(suspendedState, root.containerInfo);
}
// ...
const schedulePendingCommit = waitForCommitToBeReady(
suspendedState,
timeoutOffset,
);
if (schedulePendingCommit !== null) {
// Not yet ready to commit. Delay the commit until the renderer notifies
// us that it's ready. This will be canceled if we start work on the
// root again.
pendingEffectsLanes = lanes;
root.cancelPendingCommit = schedulePendingCommit(
completeRoot.bind(null, root, finishedWork, ...),
);
const didAttemptEntireTree = !didSkipSuspendedSiblings;
markRootSuspended(root, lanes, spawnedLane, didAttemptEntireTree);
return;
}
}
这是 8.4 退出状态机之外的第二套挂起机制——提交阶段挂起。渲染完成了,但图片/字体/CSS 还没加载完,或者上一个 View Transition 还在跑,commit 就要等。注意 markRootSuspended 又被调了一次——挂起的 commit 也要在 root 上记账,scheduleTaskForRootDuringMicrotask 靠 root.cancelPendingCommit !== null 跳过调度(ReactFiberRootScheduler.js:430)。
时序上,它把“渲染→提交”这对原子操作拆成了可等待的两步:
sequenceDiagram
participant R as renderRootConcurrent
participant F as finishConcurrentRender
participant C as completeRootWhenReady
participant H as 宿主(渲染器)
participant V as ViewTransition API
R->>F: RootCompleted + lanes=Transition
F->>C: completeRootWhenReady
C->>C: isViewTransitionEligible?
C->>H: startSuspendingCommit / accumulateSuspenseyCommit
C->>H: waitForCommitToBeReady
alt 资源未就绪
H-->>C: 返回 subscribe 函数
C->>C: root.cancelPendingCommit = subscribe(...)
C->>C: markRootSuspended
Note over C: 提交被推迟,root 重新可被调度
H-->>C: 资源就绪回调
C->>C: completeRoot
else 资源就绪
C->>C: completeRoot 立即提交
end
C->>V: commitRoot 内 startViewTransition(:3899)
8.14.3 commitRoot 里的 startViewTransition
// ReactFiberWorkLoop.js:3894-3921
pendingEffectsStatus = PENDING_MUTATION_PHASE;
if (enableViewTransition && shouldStartViewTransition) {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
startAnimating(lanes);
}
pendingViewTransition = startViewTransition(
suspendedState,
root.containerInfo,
pendingTransitionTypes,
flushMutationEffects,
flushLayoutEffects,
flushAfterMutationEffects,
flushSpawnedWork,
flushPassiveEffects,
reportViewTransitionError,
enableProfilerTimer ? suspendedViewTransition : (null as any),
enableProfilerTimer
? finishedViewTransition.bind(null, lanes)
: (null as any),
);
} else {
// Flush synchronously.
flushMutationEffects();
flushLayoutEffects();
// Skip flushAfterMutationEffects
flushSpawnedWork();
}
注意这个 if/else 的深刻之处:有 View Transition 时,commit 的四个子阶段(mutation / layout / afterMutation / spawned)被当作回调交给浏览器 API 驱动;没有时,它们同步顺序执行。也就是说 View Transition 不仅影响动画,它把 commit 阶段本身变成了“可被宿主事件驱动”的异步流程。pendingEffectsStatus 机制(hasPendingCommitEffects:780)让 Scheduler 在 View Transition 进行中暂停对该 root 的调度——performWorkOnRootViaSchedulerTask 开头就检查它(ReactFiberRootScheduler.js:530-541)。
8.14.4 <ViewTransition> 组件与 addTransitionType
组件实现在 ReactFiberViewTransitionComponent.js,核心是给 DOM 节点生成/解析 view-transition-name 与 class:
// ReactFiberViewTransitionComponent.js:28-47(节选)
export function getViewTransitionName(
props: ViewTransitionProps,
instance: ViewTransitionState,
): string {
if (props.name != null && props.name !== 'auto') {
return props.name;
}
if (instance.autoName !== null) {
return instance.autoName;
}
// We assume we always call this in the commit phase.
const root = getCommittingRoot() as any as FiberRoot;
const identifierPrefix = root.identifierPrefix;
const globalClientId = globalClientIdCounter++;
const name =
'_' + identifierPrefix + 't_' + globalClientId.toString(32) + '_';
instance.autoName = name;
return name;
}
name="auto" 时用全局计数器生成稳定唯一的名字(带 root 的 identifierPrefix,SSR 场景下保证服务端与客户端一致)。class 解析 getClassNameByType(:49-77)则依赖 getPendingTransitionTypes()——即本次提交激活的 transition 类型列表,把类型映射到 class(支持 'none' 短路与 default 兜底)。
类型的来源是 addTransitionType(packages/react/src/ReactTransitionType.js:19):
export function addTransitionType(type: string): void {
if (enableViewTransition) {
const transition = ReactSharedInternals.T;
if (transition !== null) {
const transitionTypes = transition.types;
if (transitionTypes === null) {
transition.types = [type];
} else if (transitionTypes.indexOf(type) === -1) {
transitionTypes.push(type);
}
} else {
// We're in the async gap. Simulate an implicit startTransition around it.
// ...
startTransition(addTransitionType.bind(null, type));
}
}
}
它把类型挂到当前 ReactSharedInternals.T(当前 transition 对象)上。异步 action 的“空档期”(action 已 resolve、transition 已结束)调用时会自动包一个隐式 startTransition——因为类型必须依附于某个 transition 才能进入 root.transitionTypes(queueTransitionTypes,ReactFiberTransitionTypes.js:16)并在提交时被 claimQueuedTransitionTypes(:64)领走。
8.14.5 gesture transition:并发的“即时模式”
startGestureTransition(ReactStartTransition.js:120)是 19.3 的实验特性(enableGestureTransition = __EXPERIMENTAL__)。它创建一个 transition.gesture = provider 的 Transition 对象,随后该 gesture 的所有更新走独立的 GestureLane:
// ReactFiberLane.js:718-724
export function isGestureRender(lanes: Lanes): boolean {
if (!enableGestureTransition) {
return false;
}
// This should render only the one lane.
return lanes === GestureLane;
}
requestUpdateLane 里有一条硬约束(ReactFiberWorkLoop.js:835-844):gesture transition 内禁止普通 setState,只能更新 useOptimistic()——因为 gesture 期间 UI 必须逐帧响应手势,任何副作用状态的混入都会破坏这个保证。手势的打断/恢复由 pingGestureRoot(:5098)与 restartGestureRoot(:5108)驱动,后者在手势作废时 prepareFreshStack(root, NoLanes) 重启渲染,或取消挂起的 commit。
8.14.6 unstable_Scope
unstable_Scope(ReactFiberScope.js,enableScopeAPI = false)允许在任意组件子树外查询 DOM 节点(getInstanceFromScope)。它与并发的接点很小但真实:collectScopedNodes(:33-59)遍历 fiber 树时,遇到已超时的 Suspense 边界会走进 fallback 子树(isFiberSuspenseAndTimedOut + getSuspenseFallbackChild,:52-54)——因为 scope 查询是即时的,必须反映“当前屏幕上是什么”,而不是“渲染到一半的 WIP 树是什么”。
// ReactFiberScope.js:50-57
let child = node.child;
if (isFiberSuspenseAndTimedOut(node)) {
child = getSuspenseFallbackChild(node);
}
if (child !== null) {
collectScopedNodesFromChildren(child, fn, scopedNodes);
}
getSuspenseFallbackChild(:27-29)的实现很短,但揭示了一个 fiber 布局事实:Suspense 边界的 child 是 primary 子树,offScreen 子树挂在 primaryChild.sibling 上,而 fallback 是 offScreen 子树的 child。
// ReactFiberScope.js:27-29
function getSuspenseFallbackChild(fiber: Fiber): Fiber | null {
return ((fiber.child as any as Fiber).sibling as any as Fiber).child;
}
两次 .child/.sibling 的跳跃正是这个布局。同一套约定在 8.16.3 的预渲染 unwind 里也被使用——这正是并发特性与 DOM 查询能安全共存的原因:查询永远走“当前已提交的树”,绝不碰 WIP 树。createScopeInstance(:209-216)暴露的四个方法(DO_NOT_USE_queryAllNodes、DO_NOT_USE_queryFirstNode、containsNode、getChildContextValues)全部基于这条"用 current 树、走 fallback"的规则。
上篇源码索引表(覆盖 8.1 ~ 8.14)
路径相对 packages/react-reconciler/src/(除非另注)。行号以 19.3.1 为准。
| 主题 | 文件 | 关键位置 |
|---|---|---|
| 时间切片判定 | ReactFiberWorkLoop.js | shouldTimeSlice(本篇 §8.2) |
| 并发渲染主函数 | 同上 | renderRootConcurrent(:2779) |
| 同步渲染主函数 | 同上 | renderRootSync(:2623) |
| 渲染收尾与结局分派 | 同上 | finishConcurrentRender(:1420) |
| 渲染前建栈 | 同上 | prepareFreshStack(:2023) |
| 三种 work loop | 同上 | workLoopSync(:2772)、workLoopConcurrent(:3056)、workLoopConcurrentByScheduler(:3073) |
| 单 fiber 工作单元 | 同上 | performUnitOfWork(:3081)、completeUnitOfWork(:3368) |
| Lane 选择 | ReactFiberLane.js | getNextLanes(:249)、getNextLanesToFlushSync(:363) |
| 饥饿与过期 | 同上 | markStarvedLanesAsExpired(:541)、includesExpiredLane(:696) |
| transition 判定 | 同上 | includesOnlyTransitions(:632)、includesBlockingLane(:684) |
| lane 申请 | 同上 | claimNextTransitionUpdateLane(:726)、claimNextRetryLane(:747) |
startTransition | ReactFiberHooks.js | startTransition(:3117)、mountTransition / updateTransition |
useDeferredValue | 同上 | mountDeferredValueImpl / updateDeferredValueImpl |
use 原语 | 同上 | use(:1159)、useThenable(:1098) |
| 抛出与挂起分派 | ReactFiberThrow.js | throwException、markSuspenseBoundaryShouldCapture、attachPingListener |
| async action scope | ReactFiberAsyncAction.js | entangleAsyncAction、startAsyncTransitionLoopIfNecessary |
| View Transition | ReactFiberViewTransitionComponent.js | startViewTransition、addTransitionType |
startTransition 用户侧 | packages/react/src/ReactStartTransition.js | 全文 205 行 |
| 调度衔接 | ReactFiberRootScheduler.js | ensureRootIsScheduled、flushSyncWorkAcrossRootsImpl |
| 时间切片让出 | packages/scheduler/src/forks/Scheduler.js | shouldYieldToHost、frameYieldMs |
上篇自测 FAQ(覆盖 8.1 ~ 8.14)
Q1:shouldTimeSlice 返回 false 就一定同步渲染吗?
不一定。它只回答"这次渲染可不可以切片"。真正的执行路径还要看 performConcurrentWorkOnRoot 接到的是同步还是并发的 didTimeout 状态,以及 lane 是否被 markStarvedLanesAsExpired 抬成了过期(过期会强制走同步路径)。
Q2:并发渲染被更高优先级打断,已完成的工作会丢吗?
不会丢,但也不会被保留为"部分成果"——它保留在 workInProgress 树与模块级变量(workInProgressRootRenderLanes、workInProgressSuspendedReason 等)里,下次从同一个 root 继续时直接复用同一棵树。这正是"栈外栈"设计的意义:这些状态不在函数栈上,而在模块作用域。
Q3:renderRootConcurrent 的六种结局里,哪种最容易被误解?
RootSuspendedWithDelay。它不是"挂起且延迟显示",而是"挂起,但再等一下可能就好了,所以先不提交 fallback"。只有当 lane 里混了非 transition 的更新时,React 才会先提交占位。
Q4:为什么只有"严格更高优先级"才能打断?
否则会出现互相打断的活锁:两个同优先级任务互相抢占,谁都跑不完。源码里用 includesBlockingLane 与优先级比较共同保证"新来的必须严格更高"。
Q5:markStarvedLanesAsExpired 会不会让 transition 变得无意义?
不会,它是兜底而不是常规路径:只有 lane 等待超过 expirationTime 才会被抬为过期,然后强制同步渲染一次。这保证低优先级更新最终一定会被执行(防饥饿),但不改变它在正常情况下的可打断性。
Q6:useDeferredValue 和 startTransition 是同一件事吗?
不是。startTransition 标记一次更新为可等待;useDeferredValue 让一个值慢半拍,它申请的是独立的 TransitionDeferredLanes,因此可以被后续紧急更新打断并"追赶"。多个 useDeferredValue 在同一渲染里共享一条 deferred lane。
Q7:为什么 use 可以在渲染期读 Promise?
因为它把 Promise 变成"挂起信号":useThenable 在未 settle 时抛出 thenable,handleThrow 捕获后挂起该子树并注册 ping 监听。settle 后 ping 触发重渲染,此时同一个 use 调用直接返回值。
Q8:View Transition 有 lane 门槛,为什么?
因为 View Transition 需要与"非紧急、非阻塞"的更新配合才不会打断用户输入。只有 TransitionLanes | RetryLanes | IdleLane 有资格启动(见 §8.14.1),同步更新直接走普通 commit。
Q9:并发渲染会不会导致 tearing(撕裂)?
会,如果外部可变状态在渲染中途被改写。解法是 useSyncExternalStore——它在 commit 阶段做一次一致性检查,发现版本漂移就强制同步重渲染。这是并发引入的全新约束。
Q10:workLoopConcurrent 和 workLoopConcurrentByScheduler 差在哪?
前者用 shouldYield()(基于 Scheduler 的 5ms 帧切片),后者用于 act 环境与 enableAlwaysYieldScheduler 分支,让出策略不同。生产默认走前者。
上篇小结
上篇建立了并发渲染的完整骨架:能不能切片(shouldTimeSlice)、怎么切片(renderRootConcurrent + 三种 work loop)、切片到一半被中断时状态存哪(模块级"栈外栈")、一次渲染有哪六种结局(RootExitStatus)、以及谁有资格打断谁(Lane 优先级 + 过期 + retry)。
**下篇(8.15 起)**接着往下走:并发下的错误处理、SSR 预渲染的并发路径、useDeferredValue 的完整状态机、async action 的等待机制、调试并发渲染的实操、以及从更新到像素的完整时序。
说明:本章篇幅较大,分为上下两篇;收束性的全章索引表与完整 FAQ 位于下篇末尾(§8.25 / §8.26)。