24_第_17_章_编译优化
约 20693 字大约 69 分钟
2026-10-05
前面几章我们讲了编译器的解析(AST)、转换(transform)、生成(generate)三件大事。本章要看的是“披着编译外衣的优化”——编译优化(compile-time optimization)。
编译优化(compile-time optimization):编译器在生成渲染函数时,尽可能多地提取模板里的“关键信息”(比如哪些是动态的、哪些是静态的),并用这些信息去生成更高效的代码,让运行时少干活。
打个比方:编译时相当于“设计师画图纸 + 交房前做验收”,运行时相当于“住户每天回家”。设计师在图纸上标注“这几间房会漏水”,住户每天回家就只盯这几间,不用把整栋楼的房间挨个推一遍门。编译优化干的就是“在图纸上提前标注”这件事。
一句话总结本章的核心思想:区分动态内容与静态内容,对它们采取不同的优化策略。
这一章的招式很多,但背后的思路只有一个。我们先用一栋楼把它讲透,后面所有名词都挂在这个比喻上。
没有编译优化的世界(传统虚拟 DOM Diff)是这样的:
有编译优化的世界是这样的:
所以本章的一句话主线是:编译器提前把“哪些节点会变”写在图纸上,运行时就不再挨个房间检查,只看被圈住的那几个。
后面你会看到 Vue 用 3 种方式在图纸上做标记:
| 标记方式 | 回答的问题 | 对应名词 |
|---|---|---|
| 给单个节点挂数字标签 | “这个节点哪里会变?” | patchFlag(补丁标志) |
| 给父节点挂一个“会变节点清单” | “这一整片里,哪几个会变?” | dynamicChildren + Block(块) |
| 把“永远不变”的东西搬出房间 | “这个东西根本不用每次重画” | 静态提升 / v-once / 预字符串化 |
17.1 动态节点收集与补丁标志
17.1.1 传统 Diff 算法的问题
第三篇讲渲染器的时候,我们介绍过 3 种传统的虚拟 DOM Diff 算法。不管哪种 Diff 算法,它都比对新旧两棵虚拟 DOM 树时,总要按层级“一层一层”地遍历。
举个例子,有这样一段模板:
<div id="foo">
<p class="bar">{{ text }}</p>
</div>在这段模板里,唯一可能变化的是 p 标签的文本子节点。也就是说,当响应式数据 text 变化时,最高效的更新方式是直接改 p 标签的文本内容。
我们把“最理想的做法”和“传统 Diff 实际做的事”摆在一起对比一下:代码块里只有 3 个节点。
// 理想做法:一步到位
document.querySelector("p").textContent = text
// 工作量:1 次
// 传统 Diff 实际做的事
// 第 1 步:比对 div 的 tag → 一样,跳过
// 第 2 步:比对 div 的 id 属性 → 一样,跳过
// 第 3 步:比对 div 的 children 数组 → 长度一样,继续
// 第 4 步:比对 p 的 tag → 一样,跳过
// 第 5 步:比对 p 的 class 属性 → 一样,跳过
// 第 6 步:比对 p 的 children → 不一样,更新
// 工作量:6 次**结论:改动量是 1 件事,Diff 却要做 6 次比较。**多出来的 5 次全是“确认它没变”的无用功。模板越大、结构越深,这个比例越夸张。
但传统 Diff 算法做不到这么高效。当 text 变化时,会生成一棵新的虚拟 DOM 树,传统 Diff 比对新旧两棵树的过程是这样的:
- 对比
div节点,以及它的属性和子节点。 - 对比
p节点,以及它的属性和子节点。 - 对比
p节点的文本子节点,如果内容变了则更新,否则什么都不做。
用这张图表示传统 Diff 在干什么:
回到开头的“整栋楼”比喻:text 就是整栋楼里唯一需要动的那间房(客厅的灯泡),但传统 Diff 派了个物业挨个敲门确认“你这间没坏吧”。
可以看到,跟“直接更新 p 标签的文本内容”相比,传统 Diff 多了一大堆没意义的比对操作。如果能跳过这些操作,性能就能大幅提升。这就是 Vue.js 3 编译优化的核心动机。
为什么虚拟 DOM 会有“额外开销”?
其实,模板的结构是非常稳定的——很多节点永远不会变。通过编译手段,我们可以分析出很多关键信息(比如哪些是静态的、哪些是动态的)。结合这些信息,编译器甚至可以直接生成“原生 DOM 操作”的代码,彻底抛掉虚拟 DOM。
但 Vue.js 3 为了兼顾灵活性(以及兼容 Vue.js 2),最终保留了虚拟 DOM。这就不可避免地要承担它带来的额外性能开销。
那为什么虚拟 DOM 会有额外开销?根本原因就是:渲染器在运行时得不到足够的信息。传统 Diff 算法没法利用任何编译时提取到的关键信息,这导致渲染器在运行时没法去做相应的优化。
而 Vue.js 3 的编译器会把编译时得到的关键信息“附着”在生成的虚拟 DOM 上。这些信息通过虚拟 DOM 传递给渲染器,渲染器根据这些信息走“快捷路径”,从而提升运行时性能。本章要讲的所有内容,都是围绕这个思路展开的。
17.1.2 Block 与 PatchFlags
传统 Diff 算法无法避免新旧虚拟 DOM 树间“无用的比较操作”,是因为运行时得不到足够的关键信息,无法区分动态内容和静态内容。换句话说:只要运行时能区分动态和静态,就能实现极致的优化。
看下面这段模板:
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>只有 {{ bar }} 是动态内容。理想情况下,当 bar 变化时,只需要更新 p 标签的文本节点。要做到这个,我们必须给运行时提供更多信息,这要从虚拟 DOM 的结构入手。
传统虚拟 DOM 怎么描述这段模板?
先看没经过编译优化的传统虚拟 DOM。它用 3 个字段描述一个节点:tag(标签名)、props(属性)、children(子节点)。
const vnode = {
tag: "div",
children: [
{ tag: "div", children: "foo" },
{ tag: "p", children: ctx.bar },
]
}我们逐行看:
const vnode = {:声明一个变量vnode(virtual node,虚拟节点),值是一个对象。tag: "div":这个节点的标签名是div。children: [:子节点数组开始。这个节点下面还挂着 2 个子节点。{ tag: "div", children: "foo" }:第一个子节点,div标签,文本内容是写死的字符串"foo"。{ tag: "p", children: ctx.bar }:第二个子节点,p标签,文本内容是从上下文ctx上取的bar。- 结尾的
]、}:数组和对象各自收尾。
传统的虚拟 DOM 里没有任何标志能体现节点的动态性。渲染器拿到这个对象,只能老老实实一层层遍历,它看不出 ctx.bar 是活的、"foo" 是死的。
但经过编译优化后,编译器会把提取到的关键信息“附着”到虚拟 DOM 节点上:
const vnode = {
tag: "div",
children: [
{ tag: "div", children: "foo" },
// 这是动态节点
{ tag: "p", children: ctx.bar, patchFlag: 1 },
]
}唯一的变化是 p 标签那一行多了 patchFlag: 1:
patchFlag: 1:多出来的第 4 个字段。1是编译器给的一个约定数字,表示“这个节点的文本内容会变”。- 为什么挂在
p身上而不是div身上?因为p里面才是{{ bar }}所在的位置,div只是路过。标记要标在真正会变的那一层。
可以看到,描述 p 标签的虚拟节点多了一个 patchFlag 属性,值是数字 1。只要虚拟节点存在这个属性,我们就认为它是动态节点。这里的 patchFlag 就是所谓的补丁标志(patch flag)。
| 节点类型 | 英文 | 判断标准 | 身上有没有 patchFlag |
|---|---|---|---|
| 动态节点 | dynamic node | 有可能在更新过程中发生变化的虚拟 DOM 节点 | 有 |
| 静态节点 | static node | 写死了内容、永远不变的节点 | 没有 |
判断标准很简单——编译器给它挂了 patchFlag,它就是动态节点;反过来,没有 patchFlag 的就是静态节点。
比喻:patchFlag 就是贴在家具上的“这会动”标签。桌子(静态)没贴,沙发(动态)贴了。
补丁标志(patchFlag):
附在虚拟 DOM 节点上的“动态标签”,用一个数字表示“这个节点哪里会变”。运行时的渲染器看到它就知道“该更新哪一块”。
比喻:快递包裹上贴的“易碎 / 生鲜 / 倒置”标签。装箱员(编译器)贴,搬运工(渲染器)看标签决定怎么搬。
⚠️ 注意单复数的区别:虚拟节点上挂的字段叫
patchFlag(单数),下面那个映射表叫PatchFlags(复数),两个不是同一个东西。原书把字段名写作patchFlags,本书按 Vue 3 的实际写法订正成了patchFlag。
补丁标志的含义
补丁标志就是一系列数字标记,不同的数字代表不同的含义:
- 数字
1:代表节点有动态的textContent(比如{{ bar }})。 - 数字
2:代表元素有动态的class绑定(比如:class="dynamicClass")。 - 数字
3:代表元素有动态的style绑定。 - 数字
4:其他动态绑定。 HOISTED:代表整棵子树都是静态的,会被提升。
通常我们在运行时代码里定义一个映射表:
const PatchFlags = {
TEXT: 1, // 代表节点有动态的 textContent
CLASS: 2, // 代表元素有动态的 class 绑定
STYLE: 3, // 代表元素有动态的 style 绑定
// 其他……
}我们逐行看:
const PatchFlags = {:定义一个叫PatchFlags的对象(映射表),专门用来给数字起名字。TEXT: 1:键TEXT对应值1。以后写代码时用PatchFlags.TEXT代替魔法数字1,别人一看就懂。CLASS: 2和STYLE: 3:同上,分别是 class 和 style 绑定对应的数字。// 其他……:原书在这里留了省略号,表示还有其他标记没列出来。
有了这个表,渲染器看到数字就能反查含义,整个流程是“编译时贴数字 → 运行时按数字办事”。
⚠️ 这里
STYLE: 3的数字和 Vue 源码对不上。这些标志是位运算的(源码里写成1 << 1、1 << 2),所以CLASS是1 << 1= 2,而STYLE是1 << 2= 4,PROPS才是1 << 3= 8。书上写成3是笔误,不影响本章后面的讲解——本章只用到“1 = 文本、2 = class”这两个结论,17.1.4 节那份注释里的对照关系也跟着一起错(见那里)。
速查表(这张图后面讲靶向更新时还会再用一次):
小细节:为什么要用数字而不是字符串? 因为数字比较更快、更省内存。运行时只需要 if (patchFlag === 1),比 if (patchFlag === "TEXT") 快一点点。
⚠️ 在浏览器里,标签搬运工(渲染器)是每秒要跑成千上万次的代码,哪怕省下一点点时间,乘起来也非常可观。这就是 Vue 团队愿意花力气抠这些细节的原因。
什么是 Block?
有了补丁标志之后,我们可以在虚拟节点的创建阶段,把它的动态子节点提取出来,并存储到该虚拟节点的 dynamicChildren 数组里。
用开头的比喻来说:patchFlag 是贴在每件家具上的“这会动”标签,而接下来这一步是在每个房间门口挂一张清单,写着“这个房间里有哪几件会动的家具”。
const vnode = {
tag: "div",
children: [
{ tag: "div", children: "foo" },
// 这是动态节点
{ tag: "p", children: ctx.bar, patchFlag: PatchFlags.TEXT }
],
// 把 children 中的动态节点提取到 dynamicChildren 数组中
dynamicChildren: [
// p 标签具有 patchFlag 属性,因此它是动态节点
{ tag: "p", children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
}我们逐行看这个 Block 的形状:
tag: "div"和children: [...]:跟普通虚拟节点一模一样,这 2 项什么都没变。也就是说 Block 并没有“换一种虚拟节点”,普通字段照旧。{ tag: "div", children: "foo" }:静态子节点。它只会待在children里,永远不会被搬进dynamicChildren。{ tag: "p", ..., patchFlag: PatchFlags.TEXT }:动态子节点。它在children里待一份(渲染要用),同时它的同一个引用被放进dynamicChildren(Diff 要用)。dynamicChildren: [...]:这个多出来的数组,是 Block 唯一的“新零件”。里面只装动态节点。- ⚠️ 关键点:
p节点在两个数组里是同一个对象,不是复制了一份。这样对比新旧树时省掉了查找开销。
跟普通虚拟节点相比,这个虚拟节点多了一个 dynamicChildren 属性。我们把带这个属性的虚拟节点称为块(Block)。
块(Block):
一种特殊的虚拟 DOM 节点,比普通虚拟节点多一个 dynamicChildren 数组。Block 本质上也是一个虚拟 DOM 节点,只不过它多了一个 dynamicChildren 字段,用来装它的动态子节点。
Block 不仅能收集直接动态子节点,还能收集所有动态子代节点(不管藏得多深)。
比喻:Block 是一张会随施工进度更新的“装修清单”。贴标签(patchFlag)是给每件家具贴“会动”标签;而 Block 是在房间门口贴一张清单,列上所有“会动的家具”,哪怕那家具在三楼阁楼里。
| 术语 | 范围 | 比喻 |
|---|---|---|
| 动态子节点 | 直接子节点 | 直接住在你家里的人 |
| 动态子代节点 | 任意深度的后代 | 你家里的人 + 他家的人 |
Block 收集的是后者。
Block 能收集子代节点
一个 Block 不仅能收集它的直接动态子节点,还能收集所有动态子代节点。比如下面这段模板:
<div>
<div>
<p>{{ bar }}</p>
</div>
</div>注意这个模板比上一个多套了一层。最外层 div 的直接子节点是中间那个 div(静态的),p 是“孙子”,隔了一层。
p 标签并不是最外层 div 的直接子节点,而是它的子代节点。但 Block 仍然能把 p 收集到 dynamicChildren 数组里:
const vnode = {
tag: "div",
children: [
{
tag: "div",
children: [
// 这是动态节点
{ tag: "p", children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
},
],
dynamicChildren: [
// Block 可以收集所有动态子代节点
{ tag: "p", children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
}逐个字段看清楚“收集”是怎么发生的:
- 最外层
tag: "div":这是 Block 自己,门口挂着清单。 children里的{ tag: "div", ... }:中间那层 div。它自己静态(没挂patchFlag),所以不会被收进清单;但它的孩子p会。p那一行:真正的动态节点,藏在第二层。挂的是patchFlag: PatchFlags.TEXT。dynamicChildren里的p:跟上面那个p是同一个对象,被“跨层”搬到了清单上。- 中间那层 div 在
dynamicChildren里找不到——这正是重点:清单里只有真正会动的东西,多深的都算数。
有了 Block 这个概念后,渲染器的更新操作会以 Block 为维度。也就是说,当渲染器更新一个 Block 时,会忽略虚拟节点的 children 数组,直接处理 dynamicChildren 数组,只更新里面的动态节点。这样就实现了“跳过静态内容,只更新动态内容”。
另外,由于动态节点上有补丁标志,更新时也能做到“靶向更新”——比如 patchFlag === 1 就只更新文本内容。
靶向更新(targeted update):知道节点具体哪里会变,直接动那一小块。比喻:传统 Diff 像“挨个检查每个房间的灯是不是还亮着”,靶向更新像“系统直接告诉你只有客厅的灯需要换”。
⚠️ 注意靶向更新的代价:正因为“清单上是平铺的、跨层的”,Diff 时不能再用传统的层级 Diff 逻辑去处理它们。这个坑会在 17.2 节炸开——
v-if、v-for就是从这儿翻车的。
什么节点需要作为 Block?
既然 Block 这么好,那什么情况下需要把一个普通的虚拟节点变成 Block?答案是:
- 所有模板的根节点都会是 Block 节点。
- 任何带有 结构化指令(
v-if/v-else-if/v-else、v-for)的节点,也需要作为 Block 节点。
看个例子:
<template>
<!-- 这个 div 标签是一个 Block -->
<div>
<!-- 这个 p 标签不是 Block(不是根节点) -->
<p>{{ bar }}</p>
</div>
<!-- 这个 h1 标签也是一个 Block -->
<h1>
<!-- 这个 span 标签不是 Block(不是根节点) -->
<span :id="dynamicId"></span>
</h1>
</template>只有 div 和 h1 是 Block,里面的 p 和 span 不是。
这里的判断依据是“是不是模板的根节点”。上面这段模板有 2 个根节点(div 和 h1),所以它俩都当 Block。
对照刚才的清单图,看这次被收进各房间门口的清单:
17.1.3 收集动态节点
前面讲的是概念,本节看具体怎么实现。
在编译器生成的渲染函数里,不会直接包含描述虚拟节点的数据结构,而是包含用来创建虚拟 DOM 节点的辅助函数:
render() {
return createVNode("div", { id: "foo" }, [
createVNode("p", null, "text")
])
}我们逐行看:
render() {:render是渲染函数(render function)——组件“画出什么”全靠它。数据一变,Vue 就重新跑一遍这个函数,拿到一棵新的虚拟 DOM 树。return createVNode("div", { id: "foo" }, [:createVNode是创建虚拟节点的辅助函数,这里创建<div id="foo">。createVNode("p", null, "text"):数组里的子节点,创建<p>text</p>。null表示这个p没有属性。])}:结束数组和函数。- 注意:这段代码里没有任何数据结构字面量(不像前面写的
{ tag: "div", ... })。编译产物是“一串调用”,不是“一堆对象”。
这里的 createVNode 就是“创建虚拟 DOM 节点”的辅助函数。它最基础的实现大概是:
function createVNode(tag, props, children) {
const key = props && props.key
props && delete props.key;
return {
tag,
props,
children,
key
}
}我们逐行看:
function createVNode(tag, props, children) {:函数(function)就是“一段可以反复使用的代码”,给它起名叫createVNode,接收 3 个参数。const key = props && props.key:取key。- 为什么
key要特殊对待?key是虚拟 DOM 里专门用来“认亲”的身份标识(配合同层节点 Diff 用),它的地位比普通属性高,所以被单独拎出来放到顶层。 props && props.key是什么意思? 这是“短路取值”写法:如果props存在(不是null),就取props.key;如果props是空的,结果就是props本身(假值)。写这一层保护是为了不传属性时也不会报错。
- 为什么
props && delete props.key;:把key从props里删掉。因为key已经被搬到顶层了,props里不能重复留一份。return { tag, props, children, key }:返回一个对象。JS 的属性简写——写tag等价于tag: tag,因为变量名和属性名正好一样。- 这 4 个字段:
tag标签名、props属性、children子节点、key身份标识。这就是一个普通虚拟节点的全部家当,目前它还不知道“哪些会变”。
⚠️ 真实项目里的
createVNode还要对props、children做额外处理(规范化类型、挂上el引用等),但优化相关的核心就是上面这 4 个字段。
编译优化对 createVNode 的影响
编译器在优化阶段提取的关键信息,会影响最终生成的代码。假设有模板:
<div id="foo">
<p class="bar">{{ text }}</p>
</div>编译后会生成带有补丁标志的渲染函数:
render() {
return createVNode("div", { id: "foo" }, [
// PatchFlags.TEXT 就是补丁标志
createVNode("p", { class: "bar" }, text, PatchFlags.TEXT)
])
}对比看差异——只多了一个参数:
createVNode("p", { class: "bar" }, text, PatchFlags.TEXT):- 原来 3 个参数:
"p"(标签)、{ class: "bar" }(属性)、text(文本)。 - 现在多了第 4 个参数
PatchFlags.TEXT。 - 这一格就是“编译器给运行时递的小纸条”,告诉渲染器:这个
p的文本会变。
- 原来 3 个参数:
- 外层
div没有第 4 个参数:div的id是写死的,不会变,所以不用标。- 所以“优化”落到代码上,往往就是多传一个参数这么简单。
注意 createVNode 调用多了一个第四个参数 PatchFlags.TEXT。这个参数就是补丁标志,代表当前虚拟 DOM 节点是动态节点,动态因素是“具有动态的文本子节点”。
怎么把根节点变成 Block?怎么收集动态子代?
下一步的关键问题是:怎么把根节点变成 Block,以及怎么把动态子代节点收集到根 Block 的 dynamicChildren 里?
这里有个重要事实:在渲染函数里,createVNode 的调用是层层的嵌套结构,并且执行顺序是由内向外(“内层先执行,外层后执行”)。
render 函数里的调用是嵌套结构:因为要构造一棵嵌套的树,函数调用天然就得写成“套娃”的样子——外层调用的参数里,又包含内层调用。
嵌套顺序:就是“套娃”的层次。最外层是根节点,最内层是最深的子节点。
创建顺序:JS 执行函数调用时,必须先把参数算完,才能去执行外层函数。所以嵌套调用的执行顺序是由内向外(最深的先跑)。
比喻:套娃式装修。外层是“刷墙”,内层是“装灯”。你不可能不装完灯就开始刷墙——灯(内层)先装好,墙(外层)才能刷。这个物理上无法绕开的顺序,就是 Block 能“顺手收集”的原因。
图 17-1 由内向外的执行方式
当外层 createVNode 执行时,内层的 createVNode 已经执行完了。所以为了让外层 Block 节点能收集到内层动态节点,就需要一个栈结构来临时存储内层的动态节点:
栈(stack):一种“后进先出”的数据结构——最后放进去的,最先拿出来,像一摞盘子。
为什么这里必须用栈?因为 Block 是可以嵌套的(div 里有 section,section 里还有 p)。每一层 Block 都要有自己独立的“收集清单”,同时又要把“这一层该收的”跟“上一层该收的”分开记——这正是栈的强项:压进去的是一份独立清单,弹出来时精确回到上一层。
// 动态节点栈
const dynamicChildrenStack = []
// 当前动态节点集合
let currentDynamicChildren = null
// openBlock 用来创建一个新的动态节点集合,并把它压入栈中
function openBlock() {
dynamicChildrenStack.push((currentDynamicChildren = []))
}
// closeBlock 用来把 openBlock 创建的动态节点集合从栈中弹出
function closeBlock() {
currentDynamicChildren = dynamicChildrenStack.pop();
}我们逐行看:
const dynamicChildrenStack = []:造一个空数组当栈用。数组的push/pop天生就是“后进先出”,所以直接拿它当栈,不用另外造轮子。let currentDynamicChildren = null:let声明一个“可改”变量,指向“当前正在收集的那份清单”。初始为null(还没有任何 Block 开始收集)。function openBlock() {:“开始收集”的信号。调用它就表示“我要开一个新的 Block 房间了”。dynamicChildrenStack.push((currentDynamicChildren = [])):这一行干了两件事,拆开看:- 先
= []创建一份全新的空清单(不会跟外层共用,否则会串味)。 - 再把它赋给
currentDynamicChildren,让“当前清单”指向这份新的。 - 最外层套上
push(...)和双层括号,表示把这份新清单压进栈。括号不能省——push(a = b)会被解析成“调用push并传一个参数a,把结果b赋给a”,那就全错了。
- 先
function closeBlock() {:“收集完毕”的信号。currentDynamicChildren = dynamicChildrenStack.pop():从栈顶弹出上一层的清单,重新赋给currentDynamicChildren。- ⚠️ 弹完之后
currentDynamicChildren指向的是外层 Block 的清单,而不是刚收集完的这层。看起来像“丢了”,其实刚刚那份已经被createBlock挂到自己的dynamicChildren上了(见下文)。
- ⚠️ 弹完之后
- 比喻:
openBlock= 戴上一副新的“动态收集眼镜”(开始看新的世界);closeBlock= 摘下眼镜(这层看完了,回到上一层)。
小细节:为什么这两个函数要成对出现? 因为 Block 是嵌套的。div 收集完要回到上一层,section 又要开新一轮——openBlock / closeBlock 就是这套“进入/退出”的开关。
改造 createVNode:动态节点自动收集
接下来调整 createVNode,让它在创建节点时自动检测“是不是动态节点”,是的话就加入当前集合:
function createVNode(tag, props, children, flags) {
const key = props && props.key;
props && delete props.key;
const vnode = {
tag,
props,
children,
key,
patchFlag: flags
}
// 如果有补丁标志 且 当前有正在收集的动态节点集合
if (typeof flags !== "undefined" && currentDynamicChildren) {
// 动态节点:把它添加到当前集合中
currentDynamicChildren.push(vnode);
}
return vnode;
}我们逐行看,加粗的 3 处是相对上一版新增的:
function createVNode(tag, props, children, flags) {:多出第 4 个参数flags(patch flag),接收编译器传来的补丁标志。const key = props && props.key;/props && delete props.key;:和上一版一样,把key拆到顶层。const vnode = { ... }:这次没有直接 return,而是先存进变量vnode——因为后面还要拿它做“入册登记”。patchFlag: flags:新增字段。把编译器传来的数字存到节点上,渲染器更新时就是靠读这个字段决定“靶向更新”哪一块。if (typeof flags !== "undefined" && currentDynamicChildren) {:新增的关键判断,两个条件都要满足:typeof flags !== "undefined":flags不是undefined,即“这个节点身上确实带了补丁标志”→ 它是动态节点。- 用
typeof而不是if (flags),是为了能正确区分0这种假值。虽然实际取值从1开始,但这是稳妥的写法。
- 用
currentDynamicChildren:当前确实有一个 Block 正在收集(值不是null)。如果没人在收集,这个动态节点就没有归属,不入册。
currentDynamicChildren.push(vnode);:新增的“入册登记”。把动态节点塞进当前清单。注意推进去的是vnode本身,不是拷贝。return vnode;:登记完再返回,跟之前返回的是同一个对象。
小细节:这一行的精髓是“被动收集”。 我们没有写任何遍历代码去“扫描”哪些节点是动态的,而是在每个节点诞生的那一刻,顺手问一句“你是不是动态的”,是的话就自己报个到。省掉了一整趟扫描。
重新设计的渲染函数
最后,重新设计渲染函数的执行方式:
render() {
// 1. 用 createBlock 代替 createVNode 来创建 Block
// 2. 每次调用 createBlock 之前,先调用 openBlock
return (openBlock(), createBlock("div", null, [
createVNode("p", { class: "foo" }, null, 1 /* patch flag */),
createVNode("p", { class: "bar" }, null),
]))
}
function createBlock(tag, props, children) {
// Block 本质上也是一个 vnode
const block = createVNode(tag, props, children);
// 把当前动态节点集合挂到 block.dynamicChildren
block.dynamicChildren = currentDynamicChildren;
// 关闭 Block
closeBlock();
// 返回
return block;
}渲染函数部分逐行看:
return (openBlock(), createBlock("div", null, [...])):- 逗号运算符:
(a, b)这种写法会先算a、再算b,整个表达式的值取b。 - 所以这里的效果是:先执行
openBlock()(先准备好空的收集清单),再执行createBlock(...)(真正创建 Block),返回的仍然是那个div虚拟节点对象。 - 如果只写
createBlock(...),那么在创建 div 的那一刻,还没有“当前清单”可以收集,dynamicChildren会是空的。openBlock必须在前面“先把摊子铺好”。 - 比喻:
openBlock()是“先把收纳箱拿出来”,createBlock()才是“把东西装进箱子”。
- 逗号运算符:
createVNode("p", { class: "foo" }, null, 1 /* patch flag */):注意它排在createBlock的参数里。JS 要先算完参数,所以这条语句一定在createBlock之前执行——它执行时currentDynamicChildren已经是那个空数组了,于是成功入册。1 /* patch flag */用注释标明了这是补丁标志(1= 动态文本)。- 这里
children传的是null(这个p本身没有子节点,动态点在它自己的文本上)。
createVNode("p", { class: "bar" }, null):第 2 个p,没有第 4 个参数,所以是静态节点,执行时不会入册。
createBlock 部分逐行看:
function createBlock(tag, props, children) {:专门用来创建 Block 节点的函数。它和createVNode的关系是:createBlock内部第一件事就是调用createVNode。const block = createVNode(tag, props, children);:注释说“Block 本质上也是一个 vnode”,代码正是如此——先按普通节点造出来,复用造节点的活儿。block.dynamicChildren = currentDynamicChildren;:整个优化的核心一行。把刚才收集到的那份清单,直接挂到这个 Block 上。- 前面说过执行顺序是“由内向外”,所以运行到这一行时,所有内层动态节点早已全部入册,
currentDynamicChildren里装的就是“属于这个 Block 的全部动态子代节点”。
- 前面说过执行顺序是“由内向外”,所以运行到这一行时,所有内层动态节点早已全部入册,
closeBlock();:必须放在赋值之后。因为closeBlock一执行,currentDynamicChildren就被栈里上一层的清单覆盖了。顺序反了,Block 就会挂上一份空的或别人的清单。return block;:返回这个 Block。跟createVNode返回的是同一个对象,只是多挂了dynamicChildren。- 小细节:记住这个三步节奏——造 vnode → 挂清单 → 收摊子。
编译器给运行时打的“小报告”:把上面这一整套串起来看,编译器干的活就是提前分析模板,然后生成一份“报告”塞进代码里交给渲染器。
| 编译器分析出来的事 | 怎么“写进”代码 | 运行时读到后做什么 |
|---|---|---|
| 这个节点文本会变 | 多传一个 PatchFlags.TEXT 参数 | 只改文本,不碰其他属性 |
这个节点是根节点 / 带 v-if | 改用 createBlock + openBlock | 挂上 dynamicChildren 清单 |
| 这个节点永远不会变 | 提到 render 外面 / 缓存起来 | 直接跳过,连比对都省了 |
一句话:编译时的“小报告”= 运行时“少干活”的通行证。
为什么这样能收集到?
注意 createVNode 和 createBlock 的执行顺序是由内向外。当 createBlock 执行时,内层的 createVNode 已经全部执行完毕了。这时候 currentDynamicChildren 数组里装的就是属于当前 Block 的所有动态子代节点(不管嵌套多深)。
只要把 currentDynamicChildren 直接挂到 block.dynamicChildren 上,就完成了“收集动态子代节点”的工作。整个过程可以用这张图表示:
用开头的“房间清单”比喻看这 5 步:
结论:栈结构 + 由内向外执行 + 自动 push,3 件事合起来完成了“收集所有动态子代节点”。
⚠️ 注意第 ⑤ 步之后
currentDynamicChildren已经指向上一层了。所以 ④ 的赋值必须在 ⑤ 之前——这两个函数挨在一起,顺序不能换。
17.1.4 渲染器的运行时支持
现在我们已经有了两件武器:
- 虚拟节点上的
dynamicChildren数组; - 附在动态节点上的补丁标志
patchFlag。
基于这两点,渲染器就能实现“靶向更新”了。
传统的 patchElement
回顾一下传统的节点更新方式(取自第三篇渲染器):
patchElement(更新元素):渲染器里负责“把旧节点 n1 原地改成新节点 n2 那样”的函数。
n1/n2:分别是旧节点(next 的前一个,n 为 1)和新节点(n 为 2)。这是渲染器里约定俗成的命名。el:真实 DOM 元素。虚拟节点只是个“图纸”,el才是墙上真正钉着的东西。- 比喻:
n1是墙上原来的挂钩照片,n2是新照片,el是那面墙。patchElement干的事是“在同一个挂钩上,把旧照片换成新照片”。
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 比对新 props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key]);
}
}
// 比对删除的 props
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null);
}
}
// 处理子节点
patchChildren(n1, n2, el);
}小细节:n2 是刚生成的、还没挂到页面上,它的 el(真实 DOM 元素)是空的。所以第 2 行 const el = n2.el = n1.el 是一次连锁赋值,含义是:把 n1.el 赋给 n2.el,再把结果赋给局部变量 el。等于“新图纸继承旧图纸记录的墙上位置”——新节点从此也知道自己是挂在哪面墙上了。
我们逐行看:
function patchElement(n1, n2) {:接收新旧两个虚拟节点。const el = n2.el = n1.el:连锁赋值。n2还没有对应的真实 DOM,所以从n1那里“继承”一份;同时赋给局部变量el供后面反复使用。const oldProps = n1.props:取出旧节点的属性,取个短名字oldProps(props = properties,属性)。const newProps = n2.props:同上,取出新节点的属性newProps。取短名字纯粹是为了下面少打字。for (const key in newProps) {:第一个for循环,遍历新属性表里的每一项。for...in是 JS 的“遍历对象所有键名”的语法。
if (newProps[key] !== oldProps[key]) {:只有新旧值不一样才动手。- 这就是“比对”两个字的具体含义:不是无脑全部重新设置,而是先问一句“变了吗”。
patchProps(el, key, oldProps[key], newProps[key]);:真变了,调用patchProps(改属性)把这一个属性改掉。- 注意传的是单个 key,不是整个对象——一次只改一处。
}:循环结束。for (const key in oldProps) {:第二个for循环,遍历旧属性表。- 为什么要再循环一次?因为属性可能被删掉了。第一个循环只看“新的有什么”,看不到“旧的有什么、新的没有”。
if (!(key in newProps)) {:反向查找——这个 key 在新属性表里不存在了。!(x in obj)这个写法要拆开读:先算x in obj(x 是不是 obj 的属性,返回真/假),外面套的!是“取反”。- 所以整句是“如果新属性表里已经没有它了,那就说明它被删了”。
patchProps(el, key, oldProps[key], null);:删属性时,把新值传成null作为“请删掉它”的信号。patchChildren(n1, n2, el);:这一行是重点。属性比对完,还要处理子节点——调用patchChildren去比对children里的内容。- ⚠️
patchChildren内部用的就是传统的层级 Diff 算法,会把子节点一层层遍历过去。
- ⚠️
可以看到,传统 patchElement 调用 patchChildren 来更新子节点。patchChildren 内部用传统虚拟 DOM Diff 算法,效率比较低。
用开头的楼比喻看这个函数:它先把整栋楼的所有门窗家具挨个检查一遍(两个 for 循环),再叫物业挨个房间敲门检查(patchChildren)。可整栋楼里可能只有一盏灯要换。
升级后的 patchElement
有了 dynamicChildren 后,我们直接对比动态节点就行:
function patchElement(n1, n2) {
const el = n2.el = n1.el;
const oldProps = n1.props;
const newProps = n2.props;
// 省略部分代码
if (n2.dynamicChildren) {
// 有 dynamicChildren,只更新动态节点
patchBlockChildren(n1, n2);
} else {
// 没有,按传统方式更新
patchChildren(n1, n2, el);
}
}
function patchBlockChildren(n1, n2) {
// 只更新动态节点
for (let i = 0; i < n2.dynamicChildren.length; i++) {
patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i]);
}
}patchElement 部分逐行看(前面 4 行和上一版一样,新增/关键的地方下面会特别说明):
const el = .../oldProps/newProps:同上一版,继承真实 DOM、取出新旧属性。// 省略部分代码:原书在这里略去了两个for循环,因为属性比对的思路完全没变——本章要改的只是“子节点怎么比对”。if (n2.dynamicChildren) {:整个优化的入口判断。- 怎么判断一个节点是不是 Block?就看它有没有
dynamicChildren这个字段。有 → 是 Block。 - 一行
if就完成了一次“模式分流”:走优化的捷径,还是走传统的老路。
- 怎么判断一个节点是不是 Block?就看它有没有
patchBlockChildren(n1, n2);:是 Block,交给新函数,它只认dynamicChildren这张清单。} else {:没有dynamicChildren,说明这是老代码路径或手写的 vnode(比如你自己createVNode造的节点),没有优化信息可用。patchChildren(n1, n2, el);:那就老老实实退回传统 Diff。这行代码特意保留——因为没有编译器帮忙的老代码也得能跑。
patchBlockChildren 部分逐行看——这个函数短,但每一行都有讲究:
function patchBlockChildren(n1, n2) {:专门用来更新 Block 动态子节点的函数。名字就是patch+Block+Children。for (let i = 0; i < n2.dynamicChildren.length; i++) {:普通for循环,不是for...in。- 为什么?因为这里比的不是“对象的键名”,而是“数组的第几项”。所以要用下标
i。
- 为什么?因为这里比的不是“对象的键名”,而是“数组的第几项”。所以要用下标
patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i]);:整个函数的灵魂。- 把新旧两份清单的同一个下标
i拿来做对比。 - 为什么这样是对的?因为
dynamicChildren是在“由内向外执行”的顺序下按顺序 push 出来的,所以新旧两棵树收集动态节点的顺序是完全一致的——第i个对第i个,天然配对。 - 这就是“数组是平级的,可以直接按下标配对”的意思:不需要传统 Diff 那一整套“同层比较 + 首尾对照 + 映射表”的花活,一个
for循环按顺序走完就行。 - ⚠️ 这个前提能成立,靠的是上一节的“结构化指令节点也要当 Block”。
v-if/v-for会让数量对不上——这就是 17.2 节要解决的问题。
- 把新旧两份清单的同一个下标
}:for循环和函数各自收尾。- 注意递归:这里调用的又是
patchElement。如果这份清单里装的也是 Block(比如v-if的子 Block),它会再进一次patchElement,又走一遍dynamicChildren分支——一层一层往下钻。
用比喻收尾:这份 dynamicChildren 清单就是物业给你的“报修工单”。上面两行 if 相当于前台分诊——有工单(dynamicChildren 存在)就走快速通道,只去修单子上写的房间;没工单就按老流程逐间巡查。
升级后,渲染器会跳过所有静态节点,只更新动态节点。
配合 patchFlag 实现“靶向”
有了 dynamicChildren 跳过静态节点,对于单个动态节点的更新,由于它带 patchFlag,我们还能做更精准的“靶向更新”:
function patchElement(n1, n2) {
const el = n2.el = n1.el;
const oldProps = n1.props;
const newProps = n2.props;
if (n2.patchFlag) {
// 靶向更新
if (n2.patchFlag === 1) {
// 只需要更新 text
} else if (n2.patchFlag === 2) {
// 只需要更新 class
} else if (...) {
// ...
}
} else {
// 全量更新
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key]);
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null);
}
}
}
// 处理子节点
patchChildren(n1, n2, el);
}⚠️ 这里要留意分支的对应关系:注释里的
patchFlag === 1对应“只需要更新 text”、=== 2对应“只需要更新 class”,要和 17.1.2 节的对照表对上(那里1是TEXT文本、2是CLASS、3才是STYLE)。另外这段写的是else if (...)的省略写法,说明分支还有很多。这段代码要看的不是具体数字,而是“用if/else if按标记分流”这个结构。
我们逐行看:
const el/oldProps/newProps:同前,先把要用到的东西取出来。if (n2.patchFlag) {:第一个分岔口——这个节点带补丁标志吗?- 带了 → 走“靶向更新”分支,编译器已经告诉我们哪儿会变。
- 没带 → 走
else的“全量更新”分支。
if (n2.patchFlag === 1) {:二级分岔。等于1(TEXT),就知道“只有文本会变”。} else if (n2.patchFlag === 2) {:等于2,知道是 class 相关的变化,就只管 class。} else if (...) { // ... }:还有更多分支,原书用省略号表示。- 这个
if / else if链条就是“按标记查表办事”:数字是查表用的钥匙,每个分支只做自己该做的那一件事。 } else {:没有补丁标志(老路径、手写 vnode,或者真静态节点),那就退回全量更新。for (const key in newProps) { ... }:老朋友回来了——遍历新属性逐个比对。注意这里跟 Block 无关:有没有patchFlag、是不是 Block,是两个独立的问题。patchFlag回答“这个节点哪里会变”(细粒度)。dynamicChildren回答“这一片里哪些节点会变”(粗粒度)。
- 两个
for循环:patchProps调用和传统版一模一样,这就是“兜底”——最保险但最费劲的走法。 patchChildren(n1, n2, el);:这行保留了。- 为什么带
patchFlag了还要处理子节点?因为patchFlag描述的是这个节点自己(文本 / class / style),它内部挂着的子节点是另一批vnode,得递归下去由子节点自己判断。所以这行写在if/else外面——两条分支都要用到它。
- 为什么带
- 最终效果:原来“遍历全部
props”,现在变成“看标记,只动对应的那一个”。
决策树长这样(patchFlag 各取值的分支走向):
这样,Vue.js 3 的渲染器就有了两把“快刀”:
dynamicChildren跳过静态节点:少干活(粗粒度:先砍掉大片不变的)。patchFlag跳过无关属性:精准干活(细粒度:剩下的那一小块也不乱碰)。
结论:传统的渲染器是“挨个房间检查每个灯泡”,升级后的渲染器是“系统直接告诉你哪几个灯泡坏了,照单换”。
用开头的比喻串起来:dynamicChildren 是门口那张报修清单(不在这张单子上,一概不碰),patchFlag 是单子上写的故障描述(“只换灯泡,别动墙”)。两道关卡叠起来,就叫“少干活 + 精准干活”。
17.2 Block 树
上一节我们约定了组件模板的根节点必须作为 Block。这样,从根节点开始,所有动态子代节点都会被收集到根节点的 dynamicChildren 数组里。
但如果只有根节点是 Block,就只会形成“单层 Block”,不会形成“Block 树”。既然提到“树”,就意味着除了根节点之外,还有别的节点需要充当 Block。
实际上,带有结构化指令(structural directive)的节点,比如 v-if、v-for,都需要作为 Block 角色。原因我们下面详细说。
结构化指令(structural directive):那些会改变 DOM 整体结构的指令,比如 v-if(新增/删除整块 DOM)、v-for(生成一组 DOM)。它们会让模板的“形状”发生变化。
⚠️ 为什么这一节特别重要?因为前面那套“按下标配对
dynamicChildren”的高效玩法,完全依赖一个前提:新旧两棵 Block 树收集到的动态节点数量和顺序完全一致。而v-if/v-for恰恰会破坏这个前提——它们就是来砸场子的。
17.2.1 带有 v-if 指令的节点
看下面这段模板:
<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<div v-else>
<p>{{ a }}</p>
</div>
</div>假设只有最外层的 div 是 Block,那么无论 foo 是 true 还是 false,Block 收集到的动态节点都是:
const block = {
tag: "div",
dynamicChildren: [
{ tag: "p", children: ctx.a, patchFlag: 1 }
]
// ...
}我们逐行看:
tag: "div":假设中的那个唯一 Block——最外层的div。dynamicChildren: [...]:它的收集清单,里面只有 1 项。{ tag: "p", children: ctx.a, patchFlag: 1 }:唯一的动态节点,挂在patchFlag: 1。// ...:原书省略了children等其他字段。
两个分支收集到的动态节点一模一样。但实际上,v-if 是 <section>,v-else 是 <div>——标签不同!如果什么都不做就跳过 Diff,渲染结果就完全错了,会产生严重 bug。
⚠️ 注意这里还藏着一个更深的坑:上面这版
block里连key都没有。patchBlockChildren靠的是“新旧清单按下标配对”,现在两份清单长得一模一样,配对“成功”了,于是渲染器认为“没变化,什么都不用做”——但页面上该换的标签根本没换。
下面这段模板问题更明显——两个分支都是 <section>,但内部结构不同:
<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<section v-else>
<div> <!-- 这个 div 在 Diff 中被忽略 -->
<p>{{ a }}</p>
</div>
</section>
</div>即使外层标签都是 <section>,分支内部的 DOM 树形状不同,传统 Diff 也会出错。
这里的问题藏得更深,两个分支长这样:
根本原因
上面这些问题的根本原因在于:dynamicChildren 数组收集的动态节点忽略了虚拟 DOM 树的层级。换句话说,结构化指令会导致“更新前后模板的结构发生变化”——模板结构不稳定。
结构不稳定:更新前后,某个 Block 的 dynamicChildren 数组里动态节点的数量或顺序对不上。
比喻:清单对不上号。前台拿着上一班的“报修工单”和这一班的工单逐行比对,结果这班凭空多出一行、少了一行,位置还串了——照单施工会装错门牌。
解决办法
解决办法很简单:让带有 v-if / v-else-if / v-else 等指令的节点也作为 Block。这样就形成了一棵 Block 树:
用“收快递”打个比方就很清楚了:
父级 Block 既会收集动态子代节点,也会收集子 Block。所以两个子 Block(Section v-if 和 Section v-else)会作为父 Block(div)的动态节点被收集:
const block = {
tag: "div",
dynamicChildren: [
// Block(Section v-if) 或者 Block(Section v-else)
{ tag: "section", key: 0 /* key 值会根据不同的 Block 而发生变化 */, dynamicChildren: [...] },
]
}
// 父级 Block 的 dynamicChildren 数组里装的,要么是 v-if 的 Block,要么是 v-else 的 Block⚠️ 这段示例代码要看懂的是三个字段:
tag(子 Block 的标签)、key(随 Block 变化)、dynamicChildren(子 Block 收集到的动态节点)。
我们逐行看:
dynamicChildren: [:父 Block 的清单,现在里面只放 1 个东西。// Block(Section v-if) 或者 Block(Section v-else):注释点明了关键:清单里装的是“子 Block”,不是p了。{ tag: "section", ... }:子 Block 的标签是section。{ key: 0 }:最关键的新增字段。key是虚拟 DOM 里用来区分身份的标识(第三篇讲过key的作用)。- 注释说得很清楚:
key的值会根据不同的 Block 而变化。v-if分支拿一个key(比如0),v-else分支拿另一个(比如1)。 - 比喻:
key就是贴在箱子上的快递单号。v-if那个箱子和v-else那个箱子长得一样,但单号不同,一比就知道“换人了”。
dynamicChildren: [...]:子 Block 自己也有清单(装它内部的p)。这说明 Block 是可以套 Block 的。- 末尾的注释行:再次强调“要么是 v-if 的 Block,要么是 v-else 的 Block”——每次渲染只会有一个。
这样,当 v-if 为真时,父 Block 的 dynamicChildren 里是 Block(section v-if);当 v-if 为假时,是 Block(section v-else)。Diff 阶段,渲染器根据 key 就能识别出“两个 Block 不同”,直接用新的 Block 替换旧的 Block,结构不稳定的问题就解决了。
完整走一遍这个例子:
17.2.2 带有 v-for 指令的节点
v-if 会让虚拟 DOM 树的结构不稳定,v-for 也会,而且更复杂。
看这段模板:
<div>
<p v-for="item in list">{{ item }}</p>
<i>{{ foo }}</i>
<i>{{ bar }}</i>
</div>假设 list = [1, 2],更新后变成 [1]。如果只有根 div 是 Block,更新前后对应的 Block 树是:
// 更新前
const prevBlock = {
tag: "div",
dynamicChildren: [
{ tag: "p", children: 1, 1 /* TEXT */ },
{ tag: "p", children: 2, 1 /* TEXT */ },
{ tag: "i", children: ctx.foo, 1 /* TEXT */ },
{ tag: "i", children: ctx.bar, 1 /* TEXT */ }
]
};
// 更新后
const nextBlock = {
tag: "div",
dynamicChildren: [
{ tag: "p", children: item, 1 /* TEXT */ },
{ tag: "i", children: ctx.foo, 1 /* TEXT */ },
{ tag: "i", children: ctx.bar, 1 /* TEXT */ }
]
};我们逐行看:
const prevBlock/const nextBlock:分别代表更新前(prev)和更新后(next)的 Block。tag: "div":假设只有根div是 Block。prevBlock.dynamicChildren:更新前的清单,4 项。- 两个
p(children分别是1和2)——因为list有 2 个元素,v-for生成了 2 个p。 - 后面两个
i分别对应{{ foo }}和{{ bar }}。 - 末尾的
1 /* TEXT */省略了属性名(patchFlag),数值都是1。
- 两个
nextBlock.dynamicChildren:更新后的清单,只有 3 项。list从 2 个变成 1 个,所以只剩 1 个p。- 两个
i位置不变。
这里的关键是位置全错位了,比 v-if 严重:
可以看到,prevBlock 有 4 个动态节点,nextBlock 只有 3 个。这时候怎么 Diff?
有人可能会想:“用传统 Diff 对两个 dynamicChildren 数组里的节点一一对比不就行?”——不行。因为传统 Diff 有一个很重要的前提:进行 Diff 的节点必须是同层级的。但 dynamicChildren 数组里的节点未必是同层级(它们是跨层级收集的)。
⚠️ 这里的“同层级”是传统 Diff 的硬性前提。传统 Diff 之所以敢按下标“两头对齐、往中间挤”,正是因为它保证拿到的两个节点在树上站在同一层。而
dynamicChildren恰恰是跨层级收集来的,前提被破坏了,传统 Diff 那套逻辑直接失效。
解决办法
解决办法依然简单:让带有 v-for 的节点也作为 Block 角色。这样能保证虚拟 DOM 树具有稳定的结构——不管 v-for 运行时怎么变化,这棵 Block 树看上去都是一样的:
const block = {
tag: "div",
dynamicChildren: [
// 这是一个 Block,它有 dynamicChildren
{ tag: Fragment, dynamicChildren: [/* v-for 的节点 */] },
{ tag: "i", children: ctx.foo, 1 /* TEXT */ },
{ tag: "i", children: ctx.bar, 1 /* TEXT */ }
]
}对比着看,变化一目了然:
{ tag: Fragment, dynamicChildren: [...] }:v-for那一大堆p被打包成了一个整体。不管list里是 1 个还是 100 个元素,清单里永远只有这 1 项。- 两个
i不变:它们还在第 1、2 位。 - 所以新旧的 Block 树“看上去一样”:都是 3 项,顺序一致,按下标配对又变得安全了。
用“快递”比喻:
由于 v-for 渲染的是一个“片段”(多个元素组成的列表),所以需要用类型为 Fragment 的节点来表达 v-for 的渲染结果,并让它充当 Block。
Fragment(片段):虚拟 DOM 里的一种节点类型,标签名是 Fragment。它表示“一组没有根元素的节点集合”,常用来表达 v-for、多根模板等场景。
比喻:v-for 生成的 3 个 <li> 没有共同的父元素(它们的“爸爸”是一个看不见的容器)。Fragment 就是这个看不见的容器节点——真实 DOM 里不渲染出任何元素,只在虚拟 DOM 里占个位,把这一堆元素框起来。
17.2.3 Fragment 的稳定性
上一节我们用 Fragment 表达 v-for 的渲染结果,并让它做 Block。但 Fragment 节点本身可能也是不稳定的。
看下面这段模板:
<p v-for="item in list">{{ item }}</p>当 list 由 [1, 2] 变成 [1] 时,对应的 Fragment 节点是:
// 更新前
const prevBlock = {
tag: Fragment,
dynamicChildren: [
{ tag: "p", children: item, 1 /* TEXT */ },
{ tag: "p", children: item, 2 /* TEXT */ }
]
};
// 更新后
const nextBlock = {
tag: Fragment,
dynamicChildren: [
{ tag: "p", children: item, 1 /* TEXT */ }
]
};我们逐行看:
prevBlock.dynamicChildren:更新前 2 个p(因为list有 2 项)。nextBlock.dynamicChildren:更新后只剩 1 个p。- 和 17.2.2 节一模一样的毛病:上一节我们靠“让
v-for节点当 Block”把外层搞稳定了,结果Fragment自己这份清单又不稳定了。
我们发现,Fragment 自己收集的动态节点依然面临结构不稳定的问题。所谓结构不稳定,从结果上看,指的是更新前后 dynamicChildren 数组里的动态节点数量或顺序不一致。
不稳定时怎么办?
对于这种情况,没有更好的办法。我们只能放弃基于 dynamicChildren 数组的靶向更新思路,回退到传统虚拟 DOM Diff 手段——直接用 Fragment 的 children 而不是 dynamicChildren 来做 Diff。
结论:这就是一次“局部退回”。 优化的目标从来不是“全都要”,而是“能省则省,省不了就保正确”。这里保正确优先于保速度。
回到楼比喻:门口那份工单对不上号了,那就别照单施工,老老实实按传统办法把这一层的房间挨个看一眼。慢一点,但不会装错。
但注意,Fragment 的子节点(children)仍然可以是由 Block 组成的数组:
const block = {
tag: Fragment,
children: [
{ tag: "p", children: item, dynamicChildren: [/*...*/], 1 /* TEXT */ },
{ tag: "p", children: item, dynamicChildren: [/*...*/], 1 /* TEXT */ }
]
};我们逐行看这个“退回”的结构:
children: [:用children代替了dynamicChildren。这就是“回退到传统 Diff”在代码上的具体体现——传统 Diff 认的是children。{ tag: "p", ..., dynamicChildren: [/*...*/] }:每个p自己又是 Block(有自己的dynamicChildren)。1 /* TEXT */:每个p还带着补丁标志。- 两个层级各管各的:
Fragment这一层:清单不可靠 → 用传统 Diff 处理children。- 每个
p内部:照样享受dynamicChildren+patchFlag的优化。 - 用比喻说:外层大件“编号混乱”,那外层就退回人工盘点;但盘点时发现每个小件自己都有合格标签,小件层面照样享受快速通道。
这样,当 Fragment 的子节点进行更新时,仍然可以恢复优化模式(每个子节点自己是 Block,可以走靶向更新)。
也有稳定的 Fragment
既然有不稳定的 Fragment,就有稳定的 Fragment。哪些情况是稳定的?
① v-for 表达式是常量:
<p v-for="n in 10"></p>
<!-- 或者 -->
<p v-for="s in 'abc'"></p>由于 10 和 "abc" 是常量,无论怎么更新,这两个 Fragment 都不会变化,所以是稳定的。对于稳定的 Fragment,不需要回退到传统 Diff,性能更好。
比喻:v-for="n in 10" 是“装修方案一开始就定死了 10 个插座”,后续再怎么刷新,插座数量也不会变。既然不变,清单就一直对得上号,没必要退回人工盘点。
② 模板有多个根节点:
Vue.js 3 不再限制组件模板必须有且仅有一个根节点。当模板里有多个根节点时,需要用 Fragment 来描述它:
<template>
<div></div>
<p></p>
<i></i>
</template>用来描述“多根节点模板”的 Fragment,也是稳定的。
比喻:这几个根节点是写在图纸上的固定结构,不是循环生成的。数量写死在模板里,永远不会多也不会少。
这两类之所以稳定,共同点是数量和顺序由模板本身决定,而不是由运行时数据决定——所以新旧两棵 Block 树天然对得上。
结论:v-if、v-for 等结构化指令会让 Block 不稳定,所以这些指令的节点本身也要当 Block。 如果 Fragment 内部本身就不稳定,那就回退到传统 Diff(用 children 而不是 dynamicChildren)。
到这里,Block 树这一节就讲完了。我们用一张图把整棵树的收集关系收个尾:
17.3 静态提升
理解了 Block 树之后,我们看其他优化。第一个就是静态提升(hoistStatic)。
静态提升(hoist static):
把“不会变”的虚拟 DOM 节点从渲染函数里“提”出来,放到渲染函数外面。这样更新时不需要重复创建这些节点。它能减少更新时创建虚拟 DOM 带来的性能开销和内存占用。
| 开销名 | 指什么 |
|---|---|
| 性能开销 | 干活本身花的时间 |
| 内存占用 | 这些数据在浏览器里占的空间 |
比喻:模板里的静态部分就像公司里不变的那套工牌和工位。render 每次跑都相当于“新员工入职流程重新办一遍全部证件”——工牌照片明明没换,却每次都要重拍重塑。静态提升就是把工牌照片提前做好裱框,挂在墙上永久复用,新人来了直接取。
看下面这段模板:
<div>
<p>static text</p>
<p>{{ title }}</p>
</div>这段模板里有 2 个 p:一个写死了文字(静态),一个绑定了 {{ title }}(动态)。
没有静态提升时,对应的渲染函数是:
function render() {
return (openBlock(), createBlock("div", null, [
createVNode("p", null, "static text"),
createVNode("p", null, ctx.title, 1 /* TEXT */)
]));
}我们逐行看:
function render() {:渲染函数。每次数据变化,它都会完整重跑一遍。return (openBlock(), createBlock("div", null, [:创建div这个 Block(null表示没有属性)。createVNode("p", null, "static text"):第一个p。注意它没有第 4 个参数——因为"static text"是写死的,永远不变,编译器不给它贴“会动”标签。createVNode("p", null, ctx.title, 1 /* TEXT */):第二个p,带1(TEXT)标记,文本会变。]))/}:收尾。
描述里有两个 p 标签——一个纯静态,一个有动态文本。当 title 变化时,整个渲染函数会重新执行,产生新的虚拟 DOM 树。问题来了:纯静态的虚拟节点在更新时也会被重新创建一次,这完全没必要。
我们要区分两种开销:创建和比对。
解决办法——静态提升:把纯静态的节点提升到渲染函数之外:
// 把静态节点提升到渲染函数之外
const hoist1 = createVNode("p", null, "static text");
function render() {
return (openBlock(), createBlock("div", null, [
hoist1, // 静态节点引用
createVNode("p", null, ctx.title, 1 /* TEXT */)
]));
}我们逐行看变化:
const hoist1 = createVNode("p", null, "static text");:关键变化。- 这一行写在
function render()外面——也就是模块顶层。 - 模块顶部的代码只在页面加载时执行一次。所以这个静态节点一辈子只被创建 1 次。
- 变量名
hoist1里的hoist就是“提升”(hoist)的意思。
- 这一行写在
function render() { ... }:还是老样子,但里面的children数组……hoist1, // 静态节点引用:关键变化。- 原来写的是
createVNode("p", null, "static text")(每次都造一个新的)。 - 现在换成了
hoist1——只是把那个变量的值填进来,没有调用任何函数,没有创建新对象。
- 原来写的是
createVNode("p", null, ctx.title, 1):动态那个p依然在render内部,每次更新照常重建(它本来就该重建)。
这样,渲染函数里只持有对静态节点的引用。title 变化、渲染函数重新执行时,静态节点不会重新创建,避免了额外的性能开销。
一个必须想明白的问题:那静态节点只有一个,会不会有“多份”的问题?
不会。因为编译出来的模块顶层只有 hoist1 这一个对象,组件被渲染 100 次、v-for 循环 10 次,用的也还是它。之所以不会串味,是因为一个虚拟节点只能挂在一个真实 DOM 上——渲染器挂载时会先用 cloneIfMounted 克隆一份,每次拿到的都是“同一个模板节点的不同副本”。
用这张图表示这个过程:
换个角度看更直观——搬砖次数:
静态提升以“树”为单位
需要强调:静态提升是以树为单位的。看下面的模板:
<div>
<section>
<p>
<span>abc</span>
</p>
</section>
</div>除了根节点 div 是 Block 不能被提升,整个 <section> 元素以及它的子代节点都会被提升。
这是“整棵树打包带走”的策略,而不是“能提一个节点提一个”:
如果我们把 abc 换成动态绑定 {{ abc }},那整棵树就都不会被提升——一旦某个节点是动态的,包含它的整个子树都保留下来。
小细节:为什么不能“只提升静态的那部分”? 因为一个虚拟节点的 children 是一个整体数组。要省事就整棵提;要保命就整棵留。半提半留的写法会让代码变得很难维护,收益也不大。
props 也能静态提升
包含动态绑定的节点本身不会被提升,但这个节点上的“纯静态属性”还是可以被提升。看下面这段模板:
<div>
<p foo="bar" a=b>{{ text }}</p>
</div>p 标签有动态文本,所以整个节点不会被静态提升。但它所有的 props 都是静态的,所以最终生成的渲染函数可以把这些纯静态的 props 提升到外面:
// 静态提升的 props 对象
const hoistProp = { foo: "bar", a: "b" };
function render(ctx) {
return (openBlock(), createBlock("div", null, [
createVNode("p", hoistProp, ctx.text)
]));
}我们逐行看:
const hoistProp = { foo: "bar", a: "b" };:节点提不动,但“属性包”能提。- 把两个写死的属性打包成一个对象,提到
render外面。 - 变量名
hoistProp——Prop就是props(属性)的意思。
- 把两个写死的属性打包成一个对象,提到
function render(ctx) {:和上一段的区别:这次有ctx参数。- 因为
p是动态的(要读ctx.text),渲染函数需要拿到上下文数据,所以编译后多了一个参数。
- 因为
createVNode("p", hoistProp, ctx.text):- 第 2 个参数不再是
{ foo: "bar", a: "b" }这样的新对象字面量,而是直接引用hoistProp。 - 第 3 个参数
ctx.text是动态的。 - ⚠️ 注意这里没有第 4 个参数。原书这一段的示意重点在“props 被提升”,没有再重复展示补丁标志;前面 17.1.3 节已经说明动态文本会带
PatchFlags.TEXT。
- 第 2 个参数不再是
- 省的是什么? 省掉每次更新都重新创建一个 props 对象的开销。对象多了会占内存,每次
new也要花时间。
这样做也能减少创建虚拟 DOM 的开销和内存占用。
结论:静态提升的原则是“能提就提”——静态节点提到外面、纯静态 props 也提到外面。但只要某个节点是动态的,整个子树就保留下来。
| 能提什么 | 为什么能提 | 为什么不能提 |
|---|---|---|
| 整棵纯静态子树 | 里面的虚拟节点永远一模一样 | 里面只要有一个动态绑定,整棵就都留着 |
| 根节点(Block) | —— | 它要挂 dynamicChildren,Diff 还要用 |
纯静态 props 对象 | 属性值写死,对象内容永远一样 | 含有动态绑定的属性(:class="a") |
17.4 预字符串化
基于静态提升,我们可以更进一步——预字符串化(pre-stringification)。它是静态提升的“加强版”。
当一段连续内容全是静态节点时,编译器把它直接拼成一个 HTML 字符串,并生成一个 Static 类型的 VNode,渲染时通过 innerHTML 一次性塞进去。
innerHTML:一个 DOM 属性,能把一整串 HTML 一次性变成真实 DOM 元素。浏览器底层对“解析一整段 HTML”做了大量优化,通常比“一个个 createElement 再逐个 append”快得多。
看下面这种“大量连续静态标签”的模板:
<div>
<p></p>
<p></p>
<!-- ... 20 个 p 标签 -->
<p></p>
</div>假设采用了静态提升,编译后的代码是:
const hoist1 = createVNode("p", null, null, PatchFlags.HOISTED);
const hoist2 = createVNode("p", null, null, PatchFlags.HOISTED);
// ... 20 个 hoistx 变量
const hoist20 = createVNode("p", null, null, PatchFlags.HOISTED);
render() {
return (openBlock(), createBlock("div", null, [
hoist1, hoist2, /* ...20 个变量 */, hoist20
]));
}我们逐行看:
const hoist1 = createVNode("p", null, null, PatchFlags.HOISTED);:- 沿用静态提升的套路,提到
render外面,只创建一次。 - 第 4 个参数
PatchFlags.HOISTED是补丁标志里的一项,表示“这个节点已经被提升过了”。 - 为什么要单独标记? 真实渲染时,渲染器看到
HOISTED就知道“这个虚拟节点对应的真实 DOM 也建好了、可以整体复用”,不必再走完整的创建流程。
- 沿用静态提升的套路,提到
const hoist2 = .../const hoist20 = ...:20 个一模一样的声明,从hoist1一直数到hoist20。// ... 20 个 hoistx 变量:原书用这一行代替中间 18 个。render()里的数组:hoist1, hoist2, /* ...20 个变量 */, hoist20——20 个变量名排成一长串。- ⚠️ 这段代码能正常工作,但一眼就能看出啰嗦:20 个变量声明 + 20 个名字。
20 个 createVNode 调用还是很啰嗦。预字符串化可以把这些静态节点序列化成字符串,生成一个 Static 类型的 VNode:
const hoistStatic = createStaticVNode("<p></p><p></p><p></p>...20 个...<p></p>");
render() {
return (openBlock(), createBlock("div", null, [
hoistStatic
]));
}我们逐行看变化:
const hoistStatic = createStaticVNode("<p></p><p></p>..."):- 变量名里的
Static和函数名createStaticVNode里的StaticVNode,指的都是静态类型的虚拟节点。 - 参数是一长串 HTML 文本:20 个
<p></p>直接拼在一起。 - 只调用一次,一劳永逸。
- 变量名里的
render()的数组:从 20 项缩成了 1 项(hoistStatic)。- 本质区别:上一段是“20 个虚拟节点对象”,这一段是“1 个字符串”。
这样做有几个明显优势:
- 大块的静态内容可以通过
innerHTML设置,性能上有优势(一次塞进去比创建 20 个虚拟节点再 patch 快得多)。 - 减少创建虚拟节点产生的性能开销——20 个节点变成 1 个。
- 减少内存占用——不用维护 20 个 VNode 对象。
用一张图对比:
预字符串化可以理解为“懒到极致”——既然这 20 个 <p> 永远不变,那就别一个个生成节点了,干脆打成字符串,到时候用 innerHTML 直接渲染。
⚠️ 代价是什么?
innerHTML是“整段替换”的操作,一旦真要改这段内容,得把整段重新塞一遍。所以它只适用于完全静态的内容——只要掺进一个动态绑定,预字符串化就完全不适用。
17.5 缓存内联事件处理函数
提到优化,就不能不提内联事件处理函数的缓存。它可以避免不必要的更新。
内联事件处理函数(inline event handler):
直接写在模板属性值里的处理代码。比如 @change="a + b",这里 a + b 不是函数名,而是一段当场执行的表达式。
不必要的更新:“更新”指父组件重新渲染后,发现传给子组件的东西变了,于是通知子组件也重画一遍。如果内容其实没变,这趟重画就是纯浪费。
看下面的模板:
<Comp @change="a + b" />这是一个绑定了 change 事件的组件,事件处理程序是一个内联语句 a + b。对于这样的模板,编译器会为它创建一个内联事件处理函数:
function render(ctx) {
return h(Comp, {
// 内联事件处理函数
onChange: () => (ctx.a + ctx.b)
})
}我们逐行看:
function render(ctx) {:渲染函数,接收上下文ctx(组件的数据都从它上面取)。return h(Comp, {...}):用h创建Comp组件的虚拟节点。h和前面说的createVNode是同一类角色的辅助函数。onChange: () => (ctx.a + ctx.b):问题的来源在这里。onChange是 Vue 内部约定的属性名:on+ 事件名(on+Change),表示“监听change事件”。() => ...是箭头函数(arrow function),一种简写的函数写法。- 关键:每次执行
render,都会新建一个箭头函数。哪怕a、b的值一模一样,函数也是全新的一个。
问题出在“全新”两个字上:
小细节:函数“相等”的判定规则。 JS 判断两个函数是否相同,只比引用(是不是同一个对象),从不比较函数体。写两个内容一模一样的箭头函数,在 JS 眼里就是两个完全不同的东西。
每次重新渲染时,都会为 Comp 创建一个全新的 props 对象,onChange 的值也是全新的函数,这会导致渲染器对 Comp 组件进行更新,造成额外的性能开销。为了避免这类无用的更新,我们需要对内联事件处理函数进行缓存,如下面的代码所示:
function render(ctx, cache) {
return h(Comp, {
// 将内联事件处理函数缓存到 cache 数组中
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
})
}我们逐行看,加粗的是相对上一版的变化:
function render(ctx, cache) {:多出了第 2 个参数cache。- 这个数组来自组件实例,相当于“这个组件的专属储物柜”——别的组件用不了,也不会串味。
- 因为是组件级的,所以同一个组件被渲染 100 次,就有 100 个独立的
cache,互不干扰。
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b)):缓存技巧全在这一行,用||(逻辑或)把两段逻辑串了起来:- 左边
cache[0]:先读“储物柜第 0 格”。 - 中间
||:||的规则是——左边是假值就返回右边。 - 右边
(cache[0] = ($event) => (ctx.a + ctx.b)):- 如果
cache[0]是空的(undefined,属于假值),就执行这一段。 cache[0] = ...是赋值表达式:创建一个新箭头函数存进第 0 格,并且这次赋值的结果就是刚存进去的那个函数。- 所以它做了两件事:存起来 + 把它当返回值。
- 如果
- 一句话说:柜子里有就取,没有就做一份存进去——这就是记忆化(memoization)的套路。
- 左边
($event) => (ctx.a + ctx.b):和上一版的() =>相比,多了($event)参数,接收事件对象。- 函数体内容不变。
- ⚠️ 注意这里
a、b都加了ctx.前缀。这是因为存在cache里的函数,生命周期比一次 render 更长——它会被反复调用,所以必须通过ctx实时读取当前的a、b,不能把它们“冻结”在创建那一刻。 - 比喻:不加
ctx.等于把“开张当天的股价”打印出来贴在门上;加上ctx.才是“告示牌上写:看实时行情”。
为什么 cache[0] 之后就不变了?
渲染函数的第二个参数是一个数组 cache,该数组来自组件实例,我们可以把内联事件处理函数添加到 cache 数组中。这样,当渲染函数重新执行并创建新的虚拟 DOM 树时,会优先读取缓存中的事件处理函数——无论执行多少次,props 对象中 onChange 属性的值始终不变,于是就不会触发 Comp 组件更新了。
把两次 render 连起来看:
小细节:cache[0] = ... 这个表达式的“双重身份”。 它既是“赋值动作”(把函数存进数组),又是“表达式”(这次赋值的结果就是那个函数本身)。JS 里 a = b 这种赋值写在表达式位置时,返回的是赋进去的值。这个特性让 || 那一行可以写得很紧凑。
⚠️ 也要留意它的“懒”:只有第一次会真的创建函数,后面 999 次都只是“读一下已有的”。这正是缓存的意义。
用一张表对比两种写法:
| 对比项 | 不缓存 onChange: () => ... | 缓存 cache[0] || (cache[0] = ...) |
|---|---|---|
| 每次 render 创建新函数? | 是 | 否(只有第一次) |
props.onChange 引用 | 每次都变 | 永远不变 |
| 子组件是否更新? | 会(无意义更新) | 不会 |
| 效果比喻 | 每天换新的门牌号 | 门牌号永久固定 |
结论:这种优化其实就是利用了“内容相同的函数在子组件眼里应该当同一个函数”的语义——引用稳定 = 内容稳定。
用开头的比喻收尾:这相当于工位名牌。内容没换却天天换新名牌,清洁工(子组件)就得天天以为“换人了”、重新打扫一遍工位。挂一块永久名牌(cache),谁也不用动了。
17.6 v-once
Vue.js 3 不仅会缓存内联事件处理函数,配合 v-once 指令还能实现对虚拟 DOM 的整体缓存。Vue.js 2 也支持 v-once。
Vue 的内置指令,标记“这个节点 / 这段内容只渲染一次”。编译器会把对应的虚拟节点缓存到 cache 数组中,之后渲染函数重新执行时直接复用。
和上一节的对比:17.5 缓存的是一个函数,v-once 缓存的是一整个虚拟节点——缓存的粒度更大。
看下面这段模板:
<section>
<div v-once>{{ foo }}</div>
</section>div 标签里有动态文本 {{ foo }},但被 v-once 标记了,所以这段模板会被编译成:
function render(ctx, cache) {
return (openBlock(), createBlock("div", null, [
cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */))
]));
}我们逐行看:
function render(ctx, cache) {:又是cache参数——和 17.5 节是同一个机制,但这次缓存的是节点。return (openBlock(), createBlock("div", null, [:创建section这个 Block。cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */)):- 用的是同一套
||缓存套路。 - 用的是
cache[1](第 1 格)而不是cache[0]。因为缓存是个数组,每个要缓存的东西占一格——这段模板里第 0 格可能被别的东西(比如内联事件函数)占了。
- 用的是同一套
createVNode("div", null, ctx.foo, 1 /* TEXT */):这里有个容易困惑的点——div明明被v-once标记了“只渲染一次”,为什么生成代码时还要带上ctx.foo这个动态值?- 答案:因为这是“第一次”执行时要用的值。
||左边为空时,这里创建的是这个div唯一一次的虚拟节点,此时foo是什么就存什么。 - 比喻:这是“开张留念”——只在店开张那天拍一张照片挂墙上,之后再装修变化都不再重拍。照片里拍到的是开张那天的样子。
- 答案:因为这是“第一次”执行时要用的值。
1 /* TEXT */:节点还是带着“文本会变”的标记——这个标记没有因为v-once而消失。
从编译结果中可以看到,该 div 标签对应的虚拟节点被缓存到了 cache 数组中。既然虚拟节点已经被缓存了,那么后续更新导致渲染函数重新执行时,会优先读取缓存的内容,而不会重新创建虚拟节点。同时,由于虚拟节点被缓存,意味着更新前后的虚拟节点不会发生变化,因此也就不需要这些被缓存的虚拟节点参与 Diff 操作了。所以在实际编译后的代码中经常出现下面这段内容:
render(ctx, cache) {
return (openBlock(), createBlock("div", null, [
cache[1] || (
// 阻止这段 VNode 被 Block 收集
setBlockTracking(-1),
cache[1] = h("div", null, ctx.foo, 1 /* TEXT */),
setBlockTracking(1), // 恢复
cache[1] // 整个表达式的值
)
]));
}这一版比上一版多包了一层圆括号,里面是 4 个用逗号连起来的表达式。这是 || 右边只能接“一个表达式”、而我们需要干 4 件事的必然结果。逐行看:
cache[1] || (:||右边用括号包起来,里面要连续执行 4 步。setBlockTracking(-1),:第 1 步——关掉“动态节点收集”。- 传
-1表示暂停收集。收集暂停期间创建的节点,不会被记进任何 Block 的dynamicChildren清单。 - 为什么要暂停?因为这个
div只渲染一次、以后永远不变,根本不该出现在任何更新清单里——列进去纯属浪费。
- 传
cache[1] = h("div", null, ctx.foo, 1 /* TEXT */),:第 2 步——创建并缓存这个节点。- 这里用了
h而不是createVNode——和 17.5 节一样,是渲染器里的另一种辅助函数写法,作用相同。
- 这里用了
setBlockTracking(1), // 恢复:第 3 步——恢复收集。-1关闭、1打开,成对出现。- ⚠️ 这两个调用必须成对。只关不开,后面的动态节点就再也收集不到了,整个优化直接失效。
cache[1] // 整个表达式的值:第 4 步——这一整段的最终结果。- 4 个逗号表达式串起来,整个括号里的值取最后一个——也就是
cache[1],刚存进去的那个节点。 - 这样
||就能正确地把它作为“缓存未命中时的值”返回了。
- 4 个逗号表达式串起来,整个括号里的值取最后一个——也就是
])/}:收尾。
把 setBlockTracking 的作用画成开关更直观:
注意上面这段代码里的 setBlockTracking(-1) 函数调用,它用来暂停动态节点的收集。换句话说,使用 v-once 包裹的动态节点不会被父级 Block 收集。因此,被 v-once 包裹的动态节点在组件更新时,自然不会参与 Diff 操作。
setBlockTracking:用来打开/关闭“动态节点收集”的开关。-1 表示关闭,1 表示重新打开。
小细节:它操作的正是 17.1.3 节那个 currentDynamicChildren 收集机制——关掉它,createVNode 里的那个 if 就进不去了,节点自然也就没处可登记。
比喻:清单旁边挂了个**“暂停登记”的开关**。被 v-once 圈住的那批东西路过时不登记,物业就不会上门打扰。
v-once 的典型用法
v-once 指令通常用于不会发生改变的动态绑定中,例如绑定一个常量:
<div>{{ SOME_CONSTANT }}</div>这段代码从语法上是“动态绑定”(用了 {{ }}),但绑定的其实是个常量——它永远不变。按常理它完全可以被静态提升处理掉,但“从语法上是动态的”这件事,编译器在这一步还判断不了,所以先老老实实生成动态节点。
为了提升性能,我们可以使用 v-once 来标记这段内容:
<div v-once>{{ SOME_CONSTANT }}</div>这样,在组件更新时就会跳过这段内容的更新,从而提升更新性能。
小细节:v-once 什么时候真的用得上? 当那段内容语法上是动态的、但业务上不会变的时候——比如绑定了一个只在初始化时算一次的结果、一个需要花力气计算但结果长期不变的值。
反过来说,如果你确定某段内容永远不变,更推荐用常量(computed)或直接写死,那属于编译时就能识别的静态内容,不需要 v-once。
v-once 从两方面提升性能
实际上,v-once 指令能够从两个方面提升性能。
- 避免组件更新时重新创建虚拟 DOM 带来的性能开销。因为虚拟 DOM 被缓存了,所以更新时无须重新创建。
- 避免无用的 Diff 开销。这是因为被
v-once标记的虚拟 DOM 树不会被父级Block节点收集。
这两点正好对应前面反复出现的两类开销:
用一句话形容 v-once:“这一块渲染一次就再也不变了,缓存起来永不见”。
17.7 总结
本章中,我们主要讨论了 Vue.js 3 在编译优化方面所做的努力。编译优化指的是通过编译的手段提取关键信息,并以此指导生成最优代码的过程。具体来说,Vue.js 3 的编译器会充分分析模板,提取关键信息并将其附着到对应的虚拟节点上。在运行时阶段,渲染器通过这些关键信息执行“快捷路径”,从而提升性能。
编译优化的核心在于,区分动态节点与静态节点。Vue.js 3 会为动态节点打上补丁标志,即 patchFlag。同时,Vue.js 3 还提出了 Block 的概念,一个 Block 本质上也是一个虚拟节点,但与普通虚拟节点相比,会多出一个 dynamicChildren 数组。该数组用来收集所有动态子代节点,这利用了 createVNode 函数和 createBlock 函数的层层嵌套调用的特点,即以“由内向外”的方式执行。再配合一个用来临时存储动态节点的节点栈,即可完成动态子代节点的收集。
由于 Block 会收集所有动态子代节点,所以对动态节点的比对操作是忽略 DOM 层级结构的。这会带来额外的问题,即 v-if、v-for 等结构化指令会影响 DOM 层级结构,使之不稳定。这会间接导致基于 Block 树的比对算法失效。而解决方式很简单,只需要让带有 v-if、v-for 等指令的节点也作为 Block 角色即可。
除了 Block 树以及补丁标志之外,Vue.js 3 在编译优化方面还做了其他努力,具体如下。
- 静态提升:能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用。
- 预字符串化:在静态提升的基础上,对静态节点进行字符串化。这样做能够减少创建虚拟节点产生的性能开销以及内存占用。
- 缓存内联事件处理函数:避免造成不必要的组件更新。
v-once指令:缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟 DOM 带来的性能开销,也可以避免无用的 Diff 操作。
本章小结
- 传统 Diff 的问题:按层级遍历,运行时无法区分动静态,会做很多无用的比较。Vue 3 通过把编译时信息“附着”到虚拟 DOM 上解决这个问题。
- 补丁标志的取值速查:
1= 动态文本、2= 动态 class、3= 动态 style。渲染器看到patchFlag就能做“靶向更新”,不必比对无关的props。 - 收集动态节点的 3 个工具函数:
openBlock()(创建集合压栈)、closeBlock()(弹栈)、createBlock()(创建 Block 并挂上dynamicChildren);配合一个dynamicChildrenStack和currentDynamicChildren。 - 静态提升的判定细节:提升以“树”为单位,且纯静态的
props也能一并提升。 - Fragment:用来表达
v-for或多根模板的“片段节点”。它也可能不稳定,不稳定时要回退到传统 Diff(用children不用dynamicChildren)。 v-once的实现细节:用setBlockTracking(-1)暂停 Block 收集,并把虚拟节点存入cache。
用一张表把本章所有优化摆在一起,看它们各自“省掉”的是哪一种开销:
| 优化策略 | 作用在哪个环节 | 省掉的成本 |
|---|---|---|
patchFlag 靶向更新 | Diff 时 | 不比对无关的 props |
dynamicChildren / Block | Diff 时 | 不比对、不进入静态节点 |
Block 树(v-if/v-for 节点也当 Block) | 收集时 | 保证清单稳定,让上面两条用得起来 |
Fragment 内部回退传统 Diff | Diff 时 | ——(不省,换正确性) |
| 静态提升 | 创建时 | 不重复创建静态节点 |
| 预字符串化 | 创建时 + 渲染时 | 20 个节点变 1 个字符串,走 innerHTML |
| 缓存内联事件处理函数 | 触发更新时 | 子组件不因“函数引用变化”而误更新 |
v-once + setBlockTracking(-1) | 创建时 + Diff 时 | 既不重建,也不参与比对 |
结论:从这张表能看出一个规律——优化的战场只有两个:“创建虚拟 DOM 的时刻”和“Diff 比对的时刻”。v-once 是唯一同时打了两场的选手;Block 那一组则只管 Diff。
用一句话把本章的所有优化串起来:“区分动静 + Block 收集动态 + 渲染器靶向更新 + 静态/常量能提就提、能缓存就缓存”——所有策略都指向同一个目标:让运行时少干活。
如果只记一件事,就记住开头那栋楼的比喻:编译时当设计师,运行时当住户。设计师在图纸上标好“这几间房会变”(patchFlag、dynamicChildren),把不会变的东西提前备好(静态提升、预字符串化),还会给特殊房间挂对号牌(Block 树)以防认错门。住户回家时,就只跑清单上那几条路。
