React 18三:Concurrent Mode
React 18 里没有一个叫 Concurrent Mode 的开关可以打开。真正存在的是三样东西:5 毫秒的时间片、31 条车道的优先级阶梯,和一个完全不认识 React 的任务队列 —— 这一篇把一次更新从 setState 走到工作循环的路径排一遍。
先把标题里那个词处理掉:React 18 里没有一个叫 Concurrent Mode 的东西可以打开。 官方发布博客的原话是,并发本身不算一个特性,它是一套幕后机制,让 React 能同时准备 UI 的好几个版本。后来的文档一律改口叫并发渲染或者并发特性,「模式」这个说法被撤回了。
上一篇已经在源码里看到过这件事怎么落实的:allowConcurrentByDefault 写死为 false,首次挂载和普通的 setState 走的都是 workLoopSync,那个循环里没有让出主线程的机会。
所以「并发」在 18 里不是一个状态,而是三样具体的东西:一个 5 毫秒的时间片、一条 31 级的优先级阶梯,以及一个完全不认识 React 的任务队列。
时间片是 5 毫秒
决定「该让出主线程了吗」的函数叫 shouldYieldToHost,在 packages/scheduler/src/forks/Scheduler.js:
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:
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,四个常量直接就是四条车道的别名:
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 里:
var maxSigned31BitInt = 1073741823;// Times out immediatelyvar IMMEDIATE_PRIORITY_TIMEOUT = -1;// Eventually times outvar USER_BLOCKING_PRIORITY_TIMEOUT = 250;var NORMAL_PRIORITY_TIMEOUT = 5000;var LOW_PRIORITY_TIMEOUT = 10000;// Never times outvar IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;这张表回答了一个实际问题:低优先级会不会被永远插队插到饿死。 不会 —— 一个普通优先级的任务排了 5 秒还没轮上,它的 expirationTime 就过了,过期的任务不再让出主线程,一口气做完。React 那边有一份对应的机制,上一篇 shouldTimeSlice 里的 !includesExpiredLane(root, lanes) 就是它:更新一旦过期,就不再切片。
还有一件事容易混:Scheduler 的优先级和 lane 不是一套东西。 lane 有 31 条,是 React 的;Scheduler 只认五档(ImmediatePriority 到 IdlePriority,定义在 SchedulerPriorities.js)。两者之间需要一个翻译层,就在下一节。
至于「让出去之后怎么再回来」,Scheduler 用的是宿主环境的异步入口,优先 setImmediate,没有就用 MessageChannel,再没有才退到 setTimeout。浏览器里走的是 MessageChannel 那条 —— 源码里的注释写明了原因:setTimeout 有 4 毫秒的最小间隔钳制。
从 setState 到工作循环
把前面几段串起来,一次更新经过的路径是这样的:
scheduleUpdateOnFiber → markRootUpdated(把车道记到根节点上)→ ensureRootIsScheduled(决定交给谁)→ 工作循环。
分岔就在 ensureRootIsScheduled,packages/react-reconciler/src/ReactFiberWorkLoop.new.js:
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 与异步渲染。