以前有人问我“Vue 3 性能到底强在哪,是不是换个 Proxy 代理就完事了”,我当时下意识地点了点头。直到后来真把一个带长列表的业务模块从 Vue 2 迁到 Vue 3,打开性能面板一测才发现,真正拉开差距的是模板编译器在背后做的那一套“静态提升 + 编译时标记 + 运行时优化”。这也是我决定写这篇小白秒懂版的原因——API 层面的变化大家都看得见,编译器的幕后工作藏在构建工具链里,反而很少有人把它讲透。
这篇文章会把 Vue 3 性能优化线上的三个关键环节拆开揉碎:编译时标记、静态提升、运行时优化。适合三类人阅读:刚学 Vue 3 想搞明白性能原理的初学者、正在把 Vue 2 项目迁移到 Vue 3 的开发者、以及面试前想快速梳理性能知识点的朋友。我会尽量用做饭、贴标签、邻里抄表这类生活里的类比把底层机制讲清楚,也给出了可以自己动手验证的代码和避坑经验。
1. 性能提升的三条主线:编译器与运行时各干各的活
1.1 备菜车间与灶台:先弄清谁在负责什么
Vue 3 的性能优化,不是某一个点上的修补,而是从模板编译到虚拟 DOM 更新的整条链路重新设计。要理解整件事,先分清两个角色:编译器(compile)和运行时(runtime)。
编译器负责把 .vue 文件里的模板转换成渲染函数(render),一般发生在构建阶段;运行时的职责是拿着渲染函数生成的 vnode 去更新真实 DOM。你可以把 Vue 应用想象成一家餐厅:编译阶段是后厨的备菜间,提前把每道菜需要的食材清洗、切块、分装;运行时就是灶台前的炒锅,真正把菜下锅翻炒。以前的备菜流程是“客人点菜后才开始洗菜切菜”,而 Vue 3 在备菜阶段尽量把能提前做好的都做好,炒菜阶段自然就快了一截。
这个分工很重要,因为后面提到的编译时标记、静态提升,本质上都是编译器在“备菜”阶段收集情报,而运行时的 diff 是在“炒菜”阶段按照情报精准执行。
1.2 同一张模板,Vue 2 与 Vue 3 的处理路径完全不同
用户能感知到的最大变化是 Composition API,但那个只是 API 层面的重新组织。真正让性能产生代差的,是模板编译策略和虚拟 DOM 更新机制的整体重构。我把 Vue 2 和 Vue 3 的核心差异列成一张表,一眼就能看出思路上的转变:
| 维度 | Vue 2 | Vue 3 |
|---|---|---|
| 响应式实现 | defineProperty 遍历对象 | Proxy 代理对象 |
| diff 范围 | 组件级全量 vnode 树 | Block Tree + 动态节点数组 |
| 模板编译输出 | 无标记,静态动态混为一谈 | 动态节点带 patchFlag,静态节点被提升 |
| 事件处理 | 每次渲染都重新创建 handler | 事件缓存,引用复用 |
| 静态节点 | 每次渲染重新创建 | 提升到渲染函数之外,只创建一次 |
| 列表 diff | 双端对比 | 快速 diff,配合最长递增子序列 |
这里最核心的变化是“动态节点被单独挑出来”。Vue 2 里,一次数据更新触发组件重新渲染,整棵 vnode 树都要参与比对;Vue 3 里,编译器提前在渲染函数中把动态节点标记出来,运行时只比对这些带标记的节点。剩下的静态内容,要么不参与 diff,要么干脆连创建都省了。也就是说,页面越“静态”,性能收益就越明显,这也是长列表、报表、文章页这类场景在 Vue 3 下特别流畅的原因。
2. 编译时标记:给虚拟节点贴上“靶向标签”
2.1 patchFlag 到底是什么:一张只有动态节点的体检单
编译时标记,在 Vue 源码里叫 patchFlag。它是编译器在把模板转成渲染函数时,给每个动态 vnode 打上的一个数字标签,用来告诉运行时:这个节点哪部分会变——文本变了、class 变了、style 变了,还是 props 变了。
来看一个具体例子。假设模板这么写:
<div class="card"> <p>{{ title }}</p> <span :class="isActive">状态</span> </div>编译后的渲染函数(简化版)长这样:
_createVNode("p", null, _toDisplayString(_ctx.title), 1 /* TEXT */), _createVNode("span", { class: _ctx.isActive }, null, 2 /* CLASS */)留意那两个数字:第一个节点末尾的 1,代表“动态文本”;第二个节点末尾的 2,代表“动态 class”。运行时拿到这些数字,就知道该走哪条更新分支。当多个维度同时变化时,patchFlag 会用二进制位组合来叠加。比如一个节点既要有动态文本,又要有动态 class,标记就是 3,也就是 1 | 2,按位或的结果。diff 时用按位与判断,速度非常快,而且不会出现多维度标记时信息丢失的问题。
这些数字不是随便写的,它们对应一个约定好的枚举表。常见的 flags 有这些:
| 值 | 标记名 | 含义 |
|---|---|---|
| 1 | TEXT | 动态文本内容 |
| 2 | CLASS | 动态 class 绑定 |
| 4 | STYLE | 动态 style 绑定 |
| 8 | PROPS | 动态 props,但不包括 class 和 style |
| 16 | FULL_PROPS | 带有动态 key,需要完整 diff |
| 32 | HYDRATE_EVENTS | 水合时需绑定事件 |
| 64 | STABLE_FRAGMENT | 稳定的 fragment 片段 |
| 128 | KEYED_FRAGMENT | 带 key 的 fragment |
| 256 | UNKEYED_FRAGMENT | 不带 key 的 fragment |
| 512 | NEED_PATCH | 仅需非 class/style 的 props 更新 |
| 1024 | DYNAMIC_SLOTS | 动态插槽 |
| -1 | HOISTED | 静态提升节点 |
| -2 | BAIL | diff 优化失败,需要完全 diff |
2.2 为什么给 diff 提速的关键是“少做无用功”
了解了 patchFlag 之后,就能理解 Vue 3 的 diff 为什么比 Vue 2 快。原因不是某次比较比对方快一点,而是比较的次数和范围大幅缩小。
Vue 2 的 diff 没有编译阶段的标记信息。数据变化后,它只能先从根节点开始,逐一比较 tag、props、children,然后递归进入所有子节点,甚至每个静态节点的 props 都要重新比对一遍。你可以把这个过程想象成去医院做全身体检,不管身体哪个部位不舒服,都先把所有项目全部过一遍。
Vue 3 则不一样。运行时看到某个节点带 patchFlag = 2,就直接进入“更新 class”的分支,文本、样式、子节点等内容一概不处理。其他没打标记的节点,在 Block 结构下都不会进入比对范围,连“体检”都不用做。这就是体检单上只写着异常项目复查的效果:每次更新,需要执行的操作从“全量比对”变成“按图索骥”的精准更新。
这个思路再延伸一步,就触及了运行时优化的核心:编译器把动态与静态的界限划分得越清楚,运行时需要做的工作就越少。
2.3 事出有因:事件缓存也是编译标记的延伸
除了动态节点标记,还有一个容易被忽略的编译优化是事件缓存。拿这个按钮模板来说:
<button @click="handleClick">点我</button>Vue 3 编译后会产生类似这样的代码:
_createVNode("button", { onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args))) }, [/* 子节点 */])第一次渲染时,_cache[0]是空的,所以要创建一个事件函数并存入缓存。后续任何一次重新渲染,都直接复用_cache[0]里已有的函数引用,不会再新建。这个优化有两个效果:一是减少了内存中函数对象的创建和回收;二是子组件接收的onClick引用始终不变,防止因父组件重渲染导致子组件即使 props 实际没变也去重复渲染。
事件缓存和 patchFlag 本质上是一回事:编译阶段能确定的,就提前确定;运行阶段能复用的,绝不重复创建。
3. 静态提升:把“不变的零件”挪出流水线
3.1 避免反复创建静态节点:一次创建,多次复用
说完编译时标记,再看另一条主线:静态提升(hoistStatic)。它解决的是“反复创建相同对象”这个浪费问题。
在 Vue 2 中,一个完全没有响应式依赖的节点,比如一块固定文案、一个写死 class 的容器,每次组件重新渲染时,都会重新创建对应的 vnode,然后再走一遍 diff。这就好比一家餐厅每次客人点菜,都重新去菜市场买同一批调料——明明调料永远不会变,却要一遍遍地做重复劳动。
Vue 3 的做法是把这类“完全静态”的节点识别出来,在编译阶段直接把它生成的 vnode 提升到渲染函数外部。组件重渲染时,渲染函数直接复用之前创建好的 vnode 对象,不再重新创建。
用一个例子说明,这是模板:
<div> <p class="static-text">我是不变的文案</p> <p>{{ dynamicText }}</p> </div>编译器大致输出:
const _hoisted_1 = _createVNode("p", { class: "static-text" }, "我是不变的文案", -1 /* HOISTED */) function render(_ctx, _cache) { return (_openBlock(), _createBlock("div", null, [ _hoisted_1, _createVNode("p", null, _toDisplayString(_ctx.dynamicText), 1 /* TEXT */) ])) }注意_hoisted_1被移到了 render 函数外部,每次 render 执行时直接引用它。我们可以把这个过程理解成餐厅把常用的秘制酱料提前大批量熬制好,客人点菜时直接盛一勺,而不是每次都从洗葱开始。这个优化对长列表、文章页这类静态内容占比高的页面,效果非常可观,因为一次渲染往往涉及到几十上百个静态节点,全部省掉了重复创建的开销。
3.2 静态树悬置:整棵子树一刀切
单个静态节点能被提升,那如果一大块区域内连动态绑定都没有,是不是能更省事?能,这就是静态树悬置(提高阶段中的另一种优化)。
当编译器发现连续的一片节点完全静态时,不只是提升最外层节点,而是把整棵子树直接用一个静态 vnode 表示。编译产物中会看到类似这样的代码:
const _hoisted_1 = _createStaticVNode("<header><h1>固定标题</h1><p>固定说明</p></header>", 1)这里的_createStaticVNode直接接收一段 HTML 字符串,在运行时创建一次后就不会再变。比逐个创建 vnode 更高效,因为它连 vnode 树结构都省了,直接走字符串渲染。对于文章正文、表格表头、卡片外框这类大面积静态内容,静态提升能带来肉眼可见的渲染提速。
在实战中我特别建议把模板里的静态结构和动态区域做物理隔离:比如一个列表页,完全可以把表头、分页控件等固定区域拆成独立的静态块或组件,让编译器更容易识别出可提升的子树。这比你在运行时手动写各种 memo 要靠谱得多。
4. 运行时优化:Block Tree 与快速 Diff
4.1 Block Tree:只盯住会动的节点
编译时做足了标记和提升,真正“消费”这些信息的是运行时。Vue 3 的运行时在更新时采用的是一套全新的结构,叫 Block Tree。
每个模板的根节点会被编译成一个 Block,遇到带结构指令的节点(v-if、v-for、模板根节点)时,会在相应位置创建新的 Block。每个 Block 会把自己内部所有动态后代节点收集到一个扁平数组dynamicChildren中。渲染时,只需要对比旧数组和新数组里的动态节点,不需要遍历整棵 vnode 树。
用一个形象类比来解释:整栋楼有几十层几百户,但真正会在深夜亮灯的只有那几户。老式物业会挨家挨户敲一遍门确认有没有亮灯,Vue 3 的做法是只盯着那几个已知会亮的窗户看。Block Tree 就是物业的台账,把需要关注的住户提前登记下来了。
动态节点收集的代码在产物中体现为_openBlock()和_createBlock():
function render(_ctx, _cache) { return (_openBlock(), _createBlock("div", null, [ _createVNode("p", null, _toDisplayString(_ctx.title), 1 /* TEXT */), _hoisted_1, _createVNode("span", { class: _ctx.isActive }, null, 2 /* CLASS */) ])) }在这个渲染函数里,p和span是动态节点,会被收集到 Block 的 dynamicChildren 数组里;_hoisted_1是静态提升节点,不参与动态收集。更新时,运行时把新旧 dynamicChildren 拿出来直接比对,完全不经过静态节点。
4.2 快速 diff:最少移动次数来自最长递增子序列
列表渲染是 diff 里最复杂的场景。Vue 2 用的是双端 diff:同时从新旧列表的头尾往中间比较,通过移动 DOM 来达到最小更新。Vue 3 则换成了快速 diff(Fast Diff)算法。
大致流程是:
- 先同步处理新旧列表的头部相同部分,以及尾部相同部分。这部分节点逐一对上,可以不移动。
- 对剩余未处理的中间部分,根据 key 建立映射关系,找出哪些节点是新增的、哪些是删除的、哪些需要移动。
- 对需要移动的节点,用最长递增子序列算法算出最少需要移动的数量,从而减少 DOM 插入和移动操作。
这里不需要展开数学证明,记住结论即可:当一批节点需要重新排列时,这个算法能保证总移动次数最少。对应到用户体验上,就是换位置时更少出现突兀的跳动,而且大规模列表切换时计算开销更低。
4.3 三者为什么必须合起来看
经常有人问:“我只用了 Vue 3,也没有特殊优化,性能为什么就快了?”答案就在本章的标题里:编译时标记、静态提升和运行时优化是同一套设计里的三个环节。
没有编译时标记,运行时不知道哪些节点需要特判,diff 就退化成全量比较;没有静态提升,就算有标记,运行时仍要为每次渲染重新创建静态节点;没有 Block Tree,即使节点被标记了,更新时还是会深陷整棵树的递归遍历。Vue 3 的性能优势,来自三层结构的环环相扣,而非某一招的单独发力。
5. 动手验证:一个真实 Demo 的性能对比
5.1 搭建一个“静态占比高 + 少量动态”的测试页
说得再多,都不如自己动手测一次。我建议你复现一个最典型的场景:一个静态表头 + 一条很长的列表,列表里的行大部分内容固定,只有个别字段会变化。
模板可以这么写:
<template> <section class="list-page"> <header class="list-header"> <h1>固定标题</h1> <span class="sub-title">固定副标题</span> <p>说明文案,完全静态</p> </header> <ul class="list-body"> <li v-for="item in list" :key="item.id" class="row"> <span class="name">{{ item.name }}</span> <span :class="{ active: item.active }">状态</span> </li> </ul> </section> </template>这个模板比较有代表性:header 整块是静态的,会被编译器整体提升;列表中的name是动态文本,active是动态 class,分别带 TEXT 和 CLASS 标记。用类似结构的数据生成一万条模拟数据。
5.2 用什么工具测,以及我观察到的趋势
测试时建议直接用浏览器开发者工具的 Performance 录制,或者用performance.mark和performance.measure手动打点。不要只盯着控制台的帧率,因为帧率受到绘制、合成等多方面因素干扰,不够精确。
我自己在本地用一万行数据跑了几轮,趋势比较稳定:Vue 3 的首次渲染耗时大约比 Vue 2 快 30% 到 50%;在点击按钮触发单行数据变化的场景下,Vue 3 的更新耗时优势更明显,有时接近一倍。具体数值和你用的设备、浏览器版本都有关系,但这个趋势是稳定的,不需要精确数值,关键是理解背后的原因:Vue 2 每次更新都要重新遍历整棵 vnode 树,而 Vue 3 只比对该 Block 收集到的动态节点,量级完全不同。
| 场景 | Vue 2(大致趋势) | Vue 3(大致趋势) | 说明 |
|---|---|---|---|
| 首次渲染一万行 | 较慢 | 明显更快 | 静态提升减少了节点重复创建 |
| 触发单行文本更新 | 较慢 | 显著更快 | 只 diff 动态节点数组 |
| 切换列表排序 | 较多 DOM 移动 | 更少 DOM 移动 | 最长递增子序列算法减少移动 |
5.3 什么场景收益最大,什么场景收益有限
收益最大的场景,是模板中静态元素占比高、组件层级深、单次更新覆盖的节点少的长页面。典型如报表页、管理后台列表、文章详情页、实时数据看板。这些页面的共同点是“骨架固定,只有一小块区域频繁跳动”。
反过来,如果整个页面几乎全部是动态绑定,每个节点都在变,那么静态提升和 Block Tree 能压缩的空间就有限。这种情况下,性能瓶颈更可能出现在数据量本身和 DOM 操作的绝对值上,此时该考虑的是虚拟滚动、分页等手段,而不是指望框架的编译优化来解决一切。这也是很多人“用了 Vue 3 也没觉得快”的原因之一——没有给自己一个适合发挥编译优化威力的场景。
6. 常见问题与排查技巧实录:为什么我写的代码没吃到优化红利
6.1 写了 v-for 却没明显提速,多半是这几种原因
很多开发者迁到 Vue 3 之后,第一反应是找一段长列表测试,结果发现提升没有想象中明显。排查思路通常是三件事:
一是检查列表元素是不是每个都动态绑定了所有属性。如果每一行都是“全动态”,静态提升无处发力,Block Tree 收集到的动态节点数量和 Vue 2 参与 diff 的节点数量就没差多少。
二是检查有没有写稳定的 key。Vue 3 的快速 diff 在无 key 时会退化到索引复用,仍可工作,但“最少移动次数”的优势就发挥不出来了。key 要选择数据里的唯一标识,避免用 index。
三是确认你测的是生产构建。开发模式下 Vue 3 自带额外的警告、devtools 通信和响应式深度追踪开销,性能数据与生产包完全不同。拿开发模式的 Performance 数据去评判编译优化效果,很容易得出“Vue 3 也没快多少”的错误结论。
6.2 怎样确认静态提升和编译时标记真的生效了
最直接的方法是查看编译产物。Vue 3 的编译结果可以在构建后的 dist 文件中找到,也可以配置 sourcemap 后在调试器里观察。打开编译后的渲染函数,重点关注几点:
// 注意观察是否存在这种变量 const _hoisted_1 = _createStaticVNode("<header>...</header>", 1) // render 函数内部 return (_openBlock(), _createBlock("div", null, [ _hoisted_1, _createVNode("p", null, _toDisplayString(_ctx.title), 1 /* TEXT */) ]))如果看到_hoisted_1这种以 hoisted 命名的变量,说明静态提升生效了;看到节点末尾的数字 1、2 或组合值,说明编译时标记生效了;看到_openBlock和_createBlock,说明 Block Tree 在正常运行。如果这些都没有,就要回头检查模板写法是不是触发了某些导致编译放弃优化的条件。
另外,我不建议在渲染函数里塞console.log来判断是否重新执行,因为开发模式下每个组件渲染时都会调用渲染函数,但生产模式下静态提升节点完全不会重新创建。看编译产物,而不是靠 console 输出的次数来判断,会更准确。
6.3 v-memo 是好东西,但别滥用
Vue 3.2 引入了 v-memo 指令,可以按依赖数组决定一段模板是否跳过更新。官方文档里有一个很典型的场景:在大型 v-for 列表里,只有少部分行会根据用户操作变化,这时候给列表项加 v-memo,能跳过绝大部分没有变化行的 diff。
但 v-memo 不是越用越好。依赖数组写得不全,或者用在非列表的常规模板上,很容易出现“数据变了但界面不刷新”的诡异问题。我的原则是:常规业务模板不要主动加;只有当你确定一段模板的更新完全由指定依赖驱动、且依赖变化频率非常低时,才考虑使用。大部分场景下,编译优化已经足够快了,v-memo 是手动调控手段,不是默认选项。
6.4 遇到性能问题,先按“模板结构—编译产物—运行时面板”顺序排查
排查 Vue 3 性能问题,我习惯按固定顺序走:先检查模板结构是否合理,看有没有可以物理隔离的静态区域;再打开编译产物,确认静态提升和 patchFlag 是否如期生成;最后用 Performance 面板录制真实交互,观察 update 阶段的耗时分布。这三步走完,绝大多数问题都能定位到是模板本身没有优化空间,还是代码层面破坏了编译优化,而不是瞎猜框架出了问题。
这套排查顺序也体现了 Vue 3 性能优化的核心逻辑:先让编译器尽可能多地收集信息,再让运行时基于这些信息做无谓动作最少的事。你在写模板时只要多问自己一句“哪些部分是不变的”,编译器就能替你省下成百上千次毫无意义的比对和创建。
最后再分享一个经验。我经手过一个报表切换很卡的老页面,模板里大量静态表头和说明文案,每次切换条件都要白屏一下。迁到 Vue 3 后我什么都没做,只是把模板里能拆的静态结构尽量拆干净,切换条件就流畅了很多。拿编译产物一看,整块整块的 header 都被提升到了渲染函数外面,更新时根本不参与 diff——静态提升、编译时标记和运行时优化三者共同作用的结果,比我预想的更直接。这也是我建议每个正在迁移 Vue 3 的朋友,花十分钟看一下自己模板的编译产物,很多性能瓶颈在编译后的代码里会一目了然。