React 18三:Concurrent Mode

React 18 里没有一个叫 Concurrent Mode 的开关可以打开。真正存在的是三样东西:5 毫秒的时间片、31 条车道的优先级阶梯,和一个完全不认识 React 的任务队列 —— 这一篇把一次更新从 setState 走到工作循环的路径排一遍。

位置
第 03 篇 / 共 6 篇
预计
7 分钟
系列
React 18

先把标题里那个词处理掉:React 18 里没有一个叫 Concurrent Mode 的东西可以打开。 官方发布博客的原话是,并发本身不算一个特性,它是一套幕后机制,让 React 能同时准备 UI 的好几个版本。后来的文档一律改口叫并发渲染或者并发特性,「模式」这个说法被撤回了。

上一篇已经在源码里看到过这件事怎么落实的:allowConcurrentByDefault 写死为 false,首次挂载和普通的 setState 走的都是 workLoopSync,那个循环里没有让出主线程的机会。

所以「并发」在 18 里不是一个状态,而是三样具体的东西:一个 5 毫秒的时间片、一条 31 级的优先级阶梯,以及一个完全不认识 React 的任务队列。

时间片是 5 毫秒

决定「该让出主线程了吗」的函数叫 shouldYieldToHost,在 packages/scheduler/src/forks/Scheduler.js

forks/Scheduler.js · shouldYieldToHost(节选)
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// 这一片还没用完,接着干
return false;
}
// 省略:一段由 isInputPending 提前让路的实验性分支
return true;
}

frameInterval 的初值是 frameYieldMs,而它定义在 packages/scheduler/src/SchedulerFeatureFlags.js,值是 5

名字里的 frame 容易让人往「一帧 16.7 毫秒」上想,但这个数就是 5。让出去之后浏览器要做的不只是画一帧 —— 样式计算、布局、绘制、以及别的任务都排在那儿,5 毫秒是 React 给自己留的份额。

有一点上一篇已经说过,这里再钉一次:这个数只在 workLoopConcurrent 里起作用。workLoopSync 的更新压根不问这个问题,跑多久都不让。

一条 31 级的阶梯

优先级的定义在 packages/react-reconciler/src/ReactFiberLane.new.js

ReactFiberLane.new.js · 车道常量(节选)
export const TotalLanes = 31;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000001;
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000000100;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultLane: Lane = /* */ 0b0000000000000000000000000010000;
// 中间依次是 1 条 TransitionHydrationLane、16 条 TransitionLane、5 条 RetryLane,
// 这几组都没有 export,只在模块内部用
export const SelectiveHydrationLane: Lane = /* */ 0b0001000000000000000000000000000;
export const IdleHydrationLane: Lane = /* */ 0b0010000000000000000000000000000;
export const IdleLane: Lane = /* */ 0b0100000000000000000000000000000;
export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000;

位越靠右,优先级越高。31 条车道正好填满一个 31 位整数,这也是 TotalLanes 的由来。

高亮的两行是最容易记混的一对。 名字带 Hydration 的那几条是注水专用的,普通的 setState 拿到的是 DefaultLane,不是 DefaultHydrationLane。这一组车道在这个系列里露过面 —— Selective Hydration 那篇里用户点到未注水的组件,走的就是 SelectiveHydrationLane

优先级不是你填的,是事件类型定的

车道从哪儿来?答案在 packages/react-reconciler/src/ReactEventPriorities.new.js,四个常量直接就是四条车道的别名:

ReactEventPriorities.new.js
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;

名字说明了分类的依据:离散事件是一次一个、彼此独立的动作 —— 点击、按键、提交,它们拿最高的 SyncLane,因为用户在等一个明确的回应。连续事件是会连成一串的 —— 移动、滚动,它们拿 InputContinuousLane,抬高但不至于霸占。其余一切走 DefaultLane

这条链上唯一留给你的口子是 startTransition:它把一次更新推到 transition 车道去。而 transition 不属于 includesBlockingLane 认的那几条,于是 shouldTimeSlice 成立,渲染才真的走进 workLoopConcurrent

上一篇拆的是这条因果的后半段,这一篇补上前半段 —— 合起来是完整的一句:标成 transition 是让一次更新变得可中断的唯一办法。

Scheduler 里是两个小顶堆

scheduler 是个独立的包,里面没有任何 React 的概念。它维护两个队列,都是小顶堆(packages/scheduler/src/SchedulerMinHeap.js):

  • taskQueue —— 已经该跑的任务,按 expirationTime 排序。
  • timerQueue —— 还没到时间的延迟任务,按 startTime 排序,到点了挪进 taskQueue

expirationTime 是这么来的:unstable_scheduleCallback 按优先级查一张表,拿到超时时长,再加上当前时间。表就在 forks/Scheduler.js 里:

forks/Scheduler.js · 五档超时(原样)
var maxSigned31BitInt = 1073741823;
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

这张表回答了一个实际问题:低优先级会不会被永远插队插到饿死。 不会 —— 一个普通优先级的任务排了 5 秒还没轮上,它的 expirationTime 就过了,过期的任务不再让出主线程,一口气做完。React 那边有一份对应的机制,上一篇 shouldTimeSlice 里的 !includesExpiredLane(root, lanes) 就是它:更新一旦过期,就不再切片。

还有一件事容易混:Scheduler 的优先级和 lane 不是一套东西。 lane 有 31 条,是 React 的;Scheduler 只认五档(ImmediatePriorityIdlePriority,定义在 SchedulerPriorities.js)。两者之间需要一个翻译层,就在下一节。

至于「让出去之后怎么再回来」,Scheduler 用的是宿主环境的异步入口,优先 setImmediate,没有就用 MessageChannel,再没有才退到 setTimeout。浏览器里走的是 MessageChannel 那条 —— 源码里的注释写明了原因:setTimeout 有 4 毫秒的最小间隔钳制。

从 setState 到工作循环

把前面几段串起来,一次更新经过的路径是这样的:

scheduleUpdateOnFibermarkRootUpdated(把车道记到根节点上)→ ensureRootIsScheduled(决定交给谁)→ 工作循环。

分岔就在 ensureRootIsScheduledpackages/react-reconciler/src/ReactFiberWorkLoop.new.js

ReactFiberWorkLoop.new.js · ensureRootIsScheduled 的分岔(简化)
let newCallbackNode;
if (newCallbackPriority === SyncLane) {
// 同步车道:进 React 自己的同步队列,不走 Scheduler
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
if (supportsMicrotasks) {
scheduleMicrotask(() => { /* 省略:冲刷同步队列 */ });
} else {
scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
}
newCallbackNode = null;
} else {
// 其余车道:把 lane 翻译成 Scheduler 的五档之一
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
}

那个 switch 就是上一节说的翻译层:31 条车道压成 5 档。

左边那条分支还藏着一件常被单独介绍的事:同步更新不是当场就跑,而是排一个微任务去冲刷队列。一次事件里连着写三个 setState,它们会先进队列,等微任务跑起来时合并成一次渲染 —— 这就是 React 18 自动批处理的落点。

performConcurrentWorkOnRoot 之后的事上一篇拆过:shouldTimeSlice 决定走 renderRootConcurrent 还是 renderRootSync,然后才是那两个只差一个条件的工作循环。

代价

时间切片不是免费的加速,它换的是另一样东西:

  • 总时间会变长。 切片本身有开销,让出去再回来要重新进调度、重新取任务。换来的是这段时间里页面还能响应 —— 吞吐换延迟,这笔交易在渲染量大的时候才划算。
  • 切不开你自己的循环。 切片的最小粒度是一个 Fiber 节点:performUnitOfWork 处理一个节点的过程是原子的,中途不检查时间。所以某个组件函数里跑一个 200 毫秒的 for 循环,并发渲染一点忙都帮不上。这是最常被误解的一条 —— 它切的是「组件多」,不是「单个组件慢」。
  • 默认更新根本不切。 升级到 18、换成 createRoot,然后期待页面自己变流畅,不会发生。得动手标 transition。
  • commit 阶段不切。 一次性往 DOM 里插两万个节点,18 和 17 一样卡。

所以能让一次更新真正变成可中断的动作只有一个:startTransition。它写起来什么样、哪些更新不该包进去,是落地那一篇要回答的。

这篇是 React 18的第 3 篇。前一篇是 React 18二:Fiber 架构,后一篇是 React 18 四:Suspense 与异步渲染