创建日期:2026-09-08 | 最近更新:2026-09-08 本文的编译产物均为本机用
@vue/compiler-dom3.5.42 真实编译输出;运行时/优化概念依据 Vue 官方 渲染机制 与 渲染性能。
深 · 模板编译与渲染优化:为什么 Vue3 只 diff「会变的那几个节点」
一句话:Vue 的模板在构建期会被编译成一段 render 函数,运行期执行它产出 VNode 树再 patch 到真实 DOM。Vue 3 的杀招是:编译期就把「哪些节点会动态变化」算好(打上 patchFlag),运行期只需要比较这些「动态节点」,而不是像 Vue2 那样 diff 整棵静态骨架。这篇把编译产物逐行拆开给你看。
1. 先建立三张「编译 → 运行」的地图
template(字符串) ──编译器──▶ render 函数 ──执行──▶ VNode 树 ──patch──▶ 真实 DOM
build 期 运行期(每个响应式依赖变化) 最小更新
- VNode:对真实 DOM 的「轻量描述」(标签、props、children…)。模板每次渲染都重新生成新 VNode 树,和旧树对比得出差异再打补丁。
- render 函数:你写的模板编译成的 JS。这也是为什么模板里「能用的东西」有限——它只是 JS,编译期被静态分析。
- patch(diff):新旧 VNode 树的差异算法。Vue2 是整棵比较;Vue3 有了编译期信息后,是按 block 只比较动态部分。
对 Vue2 熟手最该记住的一句:Vue2 的优化靠「手写 shouldUpdate/render 里手动避免重建」,Vue3 的优化靠「编译器在 build 期替你把动静分好」。你少写代码,机器多做功课。
2. 读真实编译产物(实验)
把下面模板丢给编译器:
<div>
<p>固定的静态段落</p>
<p>{{ msg }}</p>
<span :id="uid">动态属性</span>
<div v-if="ok"><b>条件块</b></div>
<ul>
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
</ul>
</div>
生成的 render(@vue/compiler-dom 3.5.42,真实输出,缩略注释是我加的):
return function render(_ctx, _cache) {
const { createElementVNode: _createElementVNode, toDisplayString: _toDisplayString,
openBlock: _openBlock, createElementBlock: _createElementBlock,
renderList: _renderList, Fragment: _Fragment, createCommentVNode: _createCommentVNode } = _Vue
return (_openBlock(), _createElementBlock("div", null, [ // ← div 是 Block
_createElementVNode("p", null, "固定的静态段落"), // 纯静态:无 patchFlag
_createElementVNode("p", null, _toDisplayString(msg), 1 /* TEXT */), // ← 动态文本
_createElementVNode("span", { id: uid }, "动态属性", 8 /* PROPS */, ["id"]), // ← 动态属性
ok ? (_openBlock(), _createElementBlock("div", { key: 0 }, [
_createElementVNode("b", null, "条件块")])) // v-if → 子 Block
: _createCommentVNode("v-if", true),
_createElementVNode("ul", null, [
(_openBlock(true), _createElementBlock(_Fragment, null,
_renderList(list, (item) => (_openBlock(), _createElementBlock("li",
{ key: item.id }, _toDisplayString(item.name), 1 /* TEXT */))),
128 /* KEYED_FRAGMENT */)) // ← v-for:整段只一个节点
])
]))
}
看懂三个记号,就懂了 Vue3 一半的渲染优化:
① Block 树(openBlock / createElementBlock)
- 最外层、以及每个
v-if/v-for/ 组件 / 动态插槽处,都会开一个新的 block(_openBlock())。 - block 的特殊能力:它把后代里带 patchFlag 的动态节点自动收进自己的
dynamicChildren数组。
_createElementBlock("div", null, [ /* 静态 + 动态 children */ ])
// 运行时 Vue 会维护:block.dynamicChildren = [带flag的动态后代节点们]
这是性能分水岭:patch 时只比较 dynamicChildren,静态骨架一次建立后几乎不再被比较。页面越大、静态部分越多,收益越大。
② patchFlag(那个数字)
节点创建时数字就标好了「它哪儿会变」:
| flag | 含义 | 上例 |
|---|---|---|
1 /* TEXT */ | 只有文本会变 | {{ msg }}、{{ item.name }} |
8 /* PROPS */, ["id"] | 指定属性会变(后面列出是哪些) | :id="uid" |
128 /* KEYED_FRAGMENT */ | 带 key 的 v-for 列表 | <li :key> 的列表 |
-1 /* CACHED */ | 静态/只建一次(进 _cache) | 静态节点(见 §3) |
有 flag 的节点进 dynamicChildren;没 flag 的(纯静态 <p>)不进——它只在初次创建时存在一次,之后被整体复用。diff 开销变成 O(动态节点数),而不是 O(整棵树)。
③ 数组按 index patch + key
v-for 之所以编译成 Fragment + 128/256,是因为列表 diff 需要「节点身份」:有 :key → KEYED_FRAGMENT(可跨位置复用);没 :key → UNKEYED_FRAGMENT(256,只能按 index 移动)。给 v-for 一个稳定唯一 key 是 Vue3 列表性能的头号规则——跟 Vue2 一样,但 Vue3 把它写进了编译 flag。
3. 静态提升 / 缓存:让「不变的东西」不再反复造
同一模板、把注释删干净后编译(hoistStatic 开启,真实输出):
<div><h1>固定标题</h1><p>{{ msg }}</p><i v-once>{{ once }}</i></div>
return function render(_ctx, _cache) {
const { createElementVNode: _createElementVNode, toDisplayString: _toDisplayString,
setBlockTracking: _setBlockTracking, createTextVNode: _createTextVNode,
openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_cache[0] || (_cache[0] = _createElementVNode("h1", null, "固定标题", -1 /* CACHED */)),
_createElementVNode("p", null, _toDisplayString(msg), 1 /* TEXT */),
_cache[1] || (_setBlockTracking(-1, true), (_cache[1] =
_createElementVNode("i", null, [_createTextVNode(_toDisplayString(once), 1 /* TEXT */)])
).cacheIndex = 0, _setBlockTracking(1), _cache[1])
]))
}
- 静态
<h1>:_cache[0]+-1 CACHED——只创建一次,之后每次 render 直接复用缓存里的 VNode,不再重复 new。 v-once:_cache[1]+_setBlockTracking(-1)——告诉编译器「这段内容永不更新」,运行期完全跳过 diff。- 在真实 SFC 生产构建里,纯静态大块还会被提升到模块顶层整段复用(产物形态略有差异,思路一致:不重复造)。
给开发者的推论:能写成静态的东西就写成静态;别给不变的节点绑响应式({{ constant }}、:class="'foo'" 这种),绑了它就从 -1 CACHED 掉回 1/8 flag,进 dynamicChildren。
4. v-memo:给「大而确实有条件不变」的地方手动加速
编译器默认只能看「静态/动态」,v-memo 让你声明「这段依赖这些值,这些值没变就别重渲」:
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<!-- 只有 item.selected 变化才 patch 这一行;item 其它字段变了也不动 -->
</div>
适用场景:超大列表里每个条目内部其实只关心少数字段。别滥用——v-memo 的依赖数组写宽了等于没 memo,写错了会造成该更新不更新。默认先信任编译器,遇到真实卡顿再上。
5. 把知识点折成「写代码的建议」
| 做法 | 原理 |
|---|---|
v-for 永远给稳定唯一 :key | 否则掉到 UNKEYED(256),diff 按 index,代价高、易错位 |
别在 v-for 同一元素上写 v-if | 编译 tip 会提示,先 filter 出列表再 for |
| 别把静态内容绑定成动态 | 让它走 -1 CACHED,别进 dynamicChildren |
真正不变的节点用 v-once | 编译期整段跳过 diff |
| 超大列表 + 条目内只关心少数字段 | 才考虑 v-memo(先测量再优化) |
| 组件切分粒度适中 | 组件多 = block 多、props diff 多;别为切而切 |
6. 串起整条链路(为什么 Vue3「快」)
写模板
│ 编译期(build)
▼
render 函数生成 patchFlag / block / _cache
│ 运行期(响应式触发 → 重新 render)
▼
只重建动态节点 → patch 时只 diff dynamicChildren
▼
真正变化的部分打到 DOM
- **响应式(篇 3)**负责回答「要不要重跑」:改了一个字段,依赖它的组件 effect 排进队列。
- **编译优化(本文)**负责回答「重跑后 diff 多少」:有 flag 的才进 dynamicChildren,静态的走缓存/提升。
- 两者拼起来 = 「精确到字段的重渲 + 精确到节点的 diff」。这就是 Vue3 在同样心智负担下比 Vue2 快一截的根源,也是你在模板里少做手动优化也稳的原因。
关联
- 上一篇:响应式原理
- 下一篇:组合式进阶 × TypeScript × 工程化
- 实验复跑:
/tmp/vue3-lab/probe-compile.mjs(@vue/compiler-dom3.5.42)
参考
- 渲染机制 / 渲染性能:cn.vuejs.org/guide/extras/rendering-mechanism、cn.vuejs.org/guide/best-practices/performance
- 源码
shared/patchFlags(flag 全表) - 编译产物为 3.5.42 实测