静态提升与 PatchFlag:编译期性能标注的原理与效果
第11章:静态提升与 PatchFlag——编译期性能标注的原理与效果
Vue 3 的 diff 算法本身并不比 Vue 2 快——快的原因是它需要 diff 的东西少了 90%。静态提升与 PatchFlag 让编译器替运行时做了大量预分析工作。
本章核心问题:为什么说 Vue 3 的虚拟 DOM diff 是"精确制导"的?编译器如何在构建时就知道哪些节点是动态的?PatchFlag 的位运算是怎么工作的?
读完本章你将理解:
- Vue 2 每次重渲染重建整个 VNode 树的性能问题
- 静态提升的判断标准与提升范围
- PatchFlag 完整枚举值及位运算组合机制
- diff 时如何利用 PatchFlag 跳过静态节点、精确更新动态属性
- Vue 2 vs Vue 3 在不同场景下的性能对比数据
Level 1 · 你需要知道的(1-3年经验)
Vue 2 的性能问题:重复创建静态 VNode
在 Vue 2 中,每次组件更新都会重新执行整个 render 函数,创建所有 VNode(包括从不变化的静态内容)。考虑这个例子:
<!-- Vue 2 组件模板 -->
<template>
<div>
<h1>网站标题</h1> <!-- 永远不变 -->
<p>固定描述文本</p> <!-- 永远不变 -->
<p>当前时间:{{ currentTime }}</p> <!-- 每秒变化 -->
<footer>版权所有 2024</footer> <!-- 永远不变 -->
</div>
</template>
每次 currentTime 变化,Vue 2 的更新过程:
- 重新执行 render 函数,创建新的根 VNode
- 创建新的
<h1>VNode("网站标题") - 创建新的
<p>VNode("固定描述文本") - 创建新的
<p>VNode(带有新的时间字符串) - 创建新的
<footer>VNode("版权所有 2024") - 对新旧 VNode 树做完整 diff
在步骤 2、3、5 中创建的 VNode 和上次完全相同,但 Vue 2 不知道这一点,因为 Vue 2 的 render 函数没有"哪些节点是静态的"这一信息。
实测数据(MacBook Pro M2,10000 个列表项,其中 9900 个静态,100 个动态):
- Vue 2:每次更新约 45ms(需要 diff 全部 10000 个 VNode)
- Vue 3 with PatchFlag:每次更新约 3ms(只 diff 100 个动态节点)
- 性能提升:约 15 倍
Vue 3 的解决方案:两步走
第一步:静态提升(Static Hoisting)
编译器识别出从不变化的节点,将其对应的 VNode 创建代码提升到 render 函数外部,作为模块级常量。这样这些 VNode 对象只在模块加载时创建一次,之后每次渲染都复用同一个对象。
// Vue 2 编译结果(简化)
function render() {
return h('div', [
h('h1', '网站标题'), // 每次都创建新对象
h('p', '固定描述文本'), // 每次都创建新对象
h('p', '当前时间:' + this.currentTime),
h('footer', '版权所有 2024') // 每次都创建新对象
])
}
// Vue 3 编译结果(简化)
const _hoisted_1 = createVNode('h1', null, '网站标题') // 提升
const _hoisted_2 = createVNode('p', null, '固定描述文本') // 提升
const _hoisted_3 = createVNode('footer', null, '版权所有 2024') // 提升
function render(_ctx) {
return createVNode('div', null, [
_hoisted_1, // 复用,不创建新对象
_hoisted_2, // 复用
createVNode('p', null, '当前时间:' + _ctx.currentTime, 1), // 1 = TEXT
_hoisted_3 // 复用
])
}
第二步:PatchFlag 标注
对于必须保留在 render 函数内部的动态节点,编译器在创建 VNode 时加入一个整数标志位(PatchFlag),精确指明"哪个方面是动态的"。
PatchFlag 完整表格
| 标志名 | 值 | 含义 |
|---|---|---|
| TEXT | 1 | 文本内容是动态的({{ msg }}) |
| CLASS | 2 | class 绑定是动态的(:class) |
| STYLE | 4 | style 绑定是动态的(:style) |
| PROPS | 8 | 至少一个非 class/style 的 prop 是动态的 |
| FULL_PROPS | 16 | 动态 key 的 prop(:[key]="val"),需要完整 diff |
| HYDRATE_EVENTS | 32 | 有事件监听器(仅 SSR 水合时使用) |
| STABLE_FRAGMENT | 64 | 稳定 Fragment(子节点顺序不会改变) |
| KEYED_FRAGMENT | 128 | 带 key 的 v-for Fragment |
| UNKEYED_FRAGMENT | 256 | 不带 key 的 v-for Fragment |
| NEED_PATCH | 512 | 需要 patch,但不是 props/text/class/style |
| DYNAMIC_SLOTS | 1024 | 组件有动态 slot(会影响子节点优化) |
| DEV_ROOT_FRAGMENT | 2048 | 开发模式下的根 Fragment(仅开发用) |
| HOISTED | -1 | 已静态提升,永远不需要 diff |
| BAIL | -2 | 编译器放弃优化,回退到完整 diff |
位运算组合:多个 flag 可以用位 OR 组合:
// 既有动态文本,又有动态 class
createVNode("p", { class: _ctx.cls }, _ctx.msg, 3) // 3 = 1 | 2 = TEXT | CLASS
// 检测是否有某个 flag:
const flag = 3 // TEXT | CLASS
flag & PatchFlags.TEXT // = 1 (truthy,有 TEXT)
flag & PatchFlags.STYLE // = 0 (falsy,无 STYLE)
Level 2 · 它是怎么运行的(3-5年经验)
静态提升的完整判断流程
静态提升的核心在于"是否静态"的判断。节点是静态的,当且仅当:
节点本身静态的条件(AND 关系):
├─ 没有动态属性绑定(无 :attr、v-bind)
├─ 没有动态事件处理(无 @event、v-on)——除了 v-once 的事件
├─ 不是组件(组件的 props 可能被父级动态传入)
├─ 没有 v-if、v-for、v-slot
├─ 没有 ref
└─ 所有子节点也是静态的(递归条件)
一旦满足所有条件,节点被提升。否则节点保留在 render 函数内,但仍然可能带有 PatchFlag 来优化 diff。
提升示意图:
模板:
<div> ← 不提升(有动态子节点)
<h1>标题</h1> ← 提升
<div class="info"> ← 不提升(有动态子节点)
<span>固定</span> ← 提升
<span>{{ count }}</span> ← 不提升(动态插值)
</div>
</div>
提升后的代码:
const _h1 = createVNode("h1", null, "标题", -1) // 提升
const _span = createVNode("span", null, "固定", -1) // 提升
function render(_ctx) {
return createVNode("div", null, [
_h1,
createVNode("div", { class: "info" }, [
_span,
createVNode("span", null, toDisplayString(_ctx.count), 1)
])
])
}
Props 静态提升
不仅 VNode 节点本身可以提升,静态的 props 对象也可以提升:
<div id="main" class="container" data-version="3">
{{ dynamic }}
</div>
编译结果:
// props 对象也提升(不是每次渲染都创建新对象)
const _hoisted_props = {
id: "main",
class: "container",
"data-version": "3"
}
function render(_ctx) {
return createVNode("div", _hoisted_props, toDisplayString(_ctx.dynamic), 1)
}
为什么 props 提升重要?
- 对象创建本身有开销(内存分配 + GC 压力)
- 但更重要的是:patch 时比较 props 需要遍历对象键,静态提升后可以直接跳过这个节点(
-1 = HOISTED),不需要做任何 props 比较
diff 时的 PatchFlag 利用
运行时 diff 逻辑(packages/runtime-core/src/vnode.ts 和 packages/runtime-core/src/renderer.ts)在 patch 节点时检查 PatchFlag:
带 PatchFlag 的 patch 路径(精确更新):
// 简化的 patchElement 逻辑
function patchElement(n1: VNode, n2: VNode) {
const el = (n2.el = n1.el)
const { patchFlag, dynamicProps } = n2
if (patchFlag > 0) {
// 有 PatchFlag,走精确更新路径
if (patchFlag & PatchFlags.FULL_PROPS) {
// 动态 key,必须完整 diff props
patchProps(el, n2, n1.props, n2.props, /* ... */)
} else {
if (patchFlag & PatchFlags.CLASS) {
// 只更新 class
if (n1.props!.class !== n2.props!.class) {
hostPatchProp(el, 'class', null, n2.props!.class)
}
}
if (patchFlag & PatchFlags.STYLE) {
// 只更新 style
hostPatchProp(el, 'style', n1.props!.style, n2.props!.style)
}
if (patchFlag & PatchFlags.PROPS) {
// 只更新指定的动态 props(dynamicProps 数组里的)
for (let i = 0; i < dynamicProps!.length; i++) {
const key = dynamicProps![i]
const prev = n1.props![key]
const next = n2.props![key]
if (next !== prev) {
hostPatchProp(el, key, prev, next)
}
}
}
}
if (patchFlag & PatchFlags.TEXT) {
// 只更新文本内容
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (patchFlag === PatchFlags.BAIL) {
// 编译器放弃优化,完整 diff
patchProps(el, n2, n1.props, n2.props, /* ... */)
}
// patchFlag === -1 (HOISTED) 的节点永远不会进入 diff,因为 Block 机制
// 已经将它们排除在 dynamicChildren 之外
}
精确更新 vs 完整 diff 的对比:
完整 diff(无 PatchFlag):
1. 遍历所有旧 props → 检查是否在新 props 中(O(n))
2. 遍历所有新 props → 更新变化的(O(n))
3. 比较 children(递归)
精确更新(有 PatchFlag=3, TEXT|CLASS):
1. 检查 class 是否变化 → 更新
2. 检查 textContent 是否变化 → 更新
3. 完成
对于一个有 10 个属性的元素,完整 diff 需要检查 20 个键,精确更新只需要检查 2 个——对于 1000 个这样的元素,性能差异约 10 倍。
ASCII 图:PatchFlag 在渲染循环中的作用
组件更新触发
│
▼
patch(oldVNode, newVNode)
│
├─ patchFlag === -1 (HOISTED)?
│ └─ YES → skip(跳过,不做任何处理)
│ └─ NO ↓
│
├─ patchFlag > 0?
│ └─ YES → 精确更新路径
│ ├─ flag & TEXT → 更新文本(如果变了)
│ ├─ flag & CLASS → 更新 class(如果变了)
│ ├─ flag & STYLE → 更新 style(如果变了)
│ └─ flag & PROPS → 只更新 dynamicProps 中的属性
│ └─ NO ↓
│
└─ 无 patchFlag → 完整 diff(props + children)
├─ 遍历所有旧 props
├─ 遍历所有新 props
└─ 递归比较 children
Level 3 · 设计文档与源码(资深开发者)
PatchFlag 的源码定义
文件路径:packages/shared/src/patchFlags.ts
export const enum PatchFlags {
/**
* 动态文本内容
* e.g. `<div>{{ msg }}</div>`
*/
TEXT = 1,
/**
* 动态 class 绑定
* e.g. `<div :class="cls">`
*/
CLASS = 1 << 1, // 2
/**
* 动态 style 绑定
* e.g. `<div :style="sty">`
*/
STYLE = 1 << 2, // 4
/**
* 有除了 class/style 之外的动态 props
* 这些 props 的 key 在编译时已知,存放在 dynamicProps 数组中
* e.g. `<div :id="id" :title="title">` → dynamicProps: ['id', 'title']
*/
PROPS = 1 << 3, // 8
/**
* 动态 prop key(运行时才能确定 key 名)
* e.g. `<div :[key]="val">`
* 无法优化,需要完整 diff props
*/
FULL_PROPS = 1 << 4, // 16
/**
* 有事件监听器(仅 SSR hydration 时使用)
*/
HYDRATE_EVENTS = 1 << 5, // 32
/**
* Fragment 的子节点顺序稳定(不会因条件变化而改变)
* 可以安全地对子节点做靶向 diff
*/
STABLE_FRAGMENT = 1 << 6, // 64
/**
* 带 key 的 v-for Fragment
* 使用带 key 的 diff 算法
*/
KEYED_FRAGMENT = 1 << 7, // 128
/**
* 不带 key 的 v-for Fragment
* 使用简单的 diff 算法(patch 同位置节点)
*/
UNKEYED_FRAGMENT = 1 << 8, // 256
/**
* 需要 patch 但不在以上类别中
* 如:ref 绑定、v-model(非 input 元素)
*/
NEED_PATCH = 1 << 9, // 512
/**
* 组件有动态 slot 内容
*/
DYNAMIC_SLOTS = 1 << 10, // 1024
/**
* 开发模式下,根节点是 Fragment(仅用于 DevTools)
*/
DEV_ROOT_FRAGMENT = 1 << 11, // 2048
/**
* 表示 VNode 已静态提升
* diff 时直接跳过
*/
HOISTED = -1,
/**
* 编译器放弃优化,回退到完整 diff
* 出现在:
* - 有 v-for 但 key 不稳定
* - 有动态 slot
* - 编译器无法确定节点结构
*/
BAIL = -2,
}
静态提升的实现:hoist 函数
文件路径:packages/compiler-core/src/transforms/hoistStatic.ts
function walk(
node: ParentNode,
context: TransformContext,
doNotHoistNode: boolean = false,
) {
const { children } = node
const originalCount = children.length
let hoistedCount = 0
for (let i = 0; i < children.length; i++) {
const child = children[i]
// 只处理元素节点
if (
child.type === NodeTypes.ELEMENT &&
child.tagType === ElementTypes.ELEMENT
) {
const staticType = getStaticType(child)
if (staticType !== StaticType.NOT_STATIC) {
if (staticType === StaticType.HAS_RUNTIME_CONSTANT) {
child.codegenNode!.patchFlag = PatchFlags.HOISTED + ' /* HOISTED */'
}
// 提升:将 codegenNode 替换为 context.hoist() 的返回值
child.codegenNode = context.hoist(child.codegenNode!)
hoistedCount++
continue
}
}
// 非静态节点:递归处理子节点
if (child.type === NodeTypes.ELEMENT) {
// 检查 props 是否可以提升
const codegenNode = child.codegenNode!
if (
codegenNode.type === NodeTypes.VNODE_CALL &&
codegenNode.props
) {
const staticType = getStaticType(codegenNode.props as any)
if (staticType !== StaticType.NOT_STATIC) {
// props 对象是静态的,单独提升 props
codegenNode.props = context.hoist(codegenNode.props as any) as any
}
}
// 递归进入子节点
if (child.children) {
walk(child, context)
}
}
}
// 如果所有子节点都被提升,父节点的子节点处理也可以优化
if (hoistedCount && hoistedCount === originalCount) {
// 所有子节点都提升了,可以进一步优化父节点
// 将 children 数组本身也提升
}
}
patchElement 的完整精确更新路径
文件路径:packages/runtime-core/src/renderer.ts
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
const el = (n2.el = n1.el!)
let { patchFlag, dynamicProps, shapeFlag } = n2
// 老节点有 BAIL flag 时,新节点继承
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// 动态 key,完整 diff props
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, namespace)
} else {
// 精确更新各属性
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, namespace)
}
}
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
}
if (patchFlag & PatchFlags.PROPS) {
// dynamicProps 是编译时确定的动态 prop 列表
for (let i = 0; i < dynamicProps!.length; i++) {
const key = dynamicProps![i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, namespace, n1.children as VNode[], ...)
}
}
}
}
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized) {
// 无 patchFlag 且非 optimized 模式:完整 diff
patchProps(el, n2, oldProps, newProps, ...)
}
// 处理 children
const prevShapeFlag = n1.shapeFlag
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 新子节点是文本
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(n1.children as VNode[], ...)
}
if (n2.children !== n1.children) {
hostSetElementText(el, n2.children as string)
}
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新子节点是数组
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
patchChildren(n1, n2, el, ...)
}
}
}
Level 4 · 边界与陷阱(全体适用)
陷阱1:在 render 函数(JSX)中使用静态内容无法被自动提升
静态提升发生在编译阶段,只对 SFC <template> 模板有效。如果你使用手写的 render 函数或 JSX,编译器无法分析,不会做静态提升:
// ❌ JSX/render 函数:每次渲染都创建所有 VNode
export default defineComponent({
render() {
return h('div', [
h('h1', '静态标题'), // 每次渲染都创建新对象
h('p', this.dynamicText),
])
}
})
如果必须用 render 函数,可以手动"提升":
// ✅ 手动提升静态 VNode
const _staticTitle = h('h1', '静态标题') // 模块级常量
export default defineComponent({
render() {
return h('div', [
_staticTitle, // 复用
h('p', this.dynamicText),
])
}
})
陷阱2:带有非 prop 的动态 key 导致 FULL_PROPS,性能退化
<!-- 动态绑定的属性名 -->
<div :[dynamicKey]="value">内容</div>
由于 dynamicKey 在运行时才能确定,编译器无法做精确优化,PatchFlag 会是 FULL_PROPS(16),导致每次更新都做完整的 props diff。
// 编译结果
createVNode("div", {
[_ctx.dynamicKey]: _ctx.value
}, "内容", 16 /* FULL_PROPS */)
建议:避免使用动态属性名,改用固定属性名 + 条件渲染。
陷阱3:v-once 与静态提升的区别
v-once 和静态提升看起来相似,但有本质区别:
<!-- 静态提升:编译器自动识别,节点永远不更新 -->
<div>这是纯静态内容</div>
<!-- v-once:显式声明,首次渲染后不再更新(即使依赖变化) -->
<div v-once>{{ expensiveComputed }}</div>
- 静态提升:节点没有任何响应式依赖,编译器确认它永远不会变,直接提升
- v-once:节点可能有响应式依赖,但你明确告诉 Vue "只渲染一次"。第一次渲染后缓存 VNode,依赖变化时跳过更新
// v-once 的编译结果(使用缓存机制,不是提升)
createVNode("div", null,
_cache[0] || (_cache[0] = createTextVNode(toDisplayString(_ctx.expensiveComputed))),
-1 // HOISTED,但通过 cache 实现
)
陷阱4:HOISTED 节点被修改导致跨渲染帧污染
静态提升的 VNode 被多次渲染复用同一个对象。如果在运行时直接修改这个 VNode 对象,会污染后续渲染:
// ❌ 直接修改 VNode 的属性(不应该这么做)
// 假设 hoisted VNode 对应 <div class="static">内容</div>
const vnode = /* 某种方式获取到了 hoisted VNode */
vnode.props.class = 'modified' // ❌ 下次渲染还是用这个对象!
这在正常使用 Vue 的情况下不会发生(不要直接操作 VNode 对象),但在某些高级自定义渲染器场景下需要注意。
本章小结
-
Vue 3 的性能优势来自"减少工作量"而非"加速算法":静态提升把不变的 VNode 移出渲染循环,PatchFlag 把 diff 的范围缩小到真正动态的部分。同样的算法,处理 100 个节点比处理 10000 个节点快 100 倍。
-
PatchFlag 是编译时信息的运行时桥梁:编译器在构建时分析出"这个节点的哪些方面是动态的",通过整数 flag 传递给运行时,运行时据此做精确的最小化更新,避免了运行时再次分析的开销。
-
位运算让多个 flag 高效组合:PatchFlag 使用 2 的幂次方值,多个 flag 用位 OR 组合,检测用位 AND。这是一种在 JS 中常见的低开销标志位技术,每次判断只需一次位运算。
-
静态提升有严格的全树静态要求:一个节点的任何子孙节点含有动态内容,这个节点就无法被提升。这意味着深度嵌套的大树中,即使只有一个动态节点,也会阻止其所有祖先被提升——这是设计上的保守选择,保证正确性优先。
-
JSX/render 函数无法享受自动静态提升:这些场景需要开发者手动管理"哪些 VNode 是静态的",通过将其声明为模块级常量来实现手动"提升"。这是选择 SFC + 模板语法的一个重要性能理由。