Vue 3 编译器替你做了什么:静态提升、补丁标记与响应式 props 解构
一句流传很广的错话
"Vue 快,因为虚拟 DOM 快。"
这句话反了。虚拟 DOM 本身不快,甚至可以说它天生就慢:每次更新都要构造一棵新树,然后跟旧树逐节点比对。React 早期就是这么干的,代价是整棵树遍历加全量 props 比较。Vue 3 如果也这么干,不会比它快。
Vue 3 真正做的事情是把"哪里会变"提前算好,写进编译产物里,让运行时不用猜。这个思路官方叫带编译时信息的虚拟 DOM。关键点在于 Vue 同时掌握编译器和运行时——这种"自己控制两头"的便利,纯运行时框架是拿不到的。
所以看 Vue 的性能,应该看编译出来的那几十行 render 函数,而不是看 diff 算法。下面从编译产物开始拆。
模板最后变成了什么
一段模板进去,出来的是一个函数。这个函数返回 vnode 树。
<div id="app">
<p>静态文本</p>
<p>{{ msg }}</p>
</div>
编译后大致是(去掉了一堆下划线前缀):
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, createElementBlock as _createElementBlock, openBlock as _openBlock } from "vue"
const _hoisted_1 = _createElementVNode("p", null, "静态文本", -1 /* HOISTED */)
export function render(_ctx) {
return (_openBlock(), _createElementBlock("div", { id: "app" }, [
_hoisted_1,
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
]))
}
三个细节已经出现了:
第一,静态的那个 <p> 被提到了 render 函数外面,变成模块级常量 _hoisted_1。这叫静态提升。
第二,动态文本的那个节点,调用时多了一个参数 1 /* TEXT */,这是更新类型标记。
第三,最外层用的是 _createElementBlock,不是普通的 _createElementVNode,并且包了一层 _openBlock()。这是区块机制。
这三个东西加起来,构成了 Vue 3 编译优化的全部骨架。下面逐个说。
静态提升:不变的节点只创建一次
模板里大量节点是完全静态的——没有插值、没有绑定、没有事件。这些节点的 vnode 结构在组件整个生命周期里不会变,那为什么要每次重渲染都重新创建一遍?
hoistStatic 做的就是这件事:把静态节点的创建提到 render 函数外面,首次渲染时创建一次,后续重渲染直接复用同一个 vnode 对象。
效果不只是"少创建几次对象"。更关键的是,运行时能据此完全跳过这部分的 diff——新旧 vnode 是同一个引用,比对时直接判定"没变化"。官方文档的描述是:渲染器在首次渲染时会缓存这部分 vnode,后续重渲染使用缓存的 vnode,并且完全跳过对它们的差异比对。
还有更进一步的处理。当连续的静态元素足够多时,编译器会把它们压缩成一个"静态 vnode",里面存的是这些节点拼好的纯 HTML 字符串,挂载时直接走 innerHTML。
<div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div>{{ dynamic }}</div>
</div>
这五个静态 div 会变成一段字符串一次性塞进去,而不是创建五个 vnode 再逐个挂载。这一段在首屏渲染上的收益是实打实的,尤其是内容型页面里大段静态结构。
这个机制也解释了一个常见的困惑:为什么改一个列表项的文字,页面看起来"只闪了那一个地方"。因为其他部分压根没进 diff 流程。
PatchFlag:把"哪里会变"编码成一个数字
对每个带动态绑定的元素,编译器能静态推断出它到底哪部分会变。
<div :class="{ active }"></div>
编译结果里,这个 vnode 的最后一个参数是 2:
_createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)
2 就是 patch flag,标记"这个节点的 class 是动态的,其他都不是"。运行时拿到这个标记,走位运算判断:
if (vnode.patchFlag & 2 /* CLASS */) {
// 只更新 class,别的不管
}
位运算很快,但这不是重点。重点是运行时不再需要遍历这个节点的所有 props 做比较。它知道只有一个 class 会变,那就只处理 class。如果这个节点有一堆属性(id、style、data-、aria-),在纯运行时框架里每次更新都要全量对比,有了标记就一步定位。
一个节点可以有多个标记,编译时会合并成一个数字。比如 :class 加 :style,就是两个标记按位或起来的结果,运行时按位与就能分别判断出两个都需要更新。
vnode 的子节点类型也会被标记。最常见的例子是多根节点模板:
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
STABLE_FRAGMENT 的意思是"这个片段的子元素顺序永远不变",运行时因此可以完全跳过子元素顺序的重新协调过程。这段逻辑在纯运行时框架里是要真跑一遍的。
区块与树结构打平:只遍历会变的部分
有了 patch flag 还不够。如果只有标记,每次重渲染还是得顺着 vnode 树一层层往下走,才能找到那些带标记的节点——树有多深,就要走多深。
Vue 用区块(block)解决这个问题。
编译出来的树里,某些部分被标记为"区块",每个区块会追踪它所有带更新标记的后代节点,注意不只是直接子节点,是整棵子树里的。编译结果被压平成一个数组,只留下动态节点:
div (区块根)
├─ div 带 :id 绑定 ← 追踪
└─ div 带 {{ bar }} 绑定 ← 追踪
(中间那些静态的 div 不进这个数组。)
于是重渲染时,运行时只需要遍历这个打平的数组,而不是整棵树。官方把这个叫树结构打平,它把协调时需要遍历的节点数量降到了"动态节点数"这个量级。
那么问题来了:如果模板结构会变(v-if 切分支、v-for 改长度),打平数组不就失效了吗?
答案是 v-if 和 v-for 会创建新的区块。子区块挂在父区块的动态子节点数组里,父区块的结构因此保持稳定。
<div> <!-- 根区块 -->
<div>
<div v-if="ok"> <!-- if 区块,作为父区块的一个动态子项存在 -->
...
</div>
</div>
</div>
这个设计解释了一件事:区块的价值在于"结构稳定"。任何一个可能改变子节点数量的地方,编译器都会切一刀,开一个新块,让外层继续保持稳定。所以嵌套 v-if 和大列表并不会让优化失效,只是多了几层块。
优化不是万能的:这几种写法会让它失效
到这里都是好消息。但编译器能做的,前提是它能"看见"模板结构。看不见的地方,优化直接归零。
一、v-for 的 key 用得不合适。
Vue 对 v-for 会编译成 KEYED_FRAGMENT 或 UNKEYED_FRAGMENT,两者走的协调路径不同。用 index 做 key 是最常见的错误:列表头部插入一项时,所有 index 都变了,Vue 会把整列当成"每一行都换了",于是全部重渲染,还可能带走子组件的内部状态(比如输入框里的内容错位到下一行)。
值得说明的是:index 做 key 在"纯展示、不交互、不插入"的列表里确实没问题,性能损失也可以忽略。真正出问题的是带表单、带组件状态、会增删排序的列表。判断标准是"这一行有没有自己的状态"。
二、v-bind 一次性传一大坨。
<Comp v-bind="propsObject" />
编译期无法知道这个对象里有哪些 key,只能标成 FULL_PROPS,运行时退化成"比较所有 props"。这不是编译器的锅,信息确实不够。能拆开写就拆开写。
三、v-html 和运行时动态组件。
v-html 的内容对编译器完全不可见,整块跳过优化。<component :is="x"> 同理——编译期不知道会渲染什么组件,只能运行时决定。这类地方没法优化,只能靠减少渲染频次。
四、模板里调用函数。
<span>{{ formatTime(item.createdAt) }}</span>
这个 formatTime 每次重渲染都会执行一次。它不在编译优化的范围内——patch flag 只告诉运行时"这段文本是动态的",至于值怎么算出来的,编译器不管。列表里几百行、每行都调一次格式化函数,就是几百次函数调用加几百次日期解析。
修法是把计算结果挪到 computed 或者数据预处理阶段。这件事跟编译器无关,纯粹是写法问题,但它比任何编译优化都更影响体感。
五、跨组件边界。
编译器只优化单个组件的模板内部。子组件内部的渲染,父组件的编译器看不到也管不着。所以"把静态结构包进一个组件"不会让那部分变快,反而多了一层组件实例的开销。
这条最容易被误用:有人为了"代码整洁"把大段静态内容抽成组件,结果发现渲染更慢了,以为是 Vue 的问题。
v-once 和 v-memo:两个手动的逃生口
编译器判断不了的东西,可以手动告诉它。
v-once:只渲染一次,之后这个节点(含整棵子树)完全跳过更新。适合真正的静态内容——页脚版权声明、写死的使用说明、不会变的协议文本。
<footer v-once>{{ copyright }}</footer>
注意它是"彻底跳过",如果那段内容里真的引用了会变的数据,永远也不会更新。用错了比不用更麻烦。
v-memo(3.2+):给一个依赖数组,数组里的值都不变时,跳过整个子树的 diff。
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
<!-- 只有 item.selected 变化时才重新走这个子树的 diff -->
</div>
这是长列表里的实用技巧:列表项很多、每项内部结构复杂、但实际变化的只是"选中态"这一个值。用 v-memo 把依赖收窄到这一个值,其余重渲染全部跳过。
它不能替代 key,两者解决的是不同问题:key 决定"这一项是谁",v-memo 决定"这一项要不要重新算"。
六、手写渲染函数拿不到这些优化。
有人觉得"直接用 h() 构造 vnode 更底层、更可控、应该更快"。实际相反:手写的 vnode 没有 patch flag,因为标记是编译器分析模板后生成的,你手写的时候没人替你分析。运行时拿到一个没有标记的 vnode,只能按全量 props 比较处理,还得老老实实遍历子节点。
官方文档在"模板 vs 渲染函数"那一节说得很清楚:模板因为语法确定,更容易做静态分析,编译器才能施加各种编译时优化。渲染函数的适用场景是"高度动态的渲染逻辑",比如需要根据一堆运行时条件动态拼结构的可复用组件,不是因为性能。
所以别为了性能把手写模板改成 h(),方向正好是反的。
这套优化对 SSR 也是红利
前面讲的是客户端渲染。服务端渲染的收益其实更明显,官方专门用一小节讲了这件事。
SSR 在客户端要做激活(hydration):服务端吐出的 HTML 已经在页面上了,客户端要做的不是重新创建 DOM,而是把 vnode 树和已有 DOM 对上,挂上事件和响应式。这个过程的成本主要在两处——遍历节点、比对结构。
patch flag 和树结构打平正好各自解决一处:
- 单个元素的激活,可以按 patch flag 走更快的捷径,不用重新读一遍所有属性;
- 激活时只有区块节点和它的动态子节点需要遍历,静态部分直接跳过。
官方的说法是"在模板层面上实现了更高效的部分激活"。翻译一下:激活的粒度从"整棵组件树"降到了"动态节点数组"。
这也解释了为什么 Nuxt 这类 SSR 框架对模板写法有偏好——不是因为模板"更好看",而是因为只有模板才能被静态分析出动态边界。
编译期标志:一段很多人见过的警告
用 Vite 或 webpack 打包 Vue 3 项目时,控制台偶尔会冒出一段提示,大意是"你正在使用 esm-bundler 构建,建议配置编译时标志"。很多人看到就关了,其实这三个标志值得知道。
官方列的三个:
| 标志 | 默认值 | 作用 |
|---|---|---|
__VUE_OPTIONS_API__ |
true |
是否包含选项式 API 支持,关掉可以减小体积 |
__VUE_PROD_DEVTOOLS__ |
false |
生产环境是否启用 devtools 支持 |
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__ |
false |
生产环境是否输出 hydration 不匹配的详细信息(3.4+) |
它们的作用是让 tree-shaking 能把没用的代码真正删掉。比如你的项目全部用组合式 API、也没用第三方库依赖 Options API,把 __VUE_OPTIONS_API__ 设成 false,打包结果里那部分代码就没了。
@vitejs/plugin-vue 会自动提供默认值,所以正常情况下不用管。要改就在 define 里写:
export default defineConfig({
define: {
__VUE_OPTIONS_API__: 'false',
__VUE_PROD_DEVTOOLS__: 'false'
}
})
但关 __VUE_OPTIONS_API__ 有个前提得确认:第三方组件库有没有用 export default { data() {} } 这种写法。Element Plus、Ant Design Vue 这些主流库现在都是组合式 API,一般没问题;但一些老的、或者内部维护的库可能还在用。关掉之后报的错通常很诡异(this 是 undefined 之类),排查起来不直观。
3.5 里那个"响应式 props 解构",本质是编译期改名
这是本文最想讲的一点,因为它特别能说明"编译器在替你做多少事"。
在 3.4 及以前,这段代码是有名的坑:
const { foo } = defineProps(['foo'])
watchEffect(() => {
console.log(foo) // 只执行一次,foo 永远是初始值
})
原因很简单:foo 是解构出来的常量,是 props 在那一刻的值。之后父组件改了 prop,props.foo 变了,但 foo 这个局部变量不会变。
3.5 把这个变成了正式特性(官方叫 Reactive Props Destructure),并且默认开启。现在的规则是:编译器会在同一代码块内,把所有对解构变量的访问自动改写成 props.xxx。
上面那段代码编译后等价于:
const props = defineProps(['foo'])
watchEffect(() => {
console.log(props.foo) // 编译器把 foo 改写成了 props.foo
})
所以它"变响应式"了——不是运行时给变量加了什么魔法,而是编译器把变量访问全部换成了属性访问。依赖收集自然就正常了。
理解了这一点,那些看起来奇怪的限制就都讲得通了。
限制一:把解构出来的 prop 直接传给函数,会丢响应性。
const { foo } = defineProps(['foo'])
watch(foo, () => {}) // 等价于 watch(props.foo, ...),传的是值不是数据源
watch(() => foo, () => {}) // 正确:包成 getter
watch(props.foo, ...) 的第一个参数是一个字符串或数字,不是响应式数据源,watch 没法追踪。好在编译器会检测这种用法并给出警告,不会让你默默踩坑。
限制二:传进组合式函数要传 getter。
const { id } = defineProps()
useFetch(id) // 错:传的是快照
useFetch(() => id) // 对:传 getter
组合式函数内部用 toValue() 统一处理,就能同时支持 ref、getter 和普通值:
import { toValue, watchEffect } from 'vue'
export function useFetch(source) {
const data = ref(null)
watchEffect(() => {
const current = toValue(source) // getter / ref / 值,都能吃
// 用 current 去请求
})
return { data }
}
限制三:直接访问是安全的,别过度包装。
在模板、computed、watchEffect 里直接写 foo 完全没问题,编译器已经把它改成 props.foo 了。有人为了"保险"到处写 () => foo,反而让代码难读。
顺带一个实用收益:3.5 之后声明 props 默认值不用再写 withDefaults 了,用 JS 原生解构默认值就行:
// 3.4 及以前
const props = withDefaults(defineProps(), {
count: 0,
labels: () => ['one', 'two']
})
// 3.5+
const { count = 0, labels = ['one', 'two'] } = defineProps()
而且省掉了 withDefaults 那个"引用类型默认值要包成函数"的规定——解构语法下不需要,每个实例拿到的是新的解构结果。
自己看一眼编译产物,比读十篇文章有用
理解这些机制最快的办法,不是背概念,是看自己项目里的模板编译成了什么。三种方式,从轻到重。
第一种,官方在线工具。 template-explorer.vuejs.org,左边写模板右边看输出,还能勾选 hoistStatic 等选项对比差异。
第二种,本地跑一下。 装 @vue/compiler-dom,十行代码:
import { compile } from '@vue/compiler-dom'
const { code } = compile(
`<div><p>static</p><p>{{ msg }}</p><span :class="cls"></span></div>`,
{ hoistStatic: true }
)
console.log(code)
输出里能直接看到 _hoisted_1、1 /* TEXT */、2 /* CLASS */ 这些标记。拿自己项目里一个复杂组件试一次,比看任何解释都直观。
第三种,看构建后的 chunk。 生产构建里 vue 的 render 函数是被压缩过的,但 patch flag 的数字还在,_createElementBlock 也还能搜到。想确认"某个列表到底走没走区块优化",这是最接近真实情况的证据。
什么时候该操心,什么时候不必
编译优化是框架的事,不是业务开发者的日常工作量。但有两类场景值得专门看一眼:
长列表和大表格。 这是唯一一类"编译优化能带来数量级差异"的场景。检查点三个:key 是否稳定、有没有用 v-memo、列表项里有没有每行都执行的函数调用。
高频更新的组件。 拖拽、实时数据刷新、动画。这类组件的重渲染频率可能是每秒几十次,patch flag 能省下的开销会被放大。检查点是"能不能把动态范围收窄"——比如把整个对象传给子组件,不如只传会变的那两个字段。
除此之外,大部分业务页面慢,原因不在 diff。数据量大(一次渲染几千行)、DOM 节点多、图片没优化、接口串行请求——这些才是主要瓶颈,而且顺序上应该先解决它们。
有个经验判断:如果 Chrome Performance 面板里,Scripting 时间远小于 Rendering 和 Painting,那问题在布局和绘制,不在 Vue 的更新逻辑。这时候再怎么调 v-memo 都是白费劲。
最后
回到开头那句话。Vue 3 的性能优势,来自"编译期算好、运行时少猜"这个设计,虚拟 DOM 只是承载这个设计的载体。
理解了静态提升、patch flag、区块打平这三样东西,你就能回答很多以前只能靠"听说"的问题:为什么静态内容多的页面首屏快、为什么 index 当 key 会出乱子、为什么 3.5 解构 props 突然就能响应式了。
而这些问题背后其实是同一个思路:能提前算的,就别留到运行时算。这条思路不只适用于 Vue,看任何框架的优化手段,基本都能套进去。