Vue3源码中的位运算:如何用二进制构建高效虚拟DOM
读 Vue3 源码读到一半很多人会被一个“老古董”知识点勾住位运算。Vue 3 的模板编译、运行时 diff、响应式副作用管理四处都藏着二进制的影子。比起用字符串、数组、布尔字段去表达状态Vue3 更习惯用几个数字把状态压在一个整型字段里。你可能见过这样的代码const patchFlag PatchFlags.CLASS | PatchFlags.STYLE或者shapeFlag ShapeFlags.COMPONENT。这背后到底图什么一句话快而且省内存。这篇文章我会结合 Vue3 源码里真实的枚举定义和调用位置把 ShapeFlags、PatchFlags、EffectFlags 这几个典型位运算案例拆开揉碎讲清楚还会手写一个迷你版本让你从设计者的角度体会为什么位运算在框架底层这么吃香。适合想看 Vue3 源码、准备框架原理面试、以及在业务代码里追求极致性能的开发者阅读。看完之后你不仅能在面试时说上几句“编译期 patchFlag、运行时位掩码”这类内行话自己写高复用库时也多了一种表达状态的好工具。1. 位运算为什么能“又快又省内存”一位开关胜过一麻袋变量1.1 二进制和位运算的直觉理解你面对的不是数字是一排开关先别急着啃源码我们把位运算还原到最朴素的样子。计算机里的整数本质上就是二进制位每一位要么是 0要么是 1。你完全可以把它想象成一排开关0 是关1 是开。1 n表示把第 n 位这个开关打开其他位保持关闭。a | b表示“把 b 里的所有开关都打开到 a 上”a b表示“检查 a 里哪些开关和 b 是同时开着的”。举一个特别生活的例子点外卖选配料const ADD_ONION 1 0 // 0001加洋葱 const ADD_CHEESE 1 1 // 0010加芝士 const ADD_BACON 1 2 // 0100加培根 let order ADD_ONION | ADD_BACON // 0101一份洋葱加培根 if (order ADD_BACON) { console.log(这份有培根) } if ((order ADD_CHEESE) 0) { console.log(这份没加芝士) }一份订单不再需要三个布尔字段hasOnion、hasCheese、hasBacon一个数字order就装下了所有信息。这正是 Vue3 源码里大量发生的事情把若干个“布尔状态”合并成一个整数创建时用|合并判断时用拆解。理解了这层开关逻辑后面读源码就顺了。1.2 用一个数字替代一串布尔字段内存和判断成本降在哪你可能要问现代 JavaScript 引擎对对象属性访问优化得已经很快了用几个布尔字段真的比位运算差很多吗从纯指令层面看位运算确实只是 CPU 的一两条指令但真正的差距不在这一两纳秒而在下面三点。第一是内存密度。一个字段无论布尔还是数字在对象里都占一个属性位但一个数字最多能表达 32 个布尔状态而布尔字段只能表达 1 个真实状态。Vue3 里 vnode 动不动就创建成千上万个如果每个 vnode 都用isElement、isText、isComponent这种字段去标记字段数量一多对象变得臃肿。把多个标记压进一个shapeFlag对象结构更紧凑缓存友好度也更高。第二是组合能力。对象判断组合状态时你得写if (a.isText || a.isComponent)这种逻辑位运算一行搞定flag (TEXT | COMPONENT)。编译器在生成 patchFlag 时可以很自然地把多个标记通过|合并运行时就一个操作判断“是否属于这个集合”语义极其清晰。第三是稳定性。位运算没有属性名查找、没有隐式类型转换、没有原型链问题行为始终确定。对框架这种需要扛住海量高频调用的底层代码来说这种确定性本身就是优势。1.3 认清边界位运算不是 Vue3 变快的唯一原因必须说句公道话位运算不是银弹。Vue3 的“快”核心来自编译期静态分析、Proxy 响应式的细粒度依赖收集、惰性渲染等一系列架构改进。位运算只是让这些设计落地时更干净高效的“原子操作”。单独把位运算拎出来做微基准很多时候和对象属性判断差距并不夸张但当它嵌在 VNode 创建、diff 路径、effect 状态管理里优势会被高频调用放大。明白这一层你就不会在面试时把“位运算”当万能答案而是把它放在框架整体设计里讲可信度立刻不一样。2. 源码拆解第一步ShapeFlags 如何把“组件/元素/子节点类型”压进一个数字2.1 先看源码ShapeFlags 枚举定义与每个开关的含义打开 Vue3 源码找到packages/runtime-core/src/shapeFlags.ts里面定义了一个非常经典的位掩码枚举export const enum ShapeFlags { ELEMENT 1, // 00000001 普通元素 FUNCTIONAL 1 1, // 00000010 函数式组件 STATEFUL 1 2, // 00000100 有状态组件 TEXT_CHILDREN 1 3, // 00001000 子节点是文本 ARRAY_CHILDREN 1 4, // 00010000 子节点是数组 SLOTS_CHILDREN 1 5, // 00100000 子节点是插槽 TELEPORT 1 6, // 01000000 Teleport 内置组件 SUSPENSE 1 7, // 10000000 Suspense 内置组件 COMPONENT_SHOULD_KEEP_ALIVE 1 8, COMPONENT_KEPT_ALIVE 1 9, COMPONENT ShapeFlags.STATEFUL | ShapeFlags.FUNCTIONAL }注意最后一行COMPONENT不是独立的新位而是STATEFUL | FUNCTIONAL两个位组合出来的复合标记。它能存在正是因为二进制位可以自由组合。一个 vnode 的shapeFlag字段用一位到几位就能把“这个节点是元素还是组件、组件是有状态还是函数式、子节点是文本还是数组”这些信息全部表达出来。2.2 createVNode 用“或运算”组装类型renderer 用“与运算”读取类型shapeFlag到底怎么用看createVNode里的一段核心逻辑它负责把 type 转化为对应的标记位let shapeFlag 0 if (isString(type)) { shapeFlag | ShapeFlags.ELEMENT } else if (isObject(type)) { shapeFlag | ShapeFlags.STATEFUL_COMPONENT } else if (isFunction(type)) { shapeFlag | ShapeFlags.FUNCTIONAL_COMPONENT } if (shapeFlag ShapeFlags.COMPONENT) { // 组件类型进一步判断 children 是否为插槽 } else { // 元素类型判断 children 是文本还是数组 }这里出现了两个位运算动作shapeFlag | xxx是“打开开关”把对应位从 0 改成 1后面shapeFlag ShapeFlags.COMPONENT是“查开关”看这一类型是否落在组件范围内。创建 vnode 的成本极低没有多少字符串比对和分支这是框架内部高频路径里非常在意的点。到了渲染器packages/runtime-core/src/renderer.ts里同样一按键就能分流if (shapeFlag ShapeFlags.ELEMENT) { // 走元素挂载/更新逻辑 } else if (shapeFlag ShapeFlags.COMPONENT) { // 走组件挂载/更新逻辑 } else if (shapeFlag ShapeFlags.TELEPORT) { // 走 Teleport 逻辑 }这种写法最大的特点是所有类型判断在一条直线上顺序读完不加多余分支也不会因为类型判断触发复杂的属性读取。Vue2 里对 VNodetag做字符串类型判断、用componentInstance判断组件相比之下既啰嗦又不够统一。2.3 COMPONENT 一次判断两种组件组合枚举的妙用很多人第一眼看COMPONENT ShapeFlags.STATEFUL | ShapeFlags.FUNCTIONAL不太明白觉得“自定义一个 1 8 不就完了吗干嘛要组合”。这就踩到了位运算最核心的价值状态不是孤立存在的它们能通过|形成语义集合。STATEFUL是 00000100FUNCTIONAL是 00000010两者|之后是 00000110。shapeFlag 00000110只要不为 0就说明这个节点要么是有状态组件、要么是函数式组件总之是组件。当你只想判断“是不是组件、不关心具体哪种组件”时用复合标记一次搞定当你需要严格区分时就分开判断STATEFUL和FUNCTIONAL。这种灵活性是对象布尔字段很难给出的表达力。3. PatchFlags模板编译器为 diff 铺的路运行时用位掩码精准踩点3.1 Vue2 全量 diff 和 Vue3 精准 patch 的本质区别聊完类型标记再看 Vue3 另一个重量级位运算应用PatchFlags。Vue2 的虚拟 DOM diff 是对整棵 vnode 树进行递归对比的虽然也有同层比较、key 优化但静态节点只要在树里就会跟着一起走一遍 patch 过程。Vue3 的思路很不一样模板在编译阶段就能确定哪些节点是静态的、哪些属性是动态的于是把这些信息直接编码成 patchFlag 打在 vnode 上。运行时不再看整棵树而是直接盯住编译期标记出来需要更新的部分。这就是 Vue3 官方所说的“更新性能相比 Vue2 提升明显”的底层逻辑之一。不是位运算单枪匹马造成的但 patchFlag 作为载体让编译器的静态信息可以非常廉价地下放到运行时。用一个小数字标记动态点比传一堆对象描述信息要省太多。3.2 PatchFlags 源码全解析注意 HOISTED 和 BAIL 不是位掩码继续翻源码packages/runtime-core/src/patchFlags.ts里是这么定义的export const enum PatchFlags { TEXT 1, // 000000000001 动态文本 CLASS 1 1, // 000000000010 动态 class STYLE 1 2, // 000000000100 动态 style PROPS 1 3, // 000000001000 动态 props需要配合 dynamicProps 数组 FULL_PROPS 1 4, // 000000010000 全量 props diff NEED_HYDRATION 1 5, // 000000100000 需要水合 STABLE_FRAGMENT 1 6, // 000001000000 稳定 fragment KEYED_FRAGMENT 1 7, // 000010000000 带 key 的 fragment UNKEYED_FRAGMENT 1 8, // 000100000000 不带 key 的 fragment NEED_PATCH 1 9, // 001000000000 需要强制 patch DYNAMIC_SLOTS 1 10, // 010000000000 动态插槽 DEV_ROOT_FRAGMENT 1 11, // 100000000000 开发模式根 fragment HOISTED -1, BAIL -2 }这里最值得提醒的是最后两个HOISTED -1和BAIL -2它们并不是位掩码。-1在二进制里是全 1-2是除了最低位外全 1但它们在这里是“约定常数”不是用来做判断的。编译器看到patchFlag 0时知道这是静态提升节点或复合 bail 节点直接跳过特殊处理。所以读源码时别一头扎进去对所有 patchFlag 都用先看符号和大小。3.3 编译产物里的 patchFlag从 Vue 模板到 render 函数长啥样写一段最简单的模板template div classapp p{{ msg }}/p p静态文本/p /div /template经过编译器处理后得到的 render 函数大致是这样手写简版const _hoisted_1 createElementVNode(p, null, 静态文本, -1 /* HOISTED */) function render(_ctx, _cache) { return (openBlock(), createElementBlock(div, { class: app }, [ createElementVNode(p, null, toDisplayString(_ctx.msg), 1 /* TEXT */), _hoisted_1 ])) }注意两个细节动态文本节点{{ msg }}的 patchFlag 是1对应PatchFlags.TEXT运行时只要发现这个位为 1就知道该节点只需要更新文本不需要重新比较 props、children静态文本节点直接提升到 render 函数外部包一个-1HOISTED每次重新渲染时直接复用同一个 vnode连新对象都不用创建内存和性能双赢。如果你同时写了动态 class 和动态 style编译出的 patchFlag 就是2 | 4 6注释会写成6 /* CLASS, STYLE */。这就是位运算的“组合”能力在编译产物里的直接体现。3.4 运行时的一次与运算偏光镜精准命中要更新的部分到了运行时渲染器拿到这些 patchFlag 后处理的代码大致长这样if (patchFlag 0) { if (patchFlag PatchFlags.FULL_PROPS) { // 全量 diff props } else if (patchFlag PatchFlags.CLASS) { // 只处理 class } else if (patchFlag PatchFlags.STYLE) { // 只处理 style } else if (patchFlag PatchFlags.PROPS) { // 处理 dynamicProps 数组里列出的动态 props } }编译器负责用|把多个动态标记组合成一个数运行时负责用把组合拆开精准命中需要更新的那个维度。这一组动作一收一发配合得严丝合缝。你想想如果用对象表达{ text: true, class: false, style: true }运行时要判断“这个节点是否需要更新 class”就得先去读属性再和“旧状态”对比而位运算直接一个与操作就出结果根本没有属性查找的过程。4. 响应式系统里的位运算EffectFlags 状态机与其他“伪位运算”4.1 EffectFlags一个 effect 是活跃还是失活一个整数全搞定除了 vnodeVue3 响应式系统里也藏着位运算。Vue 3.2 之后的源码里定义了一个EffectFlags专门用来管理副作用函数effect的多重状态export const enum EffectFlags { ACTIVE 1 0, // 00000001 effect 处于活跃状态 RUNNING 1 1, // 00000010 effect 正在执行 TRACKING 1 2, // 00000100 effect 正在收集依赖 NOTIFIED 1 3, // 00001000 effect 已经被通知 DIRTY 1 4, // 00010000 effect 是脏的需要重新执行 ALLOW_RECURSE 1 5, // 00100000 允许递归触发自身 PAUSED 1 6, // 01000000 effect 被暂停 }一个 effect 可以是“活跃”的同时也可以“正在运行”“脏了”这种多状态叠加用对象表达就得写好多个布尔字段而且状态还会互相约束。用位运算呢effect.flags | EffectFlags.RUNNING把运行位打开effect.flags ~EffectFlags.RUNNING再把它关掉判别时effect.flags EffectFlags.ACTIVE一条语句就够。这种写法在框架底层还有个好处状态之间的组合一目了然不容易出现“这个布尔忘置位了”这种低级问题。4.2 别搞混TrackOpTypes、ReactiveFlags 并不是位掩码读响应式源码时你会发现还有一些“数字或字符串枚举”比如TrackOpTypes、TriggerOpTypes、ReactiveFlags。这里必须帮你避个雷它们不是位掩码。export const enum TrackOpTypes { GET get, HAS has, ITERATE iterate }TrackOpTypes是用于标记依赖收集操作的字符串枚举主要给人看、给调试工具用不会拿去判断。ReactiveFlags也是字符串类型IS_REACTIVE __v_isReactive它是挂在代理对象上的特殊“暗号”属性用来区分普通对象和响应式对象。面试时如果笼统说“Vue3 响应式全是位运算”会被懂源码的人一眼识破。位运算在响应式里主要负责 effect 状态管理而不是整个响应式原理的基石。5. 动手实践手写一个“位运算版迷你 Vue”并做一次性能对比5.1 定义迷你 ShapeFlags 和 PatchFlags光看不练容易飘。我们手写一个迷你版把位运算用在一个简化 vnode 系统里。先定义两组合法状态const ShapeFlags { ELEMENT: 1, // 0001 TEXT_CHILDREN: 1 1, // 0010 ARRAY_CHILDREN: 1 2, // 0100 COMPONENT: 1 3 // 1000 } const PatchFlags { TEXT: 1, // 0001 CLASS: 1 1, // 0010 STYLE: 1 2, // 0100 PROPS: 1 3 // 1000 }这套定义和 Vue3 源码里是同一个套路只是把位数量精简到能跑通逻辑的规模。位运算不关心你用几位只关心位的划分是否一致。5.2 用 createVNode 组装 vnode 类型接下来写一个极简createVNode核心就是把类型和子节点类型通过|组合进shapeFlagfunction createVNode(type, props {}, ...children) { const vnode { type, props, children: null, shapeFlag: 0, patchFlag: 0 } if (typeof type string) { vnode.shapeFlag | ShapeFlags.ELEMENT } else if (typeof type object) { vnode.shapeFlag | ShapeFlags.COMPONENT } if (typeof children[0] string) { vnode.children children[0] vnode.shapeFlag | ShapeFlags.TEXT_CHILDREN } else { vnode.children children vnode.shapeFlag | ShapeFlags.ARRAY_CHILDREN } return vnode } const ele createVNode(div, {}, hello) console.log(ele.shapeFlag) // 1 | 2 3既不是元素又不是纯文本子节点这一步完全复刻了 Vue3createVNode的组装思路。你注意到没有整个函数里没有出现“这个 vnode 是不是文本子节点”的先验分支更没有 now 临时对象暂存多个布尔判断所有类型信息都汇入一个数字最后shapeFlag的值一出来全齐了。5.3 用 patchFlag 实现“只 patch 该 patch 的节点”再给 vnode 加一个patchFlag模拟编译后的动态信息然后实现一个简化 patchfunction patch(oldVNode, newVNode, el) { if (newVNode.patchFlag PatchFlags.TEXT) { el.textContent newVNode.children return } if (newVNode.patchFlag (PatchFlags.CLASS | PatchFlags.STYLE)) { // 一个与运算同时判断两个维度是否需要更新 if (newVNode.patchFlag PatchFlags.CLASS) { el.className newVNode.props.class } if (newVNode.patchFlag PatchFlags.STYLE) { Object.assign(el.style, newVNode.props.style) } } if (newVNode.patchFlag PatchFlags.PROPS) { const { dynamicProps } newVNode for (const key of dynamicProps) { el.setAttribute(key, newVNode.props[key]) } } } const newNode { patchFlag: PatchFlags.TEXT | PatchFlags.STYLE, props: { style: { color: red } }, children: 新文本 }重点看patchFlag (PatchFlags.CLASS | PatchFlags.STYLE)这行它把两个状态打包成一个组合再通过一次与运算判断这两类更新是否需要进入。这就是模板编译器在多个动态属性同时存在时生成的场景到了业务代码里你甚至可以把这个思路用在复杂表单校验、属性更新策略上。5.4 位运算 vs 对象布尔字段一次简易 benchmark 的真实感受写一个非常粗糙的对比来感受差异const N 10000000 console.time(bitwise) let sum1 0 for (let i 0; i N; i) { const flag 2 | 4 // CLASS | STYLE if ((flag 2) ! 0) sum1 } console.timeEnd(bitwise) const state { class: true, style: true, text: false } console.time(object) let sum2 0 for (let i 0; i N; i) { if (state.class) sum2 } console.timeEnd(object)这类微基准在不同 JS 引擎上结果不一样有时对象访问也不慢因为现代引擎的隐藏类和内联缓存太强了。所以别神话位运算的“速度”它的真正优势在于用一个字段装多个状态带来的内存规模下降、组合状态表达上的简洁以及把这套机制嵌入编译器产物后运行时能够直接跳过大量无意义的比较逻辑。这是我们做 mini 版体验到的设计红利而不是单条指令上的胜负。6. 面试考点与避坑实录位运算的边界和最佳实践6.1 Vue3 位运算常见面试题速查表关于 Vue3 位运算面试官一般不会直接问“位运算怎么写”而是会绕着源码问你为什么这么设计。我整理了几个高频问题面试问题建议回答要点Vue3 中的 ShapeFlags 是什么一个位掩码枚举用一个数字表示 vnode 的类型和子节点类型通过 PatchFlags 对性能有什么作用编译器在模板编译阶段标注动态节点类型运行时用位运算判断需要更新的维度避免全量 diff让静态节点完全跳过更新为什么用1 n而不是 1、2、3、4可读性、可扩展性和防止冲突。1 n明确表达“这是第 n 位”每个状态独立占位组合和判断都清晰HOISTED 为什么是 -1它不是位掩码而是一个特殊约定的负值用来区分“这是一个被提升的静态节点”运行时直接复用不走 patch 流程位运算在响应式里也重要吗主要用于 effect 状态管理如 EffectFlagsReactiveFlags 和 TrackOpTypes 本身是字符串枚举不是位掩码要实事求是地答6.2 我在实际代码里踩过的位运算坑第一坑1 31会变成负数。JavaScript 位运算按 32 位有符号整数处理1 31的结果是 -2147483648。你如果拿它做位掩码判断结果可能完全出乎意料。所以自定义枚举时别轻易把位数怼到第 31 位以上Vue3 的 PatchFlags 堆到1 11就及时收手了。第二坑运算符优先级。flag MASK 0和(flag MASK) 0不是一回事。JS 里的优先级高于前者实际是flag (MASK 0)很容易在判断“某位是否为零”时写错。写位运算判断一定要打括号这是新手最容易翻车的地方。第三坑用 0 做状态位。0 在二进制里没有任何一位是 1它天然表示“什么都没有”。如果某个枚举值为 0你打算拿它当状态位那和“没有状态”就冲突了。Vue3 里 patchFlag 为 0 表示没有任何动态标记宁可空着也不要占位。第四坑位运算读代码很困难。源码里一长串1 8可读性并不好所以 Vue3 用注释标注了每个 flag 的含义。自己写位掩码时也要给枚举加清晰注释别让三个月后的自己对着二进制抓狂。6.3 业务代码里什么时候才值得用位运算位运算不是用来炫技的。我在团队里经常和同事说如果只是两三个布尔状态直接用布尔字段大家都能一眼看懂但如果你遇到“多个状态可以叠加、状态组合出来后还要做集合判断”的场景位运算就非常值得考虑。比如权限系统读、写、删除、导出每个权限一位角色权限直接|组合验证权限直接判断比存数组做 includes 高效整洁得多。再比如特性开关、广播事件类型、节点能力标记都是位运算的好舞台。归根结底位运算是把“状态空间”装进“整数空间”的一门手艺。Vue3 用它压缩 vnode 的体积、编码编译器静态分析结果、管理 effect 生命周期本质上是把二进制的好用之处榨到了极致。业务代码里只要状态组合的复杂度配得上这种抽象它同样能给你带来性能和可读性的双重回报。我在实际阅读 Vue3 源码的过程中最深的一点体会是那些1 n看起来冷冰冰但只要你理解它背后是“一排开关”整个框架的设计思路都会变得很通透。它不是一个需要背诵的技巧而是一种“状态也应该有体积意识”的编码观。如果你也想在自己的项目里试试建议先从权限系统和特性开关开始小范围用起来配上详细注释慢慢就会感受到这种二进制艺术的魅力。