读 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是 00000100,FUNCTIONAL是 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 class="app"> <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 函数外部,包一个-1(HOISTED),每次重新渲染时直接复用同一个 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 是活跃还是失活,一个整数全搞定
除了 vnode,Vue3 响应式系统里也藏着位运算。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,核心就是把类型和子节点类型通过|组合进shapeFlag:
function 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,模拟编译后的动态信息,然后实现一个简化 patch:
function 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 状态管理,如 EffectFlags;ReactiveFlags 和 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看起来冷冰冰,但只要你理解它背后是“一排开关”,整个框架的设计思路都会变得很通透。它不是一个需要背诵的技巧,而是一种“状态也应该有体积意识”的编码观。如果你也想在自己的项目里试试,建议先从权限系统和特性开关开始,小范围用起来,配上详细注释,慢慢就会感受到这种二进制艺术的魅力。