☰
Vue 3性能提升之谜:静态提升、编译时标记与运行时优化
2026/10/15 1:55:27 网站建设 项目流程

你可能经常听到一个说法:Vue 3 的性能比 Vue 2 提升了不少。但问得更具体一点,快在哪里?很多人第一反应是“用了 Proxy”,再深入一点就答不上来了。其实真正拉开差距的,是编译期和运行时的一整套配合优化,核心就三件事:静态提升、编译时标记、运行时优化。

这篇文章我打算用大白话把这三板斧拆开讲清楚。你不是非得懂编译原理,只要写过几天 Vue,哪怕只写过单文件组件,也能看懂 Vue 3 的“性能秘密”到底藏在哪里。如果你最近在研究性能优化、追性能问题追到头秃,或者准备从 Vue 2 迁到 Vue 3,那这篇文章尤其适合你。

1. 先把思路捋清楚:为什么性能优化从编译期就开始

1.1 真正要解决的是“重复劳动”

Vue 的核心工作是:数据变了,视图跟着变。它内部维护一棵虚拟 DOM 树,每次数据变化之后,用新的虚拟 DOM 和旧的做对比,然后把差异更新到真实 DOM 上。

Vue 2 时代,这个对比是最让人头疼的。组件一旦更新,整棵虚拟 DOM 树都要从头到尾比对一遍,哪怕这一轮只改了某个按钮里的一个文字,其他所有节点也必须跟着“走个过场”。这就好比你家里每换一盏灯,就要把所有房间的开关全部检查一遍。灯多了、页面复杂了,检查成本就上去了。

Vue 3 的改变,本质上是把“检查所有房间”变成了“只检查有问题的房间”。关键是它凭什么知道哪个房间有问题?答案不在运行时,而在编译期。

模板不是随便写写就运行的。Vue 的编译器会把模板编译成一段 render 函数,同时做一次非常认真的“静态分析”。哪些节点是永远不会变的,哪些节点只有文本会变,哪些属性是动态的,哪些事件处理函数可以复用……这些信息在编译阶段就能拿到,并且被直接写进编译产物里。运行时拿到这些“提前批注”的信息,自然就能精准办事。

1.2 三大优化各管一段

这三板斧的分工很清晰:

  • 静态提升:解决“反复创建相同对象”的问题,减少内存分配和垃圾回收压力。
  • 编译时标记:解决“更新时不知道该从哪下手”的问题,用二进制标记告诉 patch 算法节点类型。
  • 运行时优化:解决“diff 太慢”和“响应式初始化太重”的问题,升级 diff 算法、细化更新路径。

你可以把它理解成做一桌菜。静态提升是提前把凉菜切好放在冰箱里,客人来了直接端出来;编译时标记是在每盘菜上贴好标签,写着“这盘要微波”“这盘直接上”;运行时优化是炒菜的时候只开必要的火,绝不把所有灶台都点燃。

三个环节是配合关系。没有编译时标记,运行时根本不知道哪些节点是静态的;没有运行时优化,编译期标记得再漂亮,diff 依旧会走很多冤枉路。所以别把它们当成三个孤立的功能点。

1.3 先看一个最终效果

用一句话总结:Vue 3 把能提前做的事全部提前到编译期,把能省的事全都在运行时省掉。

所以你在写模板的时候,静态内容和动态内容其实已经被隐式分类了。模板里放一个<p>我是固定文案</p>,它不会跟着组件重新渲染而反复重建。模板里放一个{{ msg }},它也会被打上“动态文本”的标记,更新时直接精确修改文本内容。这种“以编译器为主导、运行时积极配合”的思路,是 Vue 3 性能提升的大前提。

2. 静态提升:不变的节点只创建一次

2.1 没有静态提升时有多浪费

先说 Vue 2 的虚拟节点生命周期。组件 render 函数每执行一次,模板里的每个节点都会生成一个全新的虚拟节点对象。哪怕这个节点写的是万年不变的“确认”两个字,也会跟着每次 render 重新创建一个对象。

对象创建本身不慢,但架不住量大、频率高。尤其是长列表、大页面,一次 render 可能产生几千个虚拟节点;数据频繁变化时,render 次数成千上万,创建出来的临时对象都成了垃圾,GC 压力直接拉满。你可能会在性能面板里看到一长串的 GC 耗时,但完全不知道源头在哪。

2.2 Vue 3 的做法:把静态节点挪到 render 外面

编译器识别出模板里的静态节点后,会把它们的创建逻辑提取到 render 函数外部。render 函数每次执行时,直接引用同一个对象,而不是重新创建。

看一个最简单例子。模板长这样:

<div> <p>固定文本</p> <p>{{ msg }}</p> </div>

没有静态提升之前,编译产物大概是这样的思路:

export function render(ctx) { return createVNode('div', null, [ createVNode('p', null, '固定文本'), createVNode('p', null, ctx.msg) ]) }

每次 render 执行,'固定文本'这个节点都会被重新 create 一次。

有了静态提升之后,编译产物变成这样:

const _hoisted_1 = createElementVNode("p", null, "固定文本", -1 /* HOISTED */) export function render(_ctx, _cache) { return (openBlock(), createElementBlock("div", null, [ _hoisted_1, createElementVNode("p", null, toDisplayString(_ctx.msg), 1 /* TEXT */) ])) }

注意_hoisted_1被提升到了 render 函数外面。模块加载时创建一次,之后无论 render 执行多少次,都是复用同一个虚拟节点。后面那个-1是编译标记里的一个特殊值,专门用来标记“静态提升节点”。运行时的 patch 逻辑看到它,会直接跳过,不做任何比较。

2.3 什么样的节点会被提升

编译器判断“静态”的条件比较严格。简单来说,节点自身以及它的所有子节点都不依赖任何响应式数据,才有可能被整体提升。

比如<p class="name">张三</p>这种,class 是静态字符串,文本也是写死的,整个节点都会被提升。但<p class="name">{{ user.name }}</p>就不会整体提升,因为它的文本节点依赖响应式数据user.name。不过编译器内部的优化不止一层,还有一些局部优化,比如某些静态属性、静态子节点也会被单独提取复用。

经验上记住一个原则:模板里能写死的,就别用变量。这既是为了让静态提升发挥作用,也是为了让编译标记更精准。写死的东西越多,最终编译出来的 render 函数里_hoisted变量就越多,运行时需要对比的动态节点就越少。

注意:静态提升的主要收益在“减少重复创建对象”和“减少 GC 压力”上。它不是直接把 DOM 操作省掉,别指望它让首次渲染快一个数量级。真实的收益在更新频繁的组件上体现得最明显。

3. 编译时标记:给每个虚拟节点贴一张“说明书”

3.1 从全量 diff 到精准 diff

如果只有静态提升,Vue 3 还不算真正的“精准打击”。关键还在于编译时生成的 patchFlag,也就是补丁标记。

这个标记是一个数字,会被写到虚拟节点的patchFlag字段上。运行时看到这个数字,就知道这个节点在更新时到底需要处理什么。

比如一个节点被打上1 TEXT标记,运行时就知道:这个节点只有文本内容会变,old text 和 new text 直接对比更新就行,不需要关心它的 class、style、children 结构有没有变化。如果节点的标记是2 CLASS,那更新时只需要检查 class 属性是否变化。标记的意义是减少无谓的比较分支,让 patch 逻辑走到最短路。

我用一个生活类比来解释。快递分拣中心每天处理大量包裹,如果每个包裹都要拆开检查里面是什么,效率极低。现在快递单上提前印了“易碎”“生鲜”“普货”之类的标签,分拣员不用拆就能快速分流。patchFlag 就是贴在虚拟节点上的快递标签。

3.2 常见标记一览

Vue 3 里常用的几个 patchFlag 如下:

标记值含义通俗解释
1TEXT只有文本内容动态
2CLASS只有 class 动态
4STYLE只有 style 动态
8PROPS部分 props 动态
16FULL_PROPS完整 props 可能是动态的
64STABLE_FRAGMENT稳定的片段,子节点固定
128KEYED_FRAGMENT带 key 的列表片段
256UNKEYED_FRAGMENT不带 key 的列表片段
512NEED_PATCH需要执行完整 patch
1024DYNAMIC_SLOTS动态插槽
-1HOISTED静态提升节点,不需要 patch

你可能注意到标记值是 1、2、4、8、16……这不是随便写的,是二进制位值。这样设计是为了让多个标记能组合在一个数字里。比如某个节点文本和 class 都动态,那它的 patchFlag 可能是1 | 2,按位或一下,运行时用位与运算就能拆解,非常快。

生产环境的编译器还会进一步优化。开发模式下为了保留调试信息,生成的代码里会有额外注释;生产模式下注释会移除,代码也更精简。

3.3 Block 树:把动态节点单独收集起来

光有标记还不够,Vue 3 还引入了一个叫 Block 的概念。我们知道 patchFlag 能告诉运行时“这个节点有某种动态”,但父节点怎么知道自己的哪些子节点是动态的呢?一个个遍历找太费劲,所以编译器会把动态节点在创建时就收集到一个数组里。

看之前那段编译产物里的openBlock()和createElementBlock,它们就是在做这件事。openBlock()开启一个 Block 的收集过程,之后创建动态子节点时,如果带 patchFlag,就会自动被收进当前 Block 的动态节点数组。最后 render 返回的虚拟节点上会挂着一个dynamicChildren数组。

更新时,运行时不需要遍历整棵子节点树,只需要遍历dynamicChildren这个数组。这就是为什么 Vue 3 的 diff 有时候看起来“跳着比”,而不是老老实实逐层递归。树的规模越大,这个优化越明显。

3.4 事件处理函数也被“缓存”了

还有一个很容易踩坑的地方是事件处理函数。Vue 里写@click="handleClick",如果编译器每次 render 都生成一个新的函数传给子组件,子组件用 props 对比时就会以为外部传进来的 props 变了,进而触发不必要的重渲染。

Vue 3 的编译器会自动缓存事件处理函数。编译产物里经常能看到这样的代码:

onClick: _cache[0] || (_cache[0] = $event => _ctx.handleClick($event))

第一次 render 时创建函数并存入_cache,以后每次 render 都直接取缓存里的同一个函数。这个优化和 patchFlag 不是一个体系,但它同样在静默地帮你减少重复渲染。很多开发者以为 Vue 3 里子组件不会因为事件函数变化而更新是响应式系统的功劳,其实这里就有编译器的功劳。

4. 运行时优化:diff 算法和响应式的底层变化

4.1 从双端 diff 到快速 diff

虚拟 DOM 更新时,最难的场景是列表。新旧两个子节点数组要找出对应关系,决定哪些节点可以复用、哪些要移动、哪些要删除、哪些要新增。

Vue 2 用的是双端 diff:从新旧数组的头和尾同时向中间比较,通过四次首尾对比来尽量复用节点。这个算法在很多常见场景下表现不错,但中间部分一旦存在大量移动,处理起来仍然繁琐。

Vue 3 的快速 diff 是先比较新旧列表的公共前缀和后缀,这部分节点没有变化就直接跳过。剩下中间那一段,先把旧节点的 key 映射成一张查找表,再根据新列表确定每个节点的位置关系,最后用一个“最长递增子序列”算法找出哪些节点不需要移动。你能不动就不动,必须动的尽量少动。

我举个例子。旧列表是[A, B, C, D, E],新列表是[A, C, D, B, E]。公共前缀 A、公共后缀 E 直接跳过,中间旧列表是[B, C, D],新列表是[C, D, B]。C、D 的相对顺序在新旧列表里都没变,它们就不需要移动,只需要把 B 移动到 D 后面。最长递增子序列算法在这里找到的稳定部分就是 C、D。DOM 移动次数从可能的两三次降到了最少的范围。

4.2 key 不是随便加加而已

快速 diff 要正常工作,key 至关重要。编译器对带 key 的列表会生成KEYED_FRAGMENT标记,对没有 key 的列表生成UNKEYED_FRAGMENT。没有 key 的列表很难做精确的位置映射,只能靠索引匹配,很容易出现节点复用错位,导致状态混乱。

所以列表一定要加 key,但不能用数组 index 当 key。用 index 在头部插入一条数据时,所有后续元素的 index 都会变化,Vue 会认为它们全都不一样,只能大量卸载重建。正确做法是使用数据本身的稳定 id,比如用户 id、订单号。如果数据结构里没有唯一标识,那就说明你的列表数据设计得有问题,早发现早解决。

4.3 响应式系统也变“懒”了

Vue 2 的响应式初始化会把 data 里的对象递归遍历一遍,给每个属性都加上 getter/setter。数据越复杂,初始化成本越高。Vue 3 改用 Proxy 代理整个对象,并且默认是懒递归——只有某个属性被读取时,才会对下一层做依赖收集和代理。

这就好比检查一大箱货物。Vue 2 是开箱把所有东西全部检查登记一遍;Vue 3 是先把箱子封上,等真的有人来取某件货时,再打开那一层去处理。很多页面数据很重,但实际用到的字段有限,懒代理的收益就很明显。

4.4 运行时不是独立存在的

运行时优化的核心价值是配合编译期信息。diff 算法再快,也需要知道哪些节点真的需要 diff;响应式再快,也需要避免把时间浪费在根本不会变化的节点上。正因为编译器提前做了静态分析,运行时才能“指哪打哪”。

这也是为什么 Vue 3 官方一直强调:尽量写模板,而不是手动写 render 函数。模板能经过编译器优化,手动 render 函数无法被静态分析,patchFlag 也不会自动生成。你当然可以在 render 函数里手工标记,但这等于自己重新实现一遍编译器的活,很容易出错。所以,能用模板就用模板,别跟编译器对着干。

5. 一个模板的完整编译产物,逐行看过去

5.1 准备一个简单的测试模板

理论看多了容易飘,我们把一个实际模板放进编译器里,看它到底变成了什么。下面是一个带静态文本、动态文本、动态属性和事件的典型模板:

<template> <div class="profile"> <h2>用户信息</h2> <p class="name">{{ user.name }}</p> <p>{{ user.bio }}</p> <button @click="updateName">改名</button> </div> </template>

这段代码包含了静态节点h2、动态文本节点、动态插值、以及一个事件绑定。编译之后的产物大致如下(经过格式整理和生产模式简化):

const _hoisted_1 = /*#__PURE__*/ createElementVNode("h2", null, "用户信息", -1 /* HOISTED */) export function render(_ctx, _cache) { return (openBlock(), createElementBlock("div", { class: "profile" }, [ _hoisted_1, createElementVNode("p", { class: "name" }, toDisplayString(_ctx.user.name), 1 /* TEXT */), createElementVNode("p", null, toDisplayString(_ctx.user.bio), 1 /* TEXT */), createElementVNode("button", { onClick: _cache[0] || (_cache[0] = $event => _ctx.updateName($event)) }, "改名") ])) }

5.2 一段一段拆开看

先看_hoisted_1。h2标签、内容是“用户信息”,完全静态,于是被提升到 render 函数外部。后面的-1就是 HOISTED 标记,patch 阶段直接忽略它。

接着看两个p。它们都带1 /* TEXT */标记,也就是动态文本节点。运行时拿到一个带 TEXT 标记的节点后,只会执行“比较新旧文本、更新 textContent”的逻辑,不会去重新处理 class、style 或者其他 props。第一个p虽然有静态 classname,但因为它的文本子节点是动态的,所以整个节点不会被提升,只能带着 TEXT 标记走常规创建流程。但创建之后,更新阶段还是只需要更新文本。

再看button。注意onClick那里套了一个_cache[0]缓存。第一次 render 时创建事件函数并缓存,之后所有 render 都复用同一个函数。这样做的好处是,如果这个按钮组件化,子组件不会因为父组件每次 render 传入了“新函数”而被迫更新。

根 div 没有 patchFlag。它的 class 是静态的,所有需要处理的动态信息都已经被收集到 Block 的dynamicChildren里。更新时,运行时直接遍历dynamicChildren,不会递归地去比较h2、静态文本这些节点。树越深、静态节点越多,这个优势越明显。

5.3 怎么亲眼验证编译产物

如果你想知道自己写的模板最终编译成什么样,最简单的方式是打开构建产物,搜索_hoisted、patchFlag、_cache这些关键词。生产模式下代码被压缩,搜索时可以用cache[0]或者hoisted片段定位。

如果想单独观察某个模板,也可以用一个空项目,把模板写进单文件组件里,然后执行一次生产构建,再打开输出的 JS 文件。第一次看会有点不适应,但如果你能认出openBlock、createElementBlock、_hoisted这些结构,说明你已经开始理解 Vue 3 的编译思路了。这块技能在排查性能问题时非常有用。

6. 常见问题与避坑指南

6.1 v-for 列表没有写 key

这是很经典的问题。编译器对没有 key 的列表会生成UNKEYED_FRAGMENT,运行时只能按位置对比,意味着列表里任意一个元素的顺序变化,都可能引起大范围的重建。

建议是:所有 v-for 都写 key,key 选稳定且唯一的 id。不要用 index。如果后端没有返回唯一 id,试试组合字段,比如type + id,但前提是组合后全局唯一且稳定。

6.2 模板里塞了太复杂的表达式

模板表达式在每次 render 时都会重新执行。如果你在模板里调用了一个三层嵌套、内部循环几百次的方法,所有依赖这个方法的节点都会在每次更新时白等一次。正确的做法是把计算逻辑放进computed,让它在依赖变化时才重新执行。静态提升和编译时标记救不了“每次渲染都要跑的重逻辑”,因为编译器只看结构,不看执行时间。

6.3 手动写 render 函数,丢了自动优化

Vue 3 对模板的优化依赖编译器的静态分析。如果你用了 render 函数,甚至用 JSX,你就在跟编译器说“这段你不用管了,我自己来”。JSX 编译也会有部分优化,但和模板对比仍然少了模板特有的静态提升和标记生成。如果你不是特殊场景,建议优先用模板。

6.4 性能问题的排查顺序

感觉组件变慢了,不要上来就猜。先打开构建产物看一眼编译标记,确认是不是模板写法有问题;再看响应式依赖范围,是不是数据状态设计得太宽;最后用浏览器性能面板录制一段交互,看耗时落在哪个函数上。很多“Vue 变慢了”的问题,最后查出来都是第三方组件太肥、网络返回了大量没用数据、或者自己在 watch 里做了重活。编译器给的优化再强,也架不住你主动制造热点。

6.5 v-once 和 v-memo 要不要用

v-once可以让一段内容只渲染一次,适合极少数完全静态的场景。但既然编译器已经有静态提升了,大部分静态节点本来就会被自动处理,手动加 v-once 的意义没有你想的那么大。v-memo是按依赖值跳过更新的手段,适合大列表里只有少量项变化的场景,但用起来要小心,依赖数组写错会导致视图不更新。我的建议是先别急着上这些“手动挡”,把模板结构写清晰、用对 key、把复杂逻辑移出模板,收益通常比加优化指令更大。

写在后面的一些体会

我自己排查过几次莫名其妙的重复渲染,最后打开编译产物一看,问题往往不在 Vue 本身,而在模板结构。Vue 3 已经把九九成的脏活提前干完了,静态提升、编译时标记、快速 diff 这些设计,平时你几乎感知不到它们的存在。但当你真正去研究性能时,你会发现这些优化不是玄学,而是一套环环相扣的工程决策。

最后分享一个我常用的习惯:每写完一个组件,我会花几十秒打开生产构建产物,扫一眼这个组件的 render 函数。看它有几个_hoisted、哪些节点带了 patchFlag、事件有没有被缓存。这个习惯帮我避免了很多低级性能问题。你也可以试一次,扫一眼,不一定马上看懂全部代码,但你会看到一个真实存在的“编译优化层”。这层东西,就是 Vue 3 性能提升的秘密所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询