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 个 bitInputContinuousLane占 1 个 bitDefaultLane占 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表示这个节点新插入 DOMUpdate表示这个节点属性/状态有变化需要更新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都是 0React 提交时可以绕过整棵无变化的子树而不需要逐个组件比对。换成遍历数组的方式付出的循环次数完全不是一个量级。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 是一个Setnumber那么每次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 团队的这个选择它不是在优化某一处代码而是在为整个架构定下一块最省力、最稳定、最不容易出错的数据底座。