2026/10/15 1:55:31

Vue 3性能提升之谜:静态提升、编译时标记与运行时优化

Vue 3性能提升之谜:静态提升、编译时标记与运行时优化 你可能经常听到一个说法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 classname张三/p这种class 是静态字符串文本也是写死的整个节点都会被提升。但p classname{{ 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需要执行完整 patch1024DYNAMIC_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 里写clickhandleClick如果编译器每次 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 classprofile h2用户信息/h2 p classname{{ user.name }}/p p{{ user.bio }}/p button clickupdateName改名/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 都写 keykey 选稳定且唯一的 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 性能提升的秘密所在。