6 月 8 日 React 团队发了 The Plan for React 18,正式公布 React 18 Alpha,同时建了一个 React 18 Working Group 让社区提前试用和反馈。master 分支上对应的变化很直接:48a11a3efc 把 @next 频道的版本号从 0.0.0-
上一篇看了 Fiber 的数据结构,结尾提到 lanes 和 childLanes 两个字段取代了 expirationTime。这篇把这个字段打开看:一次更新怎么被分配到某条 lane,31 个二进制位怎么分组,位运算函数都做了什么,以及 React 16 的 expirationTime 模型为什么被放弃。参考代码是 master 分支 cb30388d,并发相关的实现都在 .new.js fork 里,这一篇的主要对象是 packages/react-reconciler/src/ReactFiberLane.new.js。
系列目录
| 日期 | 标题 |
|---|---|
| 06-08 | 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构 |
| 06-10 | React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime(本篇) |
一次更新怎么拿到车道
setState 触发后,第一个和优先级打交道的函数是 requestUpdateLane(packages/react-reconciler/src/ReactFiberWorkLoop.new.js)。它的分支不多,按顺序排除:
- fiber 不在 ConcurrentMode 下,直接返回 SyncLane。目前所有用 ReactDOM.render 挂载的应用都走这条分支。
- render 阶段触发的更新,从当前正在渲染的 lanes 里挑一条返回,让这次更新跟着当前渲染一起提交。
- 处于 transition 中(
requestCurrentTransition()有值),调claimNextTransitionLane()领一条 transition 车道。currentEventTransitionLane会把同一事件里的第一次分配缓存住,保证同一个事件里的所有 transition 拿到同一条 lane。 - flushSync 这类 API 触发的更新,优先级由
setCurrentUpdatePriority提前放进上下文变量,这里直接取。flushSync 放进去的就是 DiscreteEventPriority,也就是 SyncLane。 - 其余情况问宿主环境要事件优先级。
packages/react-reconciler/src/ReactEventPriorities.new.js里定义了这个映射:DiscreteEventPriority 就是 SyncLane,ContinuousEventPriority 是 InputContinuousLane,DefaultEventPriority 是 DefaultLane,IdleEventPriority 是 IdleLane。也就是说在并发模式下,点击、键盘这类离散事件直接拿最高优先级的 SyncLane,scroll、mousemove 这类连续事件拿 InputContinuousLane。
拿到 lane 之后,markUpdateLaneFromFiberToRoot 用 mergeLanes 把它合并进 sourceFiber.lanes,然后沿 return 指针一路向上,把路径上每个父节点的 childLanes 都合并上这条 lane,直到 HostRoot。lanes 标记自己、childLanes 标记子树的分工,就是在这一步写入的。root 一侧还有 pendingLanes、suspendedLanes、pingedLanes 几张位掩码,分别记录整棵树上待处理、被挂起、已就绪的 lane,下一篇讲调度选择时会用到。markRootUpdated 同时会在 root.eventTimes 这个 LaneMap 里给这条 lane 记下当前时间戳,它就是后面判断「这条 lane 饿了多久」的依据。
补充一个时间线细节。render 阶段更新那条分支外面挂着 deferRenderPhaseUpdateToNextBatch 这个 feature flag,6 月 2 日的 1d3558965f 刚把它默认关掉,所以目前 render 阶段更新会直接并入当前渲染的 lanes。这类更新本来就不被官方支持,flag 的存在只是为了迁移期兼容。
31 个位的布局
ReactFiberLane.new.js 文件开头是类型定义:
export type Lanes = number;
export type Lane = number;两个都是 number。Lane 是单条车道,二进制里只有一个 1;Lanes 是车道集合,任意多个位可以是 1。TotalLanes = 31,常量区把 31 个位全部命了名,从最低位(第 0 位)开始优先级递减。常量区上方还有一行注释,提醒这些值要和 getLabelsForLanes 保持同步,那是给 devtools 的 scheduling profiler 出标签用的,改了位图记得重建那个包。
- 第 0 位 SyncLane:同步更新。离散事件和 flushSync 走这条。
- 第 1、2 位 InputContinuousHydrationLane、InputContinuousLane:连续输入,scroll、drag 这类短时间高频触发的事件。
- 第 3、4 位 DefaultHydrationLane、DefaultLane:默认优先级,普通 setState。
- 第 5 位 TransitionHydrationLane,第 6 到 21 位是 16 条 TransitionLane。transition 是 18 引入的概念,指可以被打断的非紧急更新。给 16 条独立车道是为了让多个 transition 互不阻塞,分配方式后面讲。
- 第 22 到 26 位,5 条 RetryLane:Suspense 挂起之后的重试。
- 第 27 位 SelectiveHydrationLane:选择性水合。
- 第 28、29 位 IdleHydrationLane、IdleLane:空闲时才做的更新。
- 第 30 位 OffscreenLane:Offscreen 组件后台预渲染用。6 月 1 日合入的 e16d61c300([Offscreen] Mount/unmount layout effects)就在补这块的 effect 逻辑。
带 Hydration 后缀的都是水合场景的变体,排在对应普通 lane 前面,含义是水合期间的同类更新比普通更新稍微紧急一点。
为什么是 31 位而不是 32 位:JavaScript 的位运算把操作数按 32 位有符号整数处理,最高位是符号位,用了它就有负数参与进来,所以只用低 31 位。可以顺手核对一下总数:10 条独立车道,加 16 条 transition,加 5 条 retry,正好 31。
文件里还有一个 NonIdleLanes 掩码,盖住第 0 到 27 位。getNextLanes 用它把 idle 工作挡在最后:只要还有非 idle 的待处理更新,idle lane 就不会被选中。
位运算工具函数
整个文件的操作全是位运算,没有数组,没有对象。核心的几个:
export function getHighestPriorityLane(lanes: Lanes): Lane {
return lanes & -lanes;
}
export function mergeLanes(a, b) { return a | b; }
export function removeLanes(set, subset) { return set & ~subset; }
export function isSubsetOfLanes(set, subset) { return (set & subset) === subset; }
export function includesSomeLane(a, b) { return (a & b) !== NoLanes; }
export function intersectLanes(a, b) { return a & b; }lanes & -lanes 值得展开说。补码表示下,-lanes 等于 ~lanes + 1,两者相与的结果是只保留 lanes 里最低的那个 1,其余位全部清零。比如 lanes 是 0b0110000,-lanes 的最低几位是 0b1010000,相与得 0b0010000。一次运算就拿到了最高优先级的 lane,不用循环,不用查表。
这个技巧成立的前提是布局约定:值越小,优先级越高。高优先级的 lane 放低位,最低位的 1 就是最高优先级。源码注释里把这个约定写明了:「the bits decrease in priority as you go left」,位越靠左优先级越低。getNextLanes 判断要不要打断正在进行的渲染时直接用了这个性质:
// Tests whether the next lane is equal or lower priority than the wip
// one. This works because the bits decrease in priority as you go left.
nextLane >= wipLane优先级比较退化成了普通的数字比较。higherPriorityLane(a, b) 同样只是一句 a !== NoLane && a < b ? a : b。
另有一层「同组」语义。getHighestPriorityLanes 拿到最高优先级的 lane 之后,如果它落在 TransitionLanes 里,返回的是 lanes & TransitionLanes;落在 RetryLanes 里则返回 lanes & RetryLanes,把组内所有待处理的 lane 一起带回来。transition 之间没有先后关系,16 条车道是并列资源,同一批渲染会把组内能带的都带上。Sync、Default 这些单条车道就返回自己。
还有两个小函数值得知道。pickArbitraryLaneIndex 用 31 - clz32(lanes) 把 lane 换成数组下标,eventTimes 这类按 lane 索引的 LaneMap 靠它存取。laneToLanes 本体是原样返回,注释说它只是把 Flow 类型从 Lane 转成 Lanes,一条给更新用,一组给调度用。
调用方常用的另有两个判定函数,includesOnlyRetries 和 includesOnlyTransitions,实现都是 (lanes & RetryLanes) === lanes 这样的整体比较。它们的用途能说明分组判断的实际价值,两处都在 ReactFiberWorkLoop.new.js 处理渲染退出状态的分支里。渲染挂起(RootSuspended)时,如果这批 lane 全是 retry,work loop 会把 fallback 的提交节流一个固定窗口,避免 loading 状态闪得太快;挂起带延迟(RootSuspendedWithDelay)时,如果这批 lane 全是 transition,直接不提交占位内容,等数据到了再说,因为 transition 语义上允许停留在旧界面。
transition 和 retry 车道的轮转分配
requestUpdateLane 里 transition 更新走 claimNextTransitionLane,实现只有几行:
let nextTransitionLane: Lane = TransitionLane1;
export function claimNextTransitionLane(): Lane {
const lane = nextTransitionLane;
nextTransitionLane <<= 1;
if ((nextTransitionLane & TransitionLanes) === 0) {
nextTransitionLane = TransitionLane1;
}
return lane;
}一个模块级变量记住上次发到哪条,每次左移一位,移出 TransitionLanes 的范围就绕回 TransitionLane1,16 条车道循环使用。注释里写了意图:多数情况下每个 transition 独占一条车道,直到车道用完才从头复用。
为什么要让 transition 各占一条,而不是所有 transition 共享一条。如果共享,一批 transition 在同一个 Lanes 集合里就只有合并和全清两种操作,一个挂起会把另一个也拖进 suspendedLanes。各占一条之后,每条车道的 pending、suspended、pinged、expired 状态都独立:搜索联想词的渲染挂起时,切换 tab 的渲染可以照常完成。车道总数是资源上限,同时存在 17 个 transition 时第 17 个会和第 1 个共用一条,这是 31 位总量约束下的取舍。
Retry 车道是同样的模式,claimNextRetryLane 在 5 条 RetryLane 里轮转,Suspense 挂起后的重试从这里领。另外有个和错误处理相关的函数 getLanesToRetrySynchronouslyOnError:渲染抛错需要同步重试时,它返回 pendingLanes 里除 OffscreenLane 之外的全部。代码里还有一个「只剩 OffscreenLane 就返回 OffscreenLane」的兜底分支,但按现在的写法这个分支的条件永远为假(它检测的变量已经把 OffscreenLane 排除掉了),是个没生效的死分支,照实记录。
这套模型不是 18 才发明的
查 git log 会发现 lane 模型的历史比 React 18 早不少。93e078ddf2「Initial Lanes implementation (#18796)」2020 年 5 月 2 日就进了 master 的 .new fork,6 月 11 日 8f05f2bd6d 把它搬进了 old fork。React 17 发布时这套代码已经随包发出去了:v17.0.2 的源码里有完整的 packages/react-reconciler/src/ReactFiberLane.js(没有 .new/.old 后缀的那份),17 的内部调度跑的已经是 lane 模型。
准确的说法是:React 17 完成了从 expirationTime 到 lanes 的底层替换,但 17 没有并发入口,所有更新集中在 SyncLane 附近的几条车道,31 个位大部分空着,模型基本空转。React 18 做两件事:把并发入口 createRoot 暴露出来,让模型真正转起来;同时继续修形。今年 3 月 dcdf8de7e1「Remove discrete lanes and priorities」删掉了 InputDiscrete 那一组,离散输入不再需要单独的车道,直接并入 SyncLane;5 月 df420bc0a3 删掉了 LanePriority 枚举类型。v17.0.2 里还有一层 LanePriority,从 SyncLanePriority = 15 排到 NoLanePriority = 0,getHighestPriorityLanes 用一个叫 return_highestLanePriority 的全局变量当寄存器,顺带把优先级等级返回给调用方。这层删掉之后,lane 本身就是优先级,排序和比较全靠位序,代码干净了不少。
顺带交代一句 fork 状态。上一篇说过源码是 .new.js 和 .old.js 双 fork,但这份文件现在两个 fork 的内容完全一致,唯一差异是各自 import 对应后缀的 DevToolsHook。ReactFiberLane.old.js 里同样是 16 条 transition、同样有 OffscreenLane。也就是说上面讲的布局对两个 fork 同时成立,它不依赖任何并发 feature flag,已经是两边共享的地基。
expirationTime 为什么被放弃
作为对照,看 React 16 的实现。v16.13.1 的 packages/react-reconciler/src/ReactFiberExpirationTime.js 里,优先级是一个 31 位整数表示的过期时间:Sync = MAX_SIGNED_31_BIT_INT,即最大值,其余按时间算。msToExpirationTime 的换算方式是 10 毫秒一个单位,从一个接近 Sync 的偏移量往下减,所以时间越晚数值越小;过期时间越大优先级越高,数值大小和优先级同向。这个方向和 lane 模型正好相反,lane 是数值越小优先级越高,两套代码对照着读时这是最容易混的一点。
分档由 computeExpirationBucket(currentTime, expirationInMs, bucketSizeMs) 完成:把「当前时间加过期时长」向上取整到批大小的整数倍。交互更新 150 毫秒过期、100 毫秒一档,意味着 100 毫秒窗口内发生的交互更新共享同一个过期时间,自然合成一批;普通异步更新 5000 毫秒过期、250 毫秒一档。
这份文件里有一段值得看的注释。HIGH_PRIORITY_EXPIRATION 在开发环境是 500 毫秒,生产环境只有 150 毫秒,注释解释了为什么故意不对称:交互更新拖到过期才执行,说明主线程被长时间阻塞,这本该用更好的调度来解决;开发环境给长窗口,开发者更容易注意到卡顿并修掉它;生产环境选择快速过期保住用户体验,代价是掩盖调度问题。模型本身没有时间维度以外的信息,调度器要用优先级时还得靠 inferPriorityFromExpirationTime 把过期时间反算回 Immediate、UserBlocking、Normal、Idle 几个等级。优先级到时间、时间再回优先级,中间经过一次有损换算。
这个模型在 React 16 的场景下够用,放到并发的目标下有三个问题。
第一,一个 fiber 上只有一个 expirationTime 字段。树上同时存在不同优先级的更新时,这个字段只能记下数值最大的那个,较低优先级的更新虽然还留在更新队列里,fiber 这一层的调度信息已经丢了它。想表达「树上还有一个 Default 更新没做」都做不到,更别说「有一个 Default 和一个 Transition 分别在等」。
第二,优先级和过期时间编码在同一个数里,区分不了「同一优先级的两个独立任务」。两个 transition 应该各自渲染、各自挂起、各自恢复,但时间对齐之后它们是同一个 expirationTime,分不开。lane 模型给 transition 16 条独立车道,每个 transition 占一条,挂起和恢复互不影响,这在单值模型里没有对应物。
第三,表达不了状态。lanes 模型里 root 上有 suspendedLanes 和 pingedLanes 两张掩码,一条 lane 可以被标记成「挂起中,等数据」,数据到了再 ping 回来,调度时跳过 suspended、优先处理 pinged。单个时间数值没有这个维度。
反过来,lanes 是位集合:fiber.lanes 用 31 个位同时标记多种优先级的待办,root 用几张平行的掩码管理状态,合批用 mergeLanes 取并集,渲染完用 removeLanes 清掉。expirationTime 的「过期」语义也没有丢,下一篇会讲 markStarvedLanesAsExpired 怎么按 lane 记录时间戳,把饿太久的 lane 提升为过期。等价的能力换了一种存法,换来的是多优先级并存和按组操作。
这一篇把 lane 的静态结构看完了:31 个位的分组、位运算工具、和旧模型的对照。下一篇看动态部分,getNextLanes 怎么从 pendingLanes 里选出这一批要渲染的 lane,饥饿保护怎么兜底,以及 transition 车道的轮转分配在实际场景里怎么运作。

