模块二 · 响应式与编译
模板编译与运行时:静态提升、补丁标记与 Block Tree
模板先编译再运行。这一节讲清 parse/transform/codegen 三阶段,以及 Vue 3 用补丁标记和 Block Tree 把 diff 范围缩小到什么程度。
读完这一节,你能做到
- 说出编译三阶段各自产出什么。
- 用静态提升与补丁标记解释"为什么更新更快"。
- 说清 Block Tree 如何收集动态节点。
- 判断什么时候该用模板、什么时候该手写 render。
一、编译三阶段
template → parse(AST)→ transform(优化/转换)→ codegen(render 函数字符串)
| 阶段 | 做什么 | 关键动作 |
|---|---|---|
| parse | 把模板解析成 AST | 识别元素、指令、插值 |
| transform | 遍历 AST 并打标记 | 静态提升、补丁标记、指令转换 |
| codegen | 生成可执行代码 | 拼接 render 函数字符串 |
编译可以在构建期完成(SFC),也可以在运行时完成(vue/dist/vue.esm-bundler.js 带编译器)。生产构建通常预编译,避免把编译器打进包体。
二、静态提升
不变的节点不需要每次重建:
// 编译前
<div>
<p>静态标题</p>
<span>{{ msg }}</span>
</div>;
// 编译后(简化)
const hoisted = createElementVNode("p", null, "静态标题");
function render(ctx) {
return createElementVNode("div", null, [
hoisted,
createElementVNode("span", null, ctx.msg),
]);
}
静态节点被提到 render 函数外,只创建一次;静态属性也会被提升,避免重复 diff。
三、补丁标记
动态节点会被打上标记,告诉 diff"只比较可能变化的类型":
| 标记 | 含义 |
|---|---|
| TEXT | 只有文本会变 |
| CLASS | 只有 class 会变 |
| PROPS | 属性会变 |
| FULL_PROPS | 需要完整比较属性 |
| NEED_PATCH | 需要特殊处理(ref、指令) |
有了标记,diff 不需要逐字段比较,直接按类型处理,减少了大量判断。
四、Block Tree
一个 Block 会收集其内部所有"动态后代节点",形成一个扁平数组:
Block(根)
├─ 动态节点 A
├─ 动态节点 B
└─ 动态节点 C
更新时只遍历这个数组,而不是整棵树。v-if/v-for 会创建新的 Block,因为结构可能变化,需要单独追踪。
这就是 Vue 3 在保持虚拟 DOM 的同时,把 diff 范围缩小的核心机制。
五、运行时与渲染器
runtime-core:与平台无关的渲染器、组件、调度。runtime-dom:浏览器平台实现(DOM 操作、属性 patch、事件)。reactivity:独立的响应式包,可脱离 Vue 使用。
自定义渲染器(如小程序、Canvas)就是替换 runtime-dom 的节点操作,复用 runtime-core 的组件与调度。
六、模板 vs 手写 render
| 维度 | 模板 | 手写 render |
|---|---|---|
| 编译期优化 | 有(静态提升、补丁标记) | 无 |
| 可读性 | 高 | 低,尤其是嵌套深时 |
| 灵活性 | 受语法限制 | 完全可控 |
| 适用 | 绝大多数业务组件 | 高度动态、需要精细控制 |
结论:能用模板就用模板,手写 render 只在需要动态生成结构或性能极致时使用。
七、v-if/v-for 在编译期的展开
v-if 会被编译成三元表达式或 createIfBranch 调用,v-for 会被编译成 renderList。两者都意味着“结构可能变化”,因此会创建新的 Block,内部的动态节点需要重新收集。
v-if的分支切换会卸载/挂载节点,触发组件的 beforeUnmount/Unmounted。v-for的 key 决定节点复用,缺失 key 时会按位置复用,状态可能错位。v-for上不要再挂v-if,因为编译后if在外层,拿不到循环变量。
八、编译器的可扩展点
Vue 的编译器基于 AST 转换管线,允许在 transform 阶段插入自定义节点转换。实际工作中更常见的扩展方式是:
- 自定义指令:在 transform 阶段转换成运行时代码。
compilerOptions.isCustomElement:告诉编译器哪些标签不需要当成组件解析。v-pre:跳过编译,适合展示原始模板。
九、一段编译产物逐行读
function render(_ctx) {
return (
_openBlock(),
_createElementBlock("div", null, [
_createElementVNode("p", null, "静态标题"),
_createElementVNode(
"span",
null,
_toDisplayString(_ctx.msg),
1 /* TEXT */,
),
])
);
}
_openBlock开启一个 Block,收集动态后代。1 /* TEXT */是补丁标记,表示只有文本会变。- 静态节点被提升到 render 外,不会重复创建。
能读懂编译产物,就能判断一段模板为什么“没有走优化”。
再看一个反例:模板里写 items.filter(i => i.active).map(...),每次渲染都会创建新数组,传给子组件的 props 引用变化,子组件就会重渲染;把它移到 computed,引用稳定,子组件才能命中缓存。
十、编译期优化的边界
静态提升与补丁标记能减少 diff,但不能减少“响应式触发”本身:依赖变化时组件依然要重新执行 render。因此性能优化不能只靠编译器,还要控制响应式粒度与组件拆分。
另外,编译期优化依赖模板是静态可分析的。大量 v-html、动态组件与运行时拼接的模板,会让编译器拿不到结构信息,退化为全量比较。
十一、SSR 编译的差异
同一份模板在 SSR 下会生成两套代码:一套用于服务端字符串拼接,一套用于客户端 hydrate。SSR 产物更关注“快速输出字符串”,客户端产物更关注“复用 DOM”。
这也是为什么同一段模板在 SSR 与 CSR 下性能特征不同:服务端看字符串拼接速度,客户端看 hydrate 与后续更新成本。编译器的产物不是黑盒,打开 Vue 的模板编译工具就能看到每一行生成代码。编译产物是最好的文档,多读几遍。
十二、常见坑
- 以为虚拟 DOM 一定比直接操作 DOM 快(它的价值是最小化更新与可维护性)。
- 在模板里写复杂表达式,编译后无法被静态分析,退化为全量比较。
- 用
v-html插入大段内容,绕过编译优化,还可能带来 XSS。 - 同时引入带编译器与不带编译器的构建,包体翻倍。
- 以为
key只是给v-for用的(它决定节点复用策略)。
十三、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用 Vue 的模板编译在线工具,分别编译一个静态节点和一个动态节点,对比产物。
- 把一个静态子树提到
v-once或组件外,观察 render 函数变化。 - 阅读
runtime-core的patch入口,找到补丁标记的分支。
② 让 AI 写同一题
把第 2 题(对照编译产物)交给 AI,补一句 用 SFC Playground 的编译输出逐条对照。
③ 对比要盯的点
- 静态提升、补丁标记、Block Tree 三个概念它讲串了吗?还是各讲各的?
- 它提 patchFlag 时数字含义对吗?(TEXT=1、CLASS=2、PROPS=8 是常用几个)
- 它有没有把
v-if与v-show的差异说成运行时差异?前者是编译期生成不同结构。
④ 一句话取舍
它讲的东西你能在编译产物里指出来吗?指不出就是背的。
十四、自测
- 编译三阶段分别产出什么?
- 静态提升解决了什么重复工作?
- 补丁标记如何减少 diff 成本?
- Block Tree 收集的是什么?
- 什么时候该手写 render?
参考答案要点:AST/转换/代码;避免重复创建;按类型跳过比较;动态后代节点;高度动态或精细控制。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解