Appearance
第 7 章 Vue Compiler 架构总览
本章要点
- 编译器在 Vue 运行时体系中的定位:为什么"模板→渲染函数"是性能的关键战场
- 三阶段流水线:Parse → Transform → Codegen 的职责边界与数据流
- AST 节点类型体系:从 RootNode 到 SimpleExpressionNode 的完整族谱
- PatchFlags:编译期的"体检报告",运行时 Diff 的加速密钥
- Block Tree:打破虚拟 DOM 逐层比对的结构性飞跃
- 静态提升(Static Hoisting):让不变的节点只创建一次
- 编译器与响应式系统、渲染器的三角协作模型
2016 年,Evan You 在 Vue 2 中做了一个大胆的决定:模板编译不是可选的预处理步骤,而是框架的一等公民。彼时 React 阵营正在推崇 JSX 的"JavaScript 即模板"哲学,Angular 则将模板编译深藏在 CLI 工具链的黑盒中。Vue 选择了第三条路——模板和 JSX 都支持,但模板是默认的、推荐的、也是可以被深度优化的。
这个决定的价值在 Vue 3 中彻底兑现。当 React 还在为"是否需要编译器"争论(直到 React Compiler/React Forget 才姗姗来迟),Vue 3 的编译器已经默默做了三件事:
- PatchFlags —— 在编译期标记每个动态节点的变化类型,让运行时 Diff 只比较真正会变的部分
- Block Tree —— 将动态节点"拍平"到一个数组中,跳过静态子树的逐层遍历
- 静态提升 —— 将永远不会变化的 VNode 提升到渲染函数之外,避免每次渲染重复创建
这三项优化的共同特点是:它们只能在编译期完成。没有编译器,运行时就是"盲人摸象"——它不知道哪些节点是静态的,哪些属性会变,哪些子树可以跳过。有了编译器,运行时变成了"精确制导"——每一次比对、每一次更新都直奔目标。
本章将从宏观视角审视 Vue Compiler 的完整架构。我们不会深入每一行源码(那是第 8 章的任务),而是要建立一个清晰的心智模型:编译器由哪些阶段组成?每个阶段的输入和输出是什么?PatchFlags、Block Tree、静态提升分别在哪个阶段被计算?它们如何协同工作,让 Vue 的渲染性能远超纯运行时方案?
7.1 编译器在 Vue 架构中的位置
从模板到像素:完整渲染链路
一个 .vue 文件从编写到最终渲染在屏幕上,经历了这样一条链路:
编译器的职责很明确:将模板字符串转换为渲染函数。这个渲染函数在每次组件更新时被调用,返回新的 VNode 树,然后由渲染器(Renderer)对比新旧 VNode 树,将差异应用到真实 DOM。
编译时机:AOT vs JIT
Vue 编译器有两种运行时机:
| 维度 | AOT(预编译) | JIT(运行时编译) |
|---|---|---|
| 时机 | 构建阶段(Vite/Webpack) | 浏览器运行时 |
| 入口 | @vue/compiler-sfc | @vue/compiler-dom 的 compile() |
| 产物 | 预编译的 .js 文件 | 内存中的渲染函数 |
| 体积 | 不需要运行时编译器(~14KB 更小) | 需要完整构建版本 |
| 优化 | 可以执行所有静态分析优化 | 同样支持全部优化 |
| 使用场景 | 生产环境(推荐) | 动态模板、CDN 引入、在线编辑器 |
🔥 深度洞察
很多开发者认为"运行时编译 = 没有优化",这是一个误解。Vue 3 的运行时编译器和预编译器共享同一套优化流水线——PatchFlags、Block Tree、静态提升在两种模式下都会被应用。区别仅在于编译发生的时间点和产物形态。不过,AOT 编译允许 SFC 专属的优化(如
<script setup>的变量分析、CSS 变量注入),这些是运行时编译器无法做到的。
编译器的包结构
Vue 3 的编译器代码分布在三个包中:
packages/
├── compiler-core/ # 平台无关的编译核心
│ ├── parse.ts # 模板解析器 → AST
│ ├── transform.ts # AST 转换引擎
│ ├── codegen.ts # 代码生成器
│ └── transforms/ # 内置转换插件
├── compiler-dom/ # DOM 平台专属编译
│ ├── index.ts # compile() 入口
│ └── transforms/ # DOM 专属转换(v-html, v-model 等)
└── compiler-sfc/ # 单文件组件编译
├── parse.ts # SFC 解析(<template>/<script>/<style>)
├── compileTemplate.ts
├── compileScript.ts
└── compileStyle.ts这种分层设计体现了 Vue 3 的"平台抽象"哲学:compiler-core 不知道 DOM 的存在,它只处理纯粹的模板语法到 AST 到代码字符串的转换。DOM 特定的规则(哪些是原生元素、哪些属性需要特殊处理)由 compiler-dom 以插件形式注入。这意味着同一个编译核心可以被复用来编译 SSR 代码、原生渲染代码,甚至未来的 Vapor Mode 代码。
7.2 三阶段流水线:Parse → Transform → Codegen
全景数据流
Vue 编译器的核心是一个经典的三阶段流水线:
每个阶段有明确的输入和输出:
| 阶段 | 输入 | 输出 | 核心职责 |
|---|---|---|---|
| Parse | 模板字符串 <div>{{msg}}</div> | 模板 AST(树状节点结构) | 词法分析 + 语法分析,识别元素、属性、指令、插值 |
| Transform | 模板 AST | 带有 codegenNode 的增强 AST | 语义分析、优化标记(PatchFlags)、静态提升、Block 收集 |
| Codegen | 增强后的 AST | JavaScript 代码字符串 | 遍历 AST 生成 render() 函数的源码 |
阶段一:Parse —— 从字符串到树
解析器的任务是将模板字符串转换为抽象语法树(AST)。Vue 的模板解析器是一个手写的递归下降解析器,不依赖任何第三方库。
一个简单的模板:
html
<div class="container">
<p>{{ message }}</p>
<span :title="tooltip">静态文本</span>
</div>被解析为这样的 AST:
typescript
// 简化的 AST 结构
const ast: RootNode = {
type: NodeTypes.ROOT,
children: [
{
type: NodeTypes.ELEMENT,
tag: 'div',
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'class',
value: { content: 'container' }
}
],
children: [
{
type: NodeTypes.ELEMENT,
tag: 'p',
children: [
{
type: NodeTypes.INTERPOLATION, // 插值
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message',
isStatic: false
}
}
]
},
{
type: NodeTypes.ELEMENT,
tag: 'span',
props: [
{
type: NodeTypes.DIRECTIVE, // v-bind
name: 'bind',
arg: { content: 'title', isStatic: true },
exp: { content: 'tooltip', isStatic: false }
}
],
children: [
{
type: NodeTypes.TEXT,
content: '静态文本'
}
]
}
]
}
]
}Parse 阶段的关键特征:
- 无优化:Parse 阶段不做任何优化判断,它只忠实地将模板结构转换为树形数据。"这个节点是否是静态的"不是 Parse 的职责。
- 容错处理:解析器能处理不规范的 HTML(如未闭合标签),并给出有意义的错误提示。
- 位置信息:每个 AST 节点都携带
loc(location)信息,精确到行号和列号,用于后续的 source map 生成和错误报告。
阶段二:Transform —— 从"是什么"到"怎么做"
Transform 是编译器中最复杂、最核心的阶段。如果说 Parse 回答的是"模板的结构是什么",那么 Transform 回答的是"这个结构应该如何被渲染"。
Transform 阶段的工作包括:
- 指令处理:将
v-if、v-for、v-on、v-bind、v-model等指令转换为对应的运行时代码结构 - 静态分析:判断哪些节点是完全静态的、哪些属性会动态变化
- PatchFlags 标记:为每个动态节点计算精确的变化标记
- Block 收集:将动态节点收集到 Block 的
dynamicChildren数组 - 静态提升标记:标记可以被提升到渲染函数外部的静态节点
- codegenNode 生成:为每个节点创建
codegenNode,这是 Codegen 阶段的直接输入
Transform 采用插件架构。核心引擎提供遍历和上下文管理,具体的转换逻辑由一组"转换函数"实现:
typescript
// compiler-core/src/transform.ts(简化)
function transform(root: RootNode, options: TransformOptions) {
const context = createTransformContext(root, options)
traverseNode(root, context)
// 静态提升(在遍历完成后执行)
if (options.hoistStatic) {
hoistStatic(root, context)
}
// 创建根节点的 codegenNode
createRootCodegen(root, context)
}
// 遍历引擎
function traverseNode(node: TemplateNode, context: TransformContext) {
context.currentNode = node
// 执行所有转换插件(进入阶段)
const { nodeTransforms } = context
const exitFns: Function[] = []
for (let i = 0; i < nodeTransforms.length; i++) {
const onExit = nodeTransforms[i](node, context)
if (onExit) exitFns.push(onExit) // 收集退出回调
}
// 递归遍历子节点
traverseChildren(node, context)
// 执行退出回调(逆序 —— 后进先出)
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}🔥 深度洞察
Transform 的插件执行采用了"洋葱模型":每个插件在进入节点时可以返回一个"退出回调"。子节点的转换在进入和退出之间执行。这意味着退出回调执行时,所有子节点都已经被转换完毕。这个设计至关重要——像
v-if这样的结构性指令需要在子节点转换完成后才能生成最终的 codegenNode,因为它需要知道子节点的动态特性。
核心转换插件列表:
| 插件 | 文件 | 职责 |
|---|---|---|
transformElement | transformElement.ts | 处理普通元素,生成 createVNode 调用 |
transformText | transformText.ts | 合并相邻文本和插值节点 |
transformSlotOutlet | transformSlotOutlet.ts | 处理 <slot> 出口 |
transformOnce | vOnce.ts | 处理 v-once,标记为缓存节点 |
transformIf | vIf.ts | 处理 v-if/v-else-if/v-else,生成条件分支 |
transformFor | vFor.ts | 处理 v-for,生成列表渲染 |
transformBind | vBind.ts | 处理 v-bind(: 缩写) |
transformOn | vOn.ts | 处理 v-on(@ 缩写) |
transformModel | vModel.ts | 处理 v-model,生成双向绑定代码 |
阶段三:Codegen —— 从树到字符串
代码生成器遍历 Transform 阶段产出的增强 AST(具体来说是每个节点的 codegenNode),将其转换为 JavaScript 代码字符串。
对于前面的模板示例,Codegen 产出的渲染函数大致如下:
javascript
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
// 静态提升的节点
const _hoisted_1 = { class: "container" }
const _hoisted_2 = /*#__PURE__*/ _createElementVNode("p", null, null, -1 /* HOISTED */)
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("span", {
title: _ctx.tooltip
}, "静态文本", 8 /* PROPS */, ["title"])
]))
}