知

模块二 · 响应式与编译

第 8 / 17 节17 分钟阅读型

模板编译与运行时:静态提升、补丁标记与 Block Tree

模板先编译再运行。这一节讲清 parse/transform/codegen 三阶段,以及 Vue 3 用补丁标记和 Block Tree 把 diff 范围缩小到什么程度。

读完这一节,你能做到

  1. 说出编译三阶段各自产出什么。
  2. 用静态提升与补丁标记解释"为什么更新更快"。
  3. 说清 Block Tree 如何收集动态节点。
  4. 判断什么时候该用模板、什么时候该手写 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 的模板编译工具就能看到每一行生成代码。编译产物是最好的文档,多读几遍。

十二、常见坑

  1. 以为虚拟 DOM 一定比直接操作 DOM 快(它的价值是最小化更新与可维护性)。
  2. 在模板里写复杂表达式,编译后无法被静态分析,退化为全量比较。
  3. 用 v-html 插入大段内容,绕过编译优化,还可能带来 XSS。
  4. 同时引入带编译器与不带编译器的构建,包体翻倍。
  5. 以为 key 只是给 v-for 用的(它决定节点复用策略)。

十三、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 用 Vue 的模板编译在线工具,分别编译一个静态节点和一个动态节点,对比产物。
  2. 把一个静态子树提到 v-once 或组件外,观察 render 函数变化。
  3. 阅读 runtime-core 的 patch 入口,找到补丁标记的分支。

② 让 AI 写同一题

把第 2 题(对照编译产物)交给 AI,补一句 用 SFC Playground 的编译输出逐条对照。

③ 对比要盯的点

  • 静态提升、补丁标记、Block Tree 三个概念它讲串了吗?还是各讲各的?
  • 它提 patchFlag 时数字含义对吗?(TEXT=1、CLASS=2、PROPS=8 是常用几个)
  • 它有没有把 v-if 与 v-show 的差异说成运行时差异?前者是编译期生成不同结构。

④ 一句话取舍

它讲的东西你能在编译产物里指出来吗?指不出就是背的。

十四、自测

  1. 编译三阶段分别产出什么?
  2. 静态提升解决了什么重复工作?
  3. 补丁标记如何减少 diff 成本?
  4. Block Tree 收集的是什么?
  5. 什么时候该手写 render?

参考答案要点:AST/转换/代码;避免重复创建;按类型跳过比较;动态后代节点;高度动态或精细控制。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解