React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime

2 分钟阅读
·

6 月 8 日 React 团队发了 The Plan for React 18,正式公布 React 18 Alpha,同时建了一个 React 18 Working Group 让社区提前试用和反馈。master 分支上对应的变化很直接:48a11a3efc 把 @next 频道的版本号从 0.0.0- 改成 18.0.0-alpha-,commit message 里特意说明 18 不会马上发布,版本号变化只表示 main 分支开始包含破坏性变更;第二天 aecb3b6d11 给 ReactDOM.render 和 ReactDOM.hydrate 打上废弃警告,指向新的 createRoot。追了几个月的分支终于有了正式的版本号。

上一篇看了 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 触发后,第一个和优先级打交道的函数是 requestUpdateLanepackages/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 之后,markUpdateLaneFromFiberToRootmergeLanes 把它合并进 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 出标签用的,改了位图记得重建那个包。

31 位 Lane 分组位图

  • 第 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 这些单条车道就返回自己。

还有两个小函数值得知道。pickArbitraryLaneIndex31 - clz32(lanes) 把 lane 换成数组下标,eventTimes 这类按 lane 索引的 LaneMap 靠它存取。laneToLanes 本体是原样返回,注释说它只是把 Flow 类型从 Lane 转成 Lanes,一条给更新用,一组给调度用。

调用方常用的另有两个判定函数,includesOnlyRetriesincludesOnlyTransitions,实现都是 (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 车道的轮转分配在实际场景里怎么运作。


955 字 · 55 段落
xi ming

Written by xi mingFollow onGitHub