跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本文的编译产物均为本机用 @vue/compiler-dom 3.5.42 真实编译输出;运行时/优化概念依据 Vue 官方 渲染机制渲染性能

深 · 模板编译与渲染优化:为什么 Vue3 只 diff「会变的那几个节点」

一句话:Vue 的模板在构建期会被编译成一段 render 函数,运行期执行它产出 VNode 树再 patch 到真实 DOM。Vue 3 的杀招是:编译期就把「哪些节点会动态变化」算好(打上 patchFlag),运行期只需要比较这些「动态节点」,而不是像 Vue2 那样 diff 整棵静态骨架。这篇把编译产物逐行拆开给你看。

1. 先建立三张「编译 → 运行」的地图

template(字符串) ──编译器──▶ render 函数 ──执行──▶ VNode 树 ──patch──▶ 真实 DOM
build 期 运行期(每个响应式依赖变化) 最小更新
  1. VNode:对真实 DOM 的「轻量描述」(标签、props、children…)。模板每次渲染都重新生成新 VNode 树,和旧树对比得出差异再打补丁。
  2. render 函数:你写的模板编译成的 JS。这也是为什么模板里「能用的东西」有限——它只是 JS,编译期被静态分析。
  3. 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 需要「节点身份」::keyKEYED_FRAGMENT(可跨位置复用);没 :keyUNKEYED_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 快一截的根源,也是你在模板里少做手动优化也稳的原因。

关联

参考