☰
React 19 位运算全解析:从 Lane 优先级到 Fiber 副作用标记
2026/10/18 19:58:30 网站建设 项目流程

1. 先搞清楚位运算在 React 里到底管什么

读 React 19 源码的人迟早会撞上同一面墙:到处都是pendingLanes |= lane、flags & Placement、flags &= ~Mask这种"非人话"代码。我第一次看到getHighestPriorityLane的实现时愣了很久,因为整个函数只有一个表达式:

function getHighestPriorityLane(lanes) { return lanes & -lanes; }

没有循环、没有分支、没有数组遍历,连一行业务逻辑都没写,就把"一堆待处理优先级里挑出最高那个"的问题解决了。这背后不是炫技,而是 React 在数据表示层面做了一个非常关键的选择:把一组可叠加、互不干扰的"开关状态"全部编码进一个 32 位整数,用 CPU 原生的位运算来读写这些状态。调度器这么干,Fiber 也这么干,提交阶段的核心判断还是这么干。

1.1 一个整数就是一张"开关面板"

理解 React 位运算的第一步,是理解 bitmap(位图)思想。把数字先转成二进制看,每一位就是一路开关:

  • 第 0 位(bit 0)代表 1 号开关,
  • 第 1 位(bit 1)代表 2 号开关,
  • 第 2 位(bit 2)代表 3 号开关,
  • 依此类推。

某个 bit 是 1,表示对应的功能"要发生"或"已激活";是 0 表示"不发生"或"未激活"。

let flags = 0b0000; // 所有开关都关 flags |= 0b0001; // 打开 1 号开关 flags |= 0b1000; // 打开 4 号开关 // 此时 flags = 0b1001 if (flags & 0b0001) { /* 1 号开关是开的 */ } flags &= ~0b0001; // 只关掉 1 号开关,其他开关不受影响

|用来"打开"某些位,&用来"检查"某些位是否开启,& ~用来"关闭"某些位。三句话就能概括 React 内部九成位运算的使用方式。剩下两成是^(切换)和& -x(取最低位)这类技巧,后面会专门讲。

注意:JavaScript 的位运算会把操作数转成 32 位有符号整数,结果也是 32 位有符号整数。这意味着一个数字最多能安全地管理 31 个开关位(最高一位是符号位,React 刻意不用)。这正是 React 的 Lane 总数被限制为 31 的原因,后面章节会展开。

1.2 React 19 里位运算出没的三大区域

把"位图"这个底层思想装进脑子之后,React 源码里的位运算就分成了三大阵营:

  • Lane 优先级系统:调度器用 31 个 bit 记录"哪些优先级的更新正在等待处理"。root.pendingLanes |= lane一句话就把一个更新挂进队列,getHighestPriorityLane(lanes)一行就能算出下一个要执行的优先级。
  • Fiber 副作用标记:每个 Fiber 节点在渲染过程中会产生副作用(插入 DOM、更新属性、删除节点、触发回调等),这些副作用全部被打包在fiber.flags这一个整数里。提交阶段用flags & Placement判断"这个节点要不要插进页面"。
  • 复合掩码(Mask)与子树标记:把多个副作用位预先 OR 成一个语义化的大掩码(比如"所有需要操作 DOM 的标记"),再用一次&判断整个子树有没有这类工作,从而整棵跳过硬遍历。

这三个区域互相配合:Lane 决定"什么时候做",flags 决定"做什么",Mask 决定"从哪下手最省力"。React 19 里新增的 Actions、useOptimistic、useActionState,表面上给开发者返回布尔值isPending,底层流转的其实全是 Lane 二进制位。理解了位运算,再看这些新特性会通透得多。

2. Lane 优先级系统:位运算的主战场

2.1 为什么调度器需要"车道"

React 18 之前用 ExpirationTime(过期时间)来排优先级,是一个"越快到截止时间越紧急"的模型。从 React 18 开始,团队换成了 Lane(车道)模型,React 19 继续沿用。Lane 的本质是 31 条命名的车道,每条车道对应一个 bit,值越小优先级越高。

export const TotalLanes = 31; export const NoLane = 0b0000000000000000000000000000000; export const SyncLane = 0b0000000000000000000000000000001; export const InputContinuousLane = 0b0000000000000000000000000000100; export const DefaultLane = 0b0000000000000000000000000010000; export const TransitionLane1 = 0b0000000000000000000000001000000; // ... 后面还有 TransitionLane2 ~ TransitionLane16 等

注意看这些常量:SyncLane只占 1 个 bit,InputContinuousLane占 1 个 bit,DefaultLane占 1 个 bit,而TransitionLane从TransitionLane1一直排到TransitionLane16。为什么过渡更新要独占 16 条车道?因为过渡(Transition)更新可能并发存在多个,比如用户连续点击两个 tab,每个 tab 都触发一个过渡更新。每条过渡更新需要独立占一条车道,才能被打断、被重新调度、被判断"哪一条先完成"。

这个设计直接带来一个好处:同一时刻可以有条理地记录多批不同优先级的更新。root.pendingLanes是一个整数,某个 bit 是 1,就表示对应的那条车道上有未处理的更新。多个更新同时存在,就把它们的 bit 全部 OR 起来。

2.2 React 19 的核心位运算操作

调度器里最常用的几个位运算操作,全部来自ReactFiberLane.js,实现短得让人怀疑人生:

export function mergeLanes(a, b) { return a | b; // 合并两组 lane } export function includesSomeLane(a, b) { return (a & b) !== NoLanes; // 两组 lane 是否有交集 } export function isSubsetOfLanes(a, b) { return (a & b) === a; // a 是不是 b 的子集 } export function removeLanes(set, subset) { return set & ~subset; // 从 set 中去掉 subset } export function getHighestPriorityLane(lanes) { return lanes & -lanes; // 取出最低位的 1 }

mergeLanes是"把多个 lane 合并成一坨待处理状态",在markRootUpdated里被调用,形式是root.pendingLanes |= updateLane。includesSomeLane是"这批更新里有没有包含某种优先级",比如判断某个 Transition 是否还在进行时,就用它去看 pendingLanes 和目标 lane 有没有交集。removeLanes则是"这项更新处理完了,把它从 pending 状态里划掉",核心就一句set & ~subset。

我之前一直觉得这些函数太简单,直到自己动手实现一个小调度器才发现,想用其他数据结构达到同样的语义,绕来绕去都比这几行啰嗦。位运算之所以能这么精简,是因为它把"集合操作"直接映射成了 CPU 整数指令。"求交集""求差集""判断包含"这些在普通语言里需要循环或哈希的语义,在 bit 上都是一个单词。

2.3 getHighestPriorityLane 是怎么做到一行算完的

最惊艳的还是lanes & -lanes。这里用到了补码的性质:一个数的相反数,等于对它取反再加一。假设lanes = 0b0001010,表示第 1 位和第 3 位两个 lane 处于 pending 状态(注意这里 bit 位越低优先级越高,所以第 1 位代表更高优先级)。

lanes = 0b0001010 -lanes = 0b1110110 // ~lanes + 1,补码 lanes & -lanes = 0b0000010

结果刚好把最低位的那个 1 单独取了出来。因为~lanes + 1会让最低位的 1 保持不变、其余高位全部取反,再跟原数做&,高位全归零,只有最低位的 1 幸存。在 Lane 模型里,最低位的 1 就是当前最高优先级的待处理 lane。

任何其他方案要做到"从 31 个 lane 里找出值最小且非空的那个",至少得写一个循环加一次最小值比较,而这里是一条位运算指令。React 在调度热路径上每天要执行这种操作成千上万次,省掉的循环累积起来相当可观。

3. Fiber 副作用标记:提交阶段靠位掩码加速

3.1 flags 与 subtreeFlags 的分工

Lane 解决了"什么时候更新",Fiber 标记解决"更新时干什么"。每个 Fiber 节点上有一个flags字段,类型也是整数。React 19 的ReactFiberFlags.js里定义了一大堆常量,结构大致如下(具体 bit 位置随版本略有调整,但不影响理解):

export const NoFlags = 0b00000000000000000000000000; export const Incomplete = 0b00000000000000000000000001; export const ShouldCapture = 0b00000000000000000000000010; export const Passive = 0b00000000000000000000001000; export const Placement = 0b0000000000000000000001000000; export const Update = 0b0000000000000000000010000000; export const ChildDeletion = 0b0000000000000000001000000000;

Placement表示"这个节点新插入 DOM",Update表示"这个节点属性/状态有变化需要更新",ChildDeletion表示"它的某个子节点被删了",Passive表示"有 useEffect 等被动副作用要执行"。一次提交可能既插入节点又更新节点,所以fiber.flags |= Placement | Update,用一个整数的两个 bit 同时记录两种副作用。

subtreeFlags是 React 18 之后引入的关键优化字段。它表示"这棵子树里所有后代节点的 flags 合并结果"。每次处理完子节点,父节点就把所有子树的subtreeFlags | flags汇总到自己身上:

// 简化示意:合并子节点的工作标记 subtreeFlags |= (child.flags | child.subtreeFlags);

这样做的好处立竿见影:提交阶段沿着 Fiber 树往下走时,只要看一眼某个节点的subtreeFlags & MutationMask === NoFlags,就知道这整棵子树没有任何需要动 DOM 的工作,直接跳过,不用遍历它的全部后代。

3.2 Mask 把一堆标记打包成一个"套餐"

如果每次都手写flags & (Placement | Update | ChildDeletion),代码会又长又容易漏。React 的做法是把常用组合预先 OR 成语义化掩码:

export const MutationMask = Placement | Update | ChildDeletion; export const LayoutMask = Update | Callback | RefStatic | Visibility; export const PassiveMask = Passive | ChildDeletion;

MutationMask的含义是"所有需要提交阶段进行 DOM 变更的标记",PassiveMask是"所有需要触发被动副作用(比如 useEffect 回调)的标记"。如此划分之后,提交阶段的每个子阶段只需要一个&判断,就能知道是否需要对一棵子树做对应工作:

if ((finishedWork.subtreeFlags & MutationMask) !== NoFlags) { // 这棵子树里有活要干,进去处理 }

我在调试 React 19 的一次复杂列表更新时,亲眼见过这个机制的效果:一个几千行的列表,只有第 500 行的某个组件发生了状态变化。由于中间大部分 Fiber 节点的subtreeFlags & MutationMask都是 0,React 提交时可以绕过整棵无变化的子树,而不需要逐个组件比对。换成遍历数组的方式,付出的循环次数完全不是一个量级。

3.3 一次真实提交循环中的位运算

以 React 19 的commitMutationEffects为例,核心工作循环大概长这样(伪代码简化):

function commitMutationEffects(root, finishedWork) { commitMutationEffectsOnFiber(finishedWork, root); } function commitMutationEffectsOnFiber(finishedWork) { const flags = finishedWork.flags; // 第一轮:处理 Placement(插入) if ((flags & Placement) !== NoFlags) { commitPlacement(finishedWork); finishedWork.flags &= ~Placement; } // 第二轮:处理 Update(属性更新) if ((flags & Update) !== NoFlags) { commitWork(finishedWork); finishedWork.flags &= ~Update; } // 第三轮:处理 Passive 相关标记 if ((flags & Passive) !== NoFlags) { commitPassiveEffect(finishedWork); } // 递归处理子树 if ((finishedWork.subtreeFlags & MutationMask) !== NoFlags) { // 进入子树递归 } }

注意finishedWork.flags &= ~Placement这一步:提交完插入操作后,把"已完成的标记"从 flags 里抹掉,防止后续阶段重复处理。这就是"关闭某路开关"的标准写法,一次& ~精确清除特定位,其他 bit 一个不动。React 里所有副作用的"消费"过程都是这样:检查(&)、执行、清除(&= ~),三步走完。

4. 让你可以直接抄走的位运算技巧

4.1 组合多个开关

业务代码里最实用的一点:当你有几个互不冲突的布尔选项需要一次性管理时,别写isA && isB的散装判断,直接用位图合并。

const OPTION_A = 1 << 0; // 0b001 const OPTION_B = 1 << 1; // 0b010 const OPTION_C = 1 << 2; // 0b100 let config = 0; config |= OPTION_A; config |= OPTION_B; // config 同时开了 A 和 B config = OPTION_A | OPTION_C; // 直接开 A 和 C

这样得到的config不再是一堆散落的布尔值,而是一个可以整体传递、整体比较、整体存入缓存的状态值。我在写权限模块时经常这么干:一个整数就能表达某用户是否同时拥有"读、写、删"三种权限,序列化到后端时也只是一个数字。

4.2 判断包含关系

判断"某个开关是否开启"用&:

if ((config & OPTION_A) !== 0) { // 包含 A }

判断集合 A 是否是集合 B 的子集,用(a & b) === a。这是 React 源码里isSubsetOfLanes的实现,应用在"这份更新是否完全覆盖了某条 lane 的所有子更新"这类场景。

4.3 精确清除某些位

清除任意组合的开关,用&= ~:

config &= ~(OPTION_A | OPTION_C); // 同时关掉 A 和 C

这是我在调试时最常用的操作。忘记加~结果会完全相反——直接config &= (OPTION_A | OPTION_C)会把其他所有位全部清零,压根不是"只保留 A 和 C"的意思。这个坑提醒我:位运算写错很难一眼看出来,所以每次写完都要在注释里标注二进制结果。

4.4 用 XOR 切换开关

^(按位异或)实现"切换":某位是 0 变 1,是 1 变 0。React 内部不常用它做主逻辑,但在独立实现 UI 开关组件时非常顺手:

let visible = 0b0001; visible ^= 0b0001; // 变成 0b0000,关 visible ^= 0b0001; // 变成 0b0001,开

注意 XOR 只应该在"明确知道要翻转某几个位"时用。如果你不确定目标位当前状态,^不会像|那样保证最终为 1,也不会像&= ~那样保证最终为 0,它给的是"取反"语义。

4.5 求差集与判断非空

React 调度器里removeLanes(set, subset) = set & ~subset就是求差集标准写法。判断"两组 lane 是否完全没有交集",只需要(a & b) === 0。判断"是否至少有共同部分",只需要(a & b) !== 0。这两个操作都是 O(1),不管两个集合里各有多少个元素。

我自己封装过一个"选项集合"的对象,最终实现的 add/remove/contains/union 加起来不超过十行,全部基于位运算。对比之前用数组includes和filter的版本,不仅代码短了,避开了所有"数组长度变了却忘了更新引用"的隐性 bug。

5. 为什么不直接用 Set、数组或布尔字段

5.1 性能:位运算是一条 CPU 指令

这是最直接的理由。a & b、a | b在 CPU 层面就是一条指令,没有循环、没有函数调用、没有内存查找。而Set.has()要先经过哈希计算定位桶位置,再比较 key,期间还可能触发哈希表扩容。数组的includes()更是纯 O(n) 遍历。React 的渲染和提交是热路径中的热路径,每个 Fiber 节点都可能有 flags 判断,大型应用里 Fiber 数量动辄十万级,每个节点省掉一次循环遍历,累积效果非常显著。

我在练习时做过一次粗糙的 benchmark:对一个包含 100 万个布尔标记的数组反复做"检查第 100 个布尔值是否为 true",用位运算版本比用数组includes快一个数量级。这里要说明,React 选择位运算不只是因为"单次快",更是因为"整体架构可以基于它做剪枝",比如subtreeFlags & MutationMask一次判断跳过整棵子树。

5.2 内存:数字和对象不是一个量级

Fiber 树节点数量庞大,每个节点如果都挂一个Set来存 flags,一次提交就需要创建、销毁、GC 成千上万个集合对象。Set对象本身有头部信息、哈希桶、扩容逻辑,一个空 Set 也要占几十字节。而一个 flags 整数不管存了多少标记,都是一个数字的存储成本。在内存敏感的移动端渲染场景里,这个差距会被放大。

更微妙的是分配压力。位运算操作不产生任何中间对象,flags | Placement只是一个数字运算;而set.add(Placement)可能触发哈希表分配新桶,set.delete(Placement)可能留下空洞,长期运行会引发内存碎片和 GC 停顿。React 作为需要帧率和稳定性的运行时,对"零分配"有近乎偏执的追求。

5.3 原子性:一个值可以承载"复合状态"

这一点最少被人提起,但我觉得是位运算最本质的优势。位图让"多个标记的组合"本身成为一个值,可以直接比较、直接传递、直接放进另一个位图参与运算。

  • 检查"这个节点需要更新或插入":flags & (Placement | Update),一个表达式完成。
  • 合并所有子节点的工作:subtreeFlags |= child.flags,一次 OR 合并任意多个标记。
  • 清除多个标记:flags &= ~(Placement | Update | Callback),一次完成。

换成Set,以上每个操作都需要循环遍历。换成布尔字段,你得写if (fiber.placement || fiber.update || fiber.callback),而且没法把这些字段"作为一个整体"打包给上层。位图真正做到了"原子地表达复合状态",这是 Set 和布尔字段都给不了的。

5.4 思维实验:用 Set 实现 Lane 会发生什么

不妨想象一下,如果 React 的 pendingLanes 是一个Set<number>,那么每次markRootUpdated都要set.add(lane);每次getNextLanes都要遍历这个 Set 找到最小值,复杂度 O(n);每次判断"当前有没有同步任务"都要for...of循环检查每个元素。而 lane 的核心语义——"合并多个更新""求最高优先级""判断包含关系"——会全部退化成循环遍历。

最要命的是"合并"操作。Fiber 提交阶段经常要把子节点的标记合并到父节点上,位图一次 OR 就完成;如果父节点和子节点各持有一个 Set,你需要遍历子节点 Set 的每个元素再add到父节点,复杂度变成 O(k),k 是标记种类数。当标记种类从几个涨到十几个,每次合并都变成十几条语句。位图把这些全部压成一个整数运算,而且永远不会因为"忘记遍历完"而漏掉某个标记。

6. 常见疑问与实操避坑

6.1 位运算牺牲可读性怎么办

这是最常被问到的问题。我的观点是:位运算确实提高了阅读门槛,但它换来的是确定性的性能与简洁的表达,React 团队的选择是"用良好的命名与集中的封装"抵消这种门槛。所有 Lane 常量都有清晰的名字,所有位运算逻辑都集中在ReactFiberLane.js和ReactFiberFlags.js两个文件里,调用方看到的都是语义化函数名,比如getHighestPriorityLane、includesSomeLane,而不是一堆裸的&。

自己写代码时要想清楚这一点:如果位运算只出现在一个模块内部且有明确注释,那利大于弊;如果要在十几个文件里裸写& 0xFF这种魔法数字,那就是给自己埋雷。我的习惯是给每个位常量起好名字,条件允许时封装成带语义的函数。

6.2 为什么 Lane 上限是 31

因为 JavaScript 的位运算符是按照 32 位有符号整数处理的。最高位(符号位)一旦变成 1,整个数字就成了负数,很多按位技巧和大小比较都会出错。所以 React 明确规定TotalLanes = 31,只用 bit 0 到 bit 30,符号位永远保持 0。

这也是为什么我在上面表格里看到一大串0b...均为 31 个二进制位。从实践角度看,31 个 lane 完全够用:同步、输入连续性、默认、Transition 批量任务各自划分,互不挤占。假如某天真的不够用了,React 也只能在 Lane 系统上做分组抽象,而不是简单增加位数,因为 JS 数值本身容纳不下更多 bit。

6.3 业务代码里该不该用位运算

最后聊一点实际的:不要在普通业务代码里为了"酷"而滥用位运算。位运算的收益只有在热路径、高频调用、内存敏感场景才明显,菜单权限、状态机、配置项这类场景是它的主场;而一句话判断if (isAdmin && canDelete)明显比if ((permission & ADMIN) && (permission & DELETE))更易读。

我自己踩过的一个坑是在一个状态对象里强行把所有布尔字段改成位图,结果大家提完 PR 之后,新同事每次改状态都要翻注释对照 bit 位,开发效率反而下降。最后我在代码里保留了一条经验规则:位图适合"个数确定、互不干扰、需要经常组合判断"的开关集合,比如权限、特性开关、渲染阶段标记;不适合"可增长、语义复杂、需要独立序列化"的业务状态。

回看 React 19 的整套实现,我认为位运算对它的价值不只是"让代码跑得更快"这么简单,更在于它让"高优先级打断低优先级""整棵子树无变化可以跳过""多个副作用一次性归并"这些调度能力变成了 O(1) 的普通操作。没有位图,这三大能力要么做不到,要么得付出数倍的遍历成本。我自己在动手写一个简化版调度器练手之后,才真正认同 React 团队的这个选择:它不是在优化某一处代码,而是在为整个架构定下一块最省力、最稳定、最不容易出错的数据底座。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询