Vue 3 编译器替你做了什么:静态提升、补丁标记与响应式 props 解构

作者:忆笙智云官方 | 发布时间:2026-10-30 00:02 | 更新时间:2026-10-30 00:02

一句流传很广的错话

"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 和树结构打平正好各自解决一处:

官方的说法是"在模板层面上实现了更高效的部分激活"。翻译一下:激活的粒度从"整棵组件树"降到了"动态节点数组"。

这也解释了为什么 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,看任何框架的优化手段,基本都能套进去。