编译器原理与源码解析
上一章我们解剖了响应式系统和虚拟 DOM——它们解决的是”数据变了怎么更新”和”更新时怎么操作 DOM”的问题。但还有一个关键环节被我们跳过了:你在 <template> 中写的那些 HTML 式的模板,是如何变成可执行的渲染函数的?答案就藏在 Vue 3 的编译器中。
编译器是 Vue 能够在编译期进行大量性能优化的秘密武器。React 更侧重运行时优化(如 Fiber 架构、memo 等),而 Vue 通过编译器在构建时就已经提前”算好”了许多优化。理解编译器,不仅能让你真正吃透 Vue 的设计哲学,还能帮你在遇到模板报错、性能瓶颈时快速定位问题。
本章是精通篇的最后一章,也是全课程正文的收官之作。让我们深入编译器的内核。
📋 开篇自测
在开始之前,检验一下你的前置知识:
- AST(抽象语法树)是什么?它和 DOM 树有什么关系?
- Vue 3 的渲染函数(
render)返回的是什么?它与模板之间是什么关系? - 什么是”静态提升”?你能说出它优化了什么吗?
如果这三个问题你都有清晰的答案,本章的学习会非常顺畅。如果有些模糊,不用担心——我们会从零开始,一步步把编译器的全貌拼出来。
一、编译器全景——从模板到渲染函数
1.1 编译器解决什么问题
当你在 .vue 文件中写下这样的模板:
<template>
<div class="greeting">
<p>hello world</p>
<p>{{ msg }}</p>
</div>
</template>
Vue 需要把它变成一个可执行的 JavaScript 渲染函数:
import { createElementVNode, toDisplayString, openBlock, createElementBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/createElementVNode("p", null, "hello world", -1)
export function render(_ctx, _cache) {
return (openBlock(), createElementBlock("div", { class: "greeting" }, [
_hoisted_1,
createElementVNode("p", null, toDisplayString(_ctx.msg), 1 /* TEXT */)
]))
}
这个从”模板字符串”到”渲染函数代码”的转换过程,就是编译器的职责。
1.2 三阶段流水线
Vue 3 的编译器采用经典的三阶段设计,和大多数编程语言的编译器思路一致:
Vue 3 Compiler Pipeline
Template String AST JS AST Render Function
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────┐
│ <div> │ │ Root │ │ Root │ │ function │
│ <p>text │ ──> │ Element │ ──> │ VNodeCall │ ──> │ render() { │
│ <p>{{m}} │ │ Text │ │ ... │ │ return ... │
│ </div> │ │ Interp │ │ flags │ │ } │
└───────────┘ └───────────┘ └───────────┘ └───────────────┘
│ │ │
Parse Transform Generate
(词法+语法分析) (语义分析+优化) (代码生成)
用一段简化的伪代码来表达这个流程:
function compile(template) {
// 第一步:解析——把模板字符串转换为 AST
const ast = parse(template)
// 第二步:转换——遍历 AST,添加语义信息和优化标记
transform(ast)
// 第三步:生成——把带有语义信息的 AST 转换为渲染函数代码字符串
return generate(ast)
}
每一步的输入是上一步的输出,形成一条清晰的流水线。接下来我们逐一深入每个阶段。
二、模板解析(Parse)——从字符串到 AST
2.1 解析器的核心思想
解析阶段要做的事情,本质上和浏览器解析 HTML 是类似的:逐字符扫描模板字符串,识别出标签、属性、文本、插值表达式等不同的语法成分,然后组装成一棵树形的数据结构——AST(抽象语法树)。
AST 中的节点类型主要有以下几种:
┌──────────────────────────────────────────┐
│ AST Node Types │
├──────────────────────────────────────────┤
│ ROOT (0) ── 根节点 │
│ ELEMENT (1) ── 元素节点 <div> │
│ TEXT (2) ── 纯文本 "hello" │
│ COMMENT (3) ── 注释 <!-- --> │
│ SIMPLE_EXPRESSION (4) ── 表达式 msg │
│ INTERPOLATION (5)── 插值 {{ msg }} │
└──────────────────────────────────────────┘
2.2 有限状态机与逐步消费
解析器的工作方式可以理解为一个有限状态机(Finite State Machine):维护一个游标(cursor),从模板字符串的头部开始,每次根据当前字符判断进入哪个解析分支,解析完一段后将游标前移,“消费”掉已处理的部分,直到整个字符串被消费完毕。
我们来实现一个简化版的解析器,帮你理解这个过程:
// 简化版解析上下文——追踪解析进度
function createParseContext(template) {
return {
source: template, // 尚未解析的剩余字符串
offset: 0, // 当前偏移量
line: 1, // 当前行号
column: 1 // 当前列号
}
}
// 核心操作:向前消费 n 个字符
function advanceBy(context, n) {
context.source = context.source.slice(n)
context.offset += n
}
advanceBy 是解析器最基础的操作。每解析完一个语法片段,就调用它把已解析部分从 source 中移除。举个例子:
初始: source = '<div>{{ msg }}</div>'
^
消费 '<div>':
source = '{{ msg }}</div>'
^
消费 '{{ msg }}':
source = '</div>'
^
消费 '</div>':
source = '' (解析完毕)
2.3 parseChildren——递归下降的解析引擎
解析器的核心是 parseChildren 函数。它在一个 while 循环中不断检查剩余字符串的开头,根据特征字符分派到不同的子解析器:
function parseChildren(context, ancestors) {
const nodes = []
while (!isEnd(context, ancestors)) {
const s = context.source
let node
if (s.startsWith('{{')) {
// 情况 1: 遇到 {{ 开头,解析插值表达式
node = parseInterpolation(context)
} else if (s[0] === '<') {
if (s[1] === '!') {
if (s.startsWith('<!--')) {
// 情况 2: 注释节点
node = parseComment(context)
}
} else if (s[1] === '/') {
// 情况 3: 遇到闭合标签——不创建节点,交给上层处理
break
} else if (/[a-z]/i.test(s[1])) {
// 情况 4: 遇到开始标签,解析元素
node = parseElement(context, ancestors)
}
}
// 情况 5: 以上都不匹配,当作文本节点处理
if (!node) {
node = parseText(context)
}
nodes.push(node)
}
return nodes
}
这段代码的判断逻辑用一张决策树来表示:
当前字符 source[0]
│
├── 以 '{{' 开头 ──────────> parseInterpolation() → 插值节点
│
├── 以 '<' 开头
│ ├── '<!' 开头
│ │ └── '<!--' ──────> parseComment() → 注释节点
│ ├── '</' 开头 ────────> 结束标签,跳出循环
│ └── '<[a-z]' 开头 ───> parseElement() → 元素节点
│
└── 其他 ──────────────────> parseText() → 文本节点
2.4 解析元素——开始标签、子节点、闭合标签
元素的解析分为三步,对应 HTML 标签的三个部分:
function parseElement(context, ancestors) {
// 第一步:解析开始标签 <div class="test">
const element = parseTag(context)
// 如果是自闭合标签 <br/>,直接返回
if (element.isSelfClosing) return element
// 第二步:把当前元素压入祖先栈,递归解析子节点
ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()
// 第三步:解析闭合标签 </div>,消费掉它
if (context.source.startsWith(`</${element.tag}>`)) {
parseTag(context) // 消费闭合标签
}
return element
}
这里的 ancestors 栈是关键设计。它记录了从根节点到当前节点的完整路径,有两个作用:
- 判断结束条件:当遇到
</tag>时,通过栈顶元素判断是否匹配当前元素的闭合标签。 - 建立父子关系:栈顶元素就是当前正在解析节点的父元素。
用一个例子演示栈的变化过程:
<div class="app">
<p>{{ msg }}</p>
一段文本
</div>
解析过程中 ancestors 栈的变化:
[] ← 初始状态
[div] ← 遇到 <div>,div 入栈
[div, p] ← 遇到 <p>,p 入栈
[div] ← 遇到 </p>,p 出栈
[] ← 遇到 </div>,div 出栈
2.5 解析插值表达式
插值表达式 {{ msg }} 的解析比较直观:找到 {{ 和 }} 的位置,提取中间的内容:
function parseInterpolation(context) {
const openDelimiter = '{{'
const closeDelimiter = '}}'
// 找到 }} 的位置
const closeIndex = context.source.indexOf(closeDelimiter, openDelimiter.length)
// 消费掉 {{
advanceBy(context, openDelimiter.length)
// 提取中间的内容并去除空格
const rawContentLength = closeIndex - openDelimiter.length
const rawContent = context.source.slice(0, rawContentLength)
const content = rawContent.trim()
// 消费掉内容 + }}
advanceBy(context, rawContentLength + closeDelimiter.length)
return {
type: 5, // INTERPOLATION
content: {
type: 4, // SIMPLE_EXPRESSION
content, // 'msg'
isStatic: false
}
}
}
2.6 解析结果:一棵完整的 AST
当整个模板解析完毕后,我们得到一棵树形结构。以 <p>{{ msg }}</p> 为例:
AST Root (type: 0)
└── Element (type: 1, tag: "p")
└── Interpolation (type: 5)
└── Expression (type: 4, content: "msg", isStatic: false)
每个节点都携带了类型信息、位置信息(行号、列号、偏移量),以及与该节点相关的属性和子节点。这棵树就是下一个阶段——Transform 的输入。
2.7 解析文本节点
当字符串的起始位置既不是 < 也不是 {{ 时,解析器将其视为纯文本。文本节点的解析策略是:向前扫描,直到遇到 < 或 {{,中间的内容就是一个完整的文本节点。
function parseText(context) {
// 文本的结束标志:遇到 '<' 或 '{{'
const endTokens = ['<', '{{']
let endIndex = context.source.length
for (const token of endTokens) {
const index = context.source.indexOf(token)
if (index !== -1 && index < endIndex) {
endIndex = index
}
}
// 提取文本内容
const content = context.source.slice(0, endIndex)
advanceBy(context, endIndex)
return {
type: 2, // TEXT
content
}
}
举个例子,对于这段模板:
hello {{ name }} world
解析过程如下:
第 1 轮: source = 'hello {{ name }} world'
遇到 '{{' 之前的 'hello ' 是文本 → parseText → 消费 'hello '
第 2 轮: source = '{{ name }} world'
以 '{{' 开头 → parseInterpolation → 消费 '{{ name }}'
第 3 轮: source = ' world'
不以 '<' 或 '{{' 开头 → parseText → 消费 ' world'
最终生成三个平级的子节点:TEXT("hello ")、INTERPOLATION("name")、TEXT(" world")。
2.8 解析注释节点
注释节点 <!-- content --> 的解析同样遵循”找到边界,提取内容”的模式:
function parseComment(context) {
// 消费掉 <!--
advanceBy(context, 4)
// 找到 --> 的位置
const closeIndex = context.source.indexOf('-->')
const content = context.source.slice(0, closeIndex)
// 消费掉注释内容 + -->
advanceBy(context, content.length + 3)
return {
type: 3, // COMMENT
content
}
}
Vue 3 在生产环境默认会移除注释节点,但在开发环境保留它们以便调试。这个决策也是在编译阶段通过配置项控制的。
2.9 小结:解析阶段的设计模式
回顾整个 Parse 阶段,它遵循了编译器设计中经典的递归下降解析(Recursive Descent Parsing)模式:
- 自顶向下:从根节点开始,遇到子结构就递归深入。
- 逐步消费:每解析一段就通过
advanceBy移除已处理部分,保证source始终指向下一个待解析片段。 - 栈辅助:用
ancestors栈维护嵌套层级,确保正确的父子关系和闭合标签匹配。 - 错误友好:每个解析分支都会对异常情况(如未闭合标签、缺少结束分隔符)做出明确的错误报告。
三、AST 转换(Transform)——语义分析与优化标记
3.1 为什么需要转换
Parse 阶段生成的 AST 忠实地描述了模板的语法结构,但它缺少两类关键信息:
- JS 语义:模板中的
<div>最终要变成createElementVNode("div", ...)调用,AST 需要记录”该调用哪个运行时函数、传什么参数”。 - 优化标记:哪些节点是静态的、哪些是动态的、动态部分具体是文本变化还是 class 变化——这些信息将直接影响运行时的更新效率。
Transform 阶段就是给 AST 节点”注入”这些信息。
3.2 遍历与插件架构
Transform 的核心是一个深度优先遍历 + 插件式转换的设计:
function transform(root) {
// 创建转换上下文
const context = {
currentNode: null,
helpers: new Set(), // 记录需要导入的运行时辅助函数
hoists: [], // 记录可以被静态提升的节点
// 转换插件列表——每个插件负责一种特定的转换
nodeTransforms: [
transformElement, // 处理元素节点
transformText, // 处理文本节点
transformIf, // 处理 v-if
transformFor, // 处理 v-for
transformExpression // 处理表达式
]
}
// 从根节点开始遍历
traverseNode(root, context)
}
遍历函数 traverseNode 的设计有一个巧妙之处——退出函数模式:
function traverseNode(node, context) {
context.currentNode = node
const exitFns = []
// 正序执行所有转换插件
for (const transform of context.nodeTransforms) {
// 插件可以返回一个"退出函数"
const onExit = transform(node, context)
if (onExit) exitFns.push(onExit)
}
// 递归处理子节点
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
traverseNode(node.children[i], context)
}
}
// 倒序执行退出函数(子节点已全部处理完毕)
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
为什么要用退出函数?因为很多转换需要在子节点全部处理完之后才能执行。例如 transformElement 需要知道子节点的完整信息,才能确定当前元素的 patchFlag 和 dynamicChildren。
遍历过程示意(以 <div><p>{{ msg }}</p></div> 为例):
traverseNode(div)
├── 执行 transforms → 收集退出函数
├── traverseNode(p)
│ ├── 执行 transforms → 收集退出函数
│ ├── traverseNode(interpolation)
│ │ ├── 执行 transforms
│ │ └── 执行退出函数
│ └── 执行 p 的退出函数 ← 此时 p 的所有子节点已处理完毕
└── 执行 div 的退出函数 ← 此时 div 的所有子节点已处理完毕
3.3 transformElement——生成 codegenNode
转换阶段最核心的产物是为每个节点生成 codegenNode——一个描述”该节点在代码生成阶段应该生成什么样的 JS 代码”的对象:
const transformElement = (node, context) => {
// 返回退出函数,在子节点处理完之后执行
return () => {
if (node.type !== 1) return // 只处理元素节点
// 为元素节点创建 VNodeCall 描述对象
node.codegenNode = {
type: 13, // VNODE_CALL
tag: `"${node.tag}"`,
props: node.props,
children: node.children,
patchFlag: analyzePatchFlag(node), // 动态标记
isBlock: false
}
// 标记需要导入的辅助函数
context.helpers.add('createElementVNode')
}
}
转换前后的对比:
转换前 (Parse 的产物): 转换后 (Transform 的产物):
{ {
type: 1, type: 1,
tag: "p", tag: "p",
children: [ children: [...],
{ type: 5, content: "msg" } codegenNode: { ← 新增!
] type: 13,
} tag: '"p"',
children: ...,
patchFlag: 1, ← 标记为动态文本
}
}
3.4 PatchFlags——精准的动态标记
PatchFlags 是 Vue 3 编译时优化的核心武器。它用二进制位来标记节点中”哪些部分是动态的”:
const PatchFlags = {
TEXT: 1, // 0000000001 动态文本
CLASS: 1 << 1, // 0000000010 动态 class
STYLE: 1 << 2, // 0000000100 动态 style
PROPS: 1 << 3, // 0000001000 动态 props(非 class/style)
FULL_PROPS: 1 << 4, // 0000010000 有动态 key 的 props
HYDRATE_EVENTS: 1 << 5, // 0000100000 SSR hydration 时需要添加事件监听
STABLE_FRAGMENT: 1 << 6, // 0001000000 子节点顺序不变的 fragment
KEYED_FRAGMENT: 1 << 7, // 0010000000 有 key 的 fragment
UNKEYED_FRAGMENT:1 << 8, // 0100000000 无 key 的 fragment
NEED_PATCH: 1 << 9, // 1000000000 需要 patch(ref、指令等)
HOISTED: -1, // 静态提升节点
BAIL: -2 // 退出优化模式
}
使用二进制位的好处是可以用位运算高效地组合和检测多个标记:
// 同时标记文本和样式是动态的
const flag = PatchFlags.TEXT | PatchFlags.STYLE // 0000000101
// 检测是否有动态样式
if (flag & PatchFlags.STYLE) {
// 只更新 style,跳过其他 props
}
// 检测是否有动态文本
if (flag & PatchFlags.TEXT) {
// 只更新文本内容
}
举一个实际例子:
<div :class="cls" id="static">hello</div>
编译器分析后发现:id 是静态的,class 是动态绑定的,文本是静态的。因此 patchFlag = 2(即 CLASS)。运行时在 diff 这个节点时,只需要比较 class 属性,完全跳过 id 和文本内容的比较。
再看一个稍复杂的例子:
<p :class="cls" :style="stl">{{ msg }}</p>
这里 class、style、文本内容都是动态的,编译器会设置:
patchFlag = PatchFlags.TEXT | PatchFlags.CLASS | PatchFlags.STYLE
// 即: 1 | 2 | 4 = 7 (二进制 0000000111)
运行时只需要对这三项做 diff,其余属性一概跳过。
3.5 helpers 收集机制
在转换过程中,编译器还需要知道最终生成的代码需要从 Vue 运行时导入哪些辅助函数。这通过 context.helpers 这个集合来追踪:
// Transform 过程中的 helper 收集
function traverseNode(node, context) {
// ...
switch (node.type) {
case 3: // COMMENT
context.helpers.add('createCommentVNode')
break
case 5: // INTERPOLATION
context.helpers.add('toDisplayString')
break
}
// ...
}
转换结束后,context.helpers 中收集到的所有辅助函数名会被挂载到 AST 根节点上,供代码生成阶段使用。比如一个包含插值和注释的模板,最终 helpers 可能是:
['createElementVNode', 'toDisplayString', 'openBlock', 'createElementBlock']
代码生成阶段就会据此生成相应的 import 语句。
3.6 创建根代码生成节点
Transform 的最后一步是调用 createRootCodegen,为 AST 根节点创建 codegenNode。这一步需要处理一个 Vue 3 特有的情况——模板可以有多个根节点:
<!-- Vue 3 支持多根节点 -->
<template>
<header>...</header>
<main>...</main>
<footer>...</footer>
</template>
当只有一个根节点时,直接将该节点的 codegenNode 作为根的 codegenNode,并标记为 Block。当有多个根节点时,编译器会创建一个 Fragment(片段)节点来包裹它们:
function createRootCodegen(root, context) {
const { children } = root
if (children.length === 1) {
// 单根节点:直接使用子节点的 codegenNode
const child = children[0]
if (child.codegenNode) {
child.codegenNode.isBlock = true // 标记为 Block
root.codegenNode = child.codegenNode
}
} else if (children.length > 1) {
// 多根节点:创建一个 Fragment 来包裹
root.codegenNode = {
type: 13, // VNODE_CALL
tag: 'Fragment',
children: root.children,
patchFlag: 64, // STABLE_FRAGMENT
isBlock: true
}
context.helpers.add('Fragment')
}
}
至此,Transform 阶段完成。我们得到了一棵包含完整语义信息和优化标记的增强 AST,可以交给下一阶段——代码生成。
四、代码生成(Generate)——从 AST 到渲染函数字符串
4.1 生成器的工作模型
代码生成阶段的任务很直接:遍历 Transform 后的 AST,根据每个节点的 codegenNode,拼接出 JavaScript 代码字符串。
生成器维护一个上下文对象,其中最关键的工具是 push 函数——它负责将代码片段逐段拼接:
function createCodegenContext() {
const context = {
code: '', // 最终生成的代码字符串
indentLevel: 0, // 当前缩进层级
push(code) {
context.code += code
},
indent() {
context.indentLevel++
context.push('\n' + ' '.repeat(context.indentLevel))
},
deindent() {
context.indentLevel--
context.push('\n' + ' '.repeat(context.indentLevel))
},
newline() {
context.push('\n' + ' '.repeat(context.indentLevel))
}
}
return context
}
4.2 代码生成的完整流程
生成器按以下顺序构建代码:
┌─────────────────────────────────────────────────┐
│ 1. 生成导入语句 (import { ... } from "vue") │
│ 2. 生成静态提升变量 (const _hoisted_1 = ...) │
│ 3. 生成 render 函数签名 │
│ 4. 生成 render 函数体 (return ...) │
│ 5. 闭合函数 │
└─────────────────────────────────────────────────┘
我们用教学代码来演示这个过程:
function generate(ast) {
const context = createCodegenContext()
const { push, indent, deindent, newline } = context
// 第一步:生成导入语句
if (ast.helpers.length > 0) {
const helpers = ast.helpers
.map(h => `${h} as _${h}`)
.join(', ')
push(`import { ${helpers} } from "vue"`)
newline()
newline()
}
// 第二步:生成静态提升变量
ast.hoists.forEach((exp, i) => {
push(`const _hoisted_${i + 1} = `)
genNode(exp, context)
newline()
})
// 第三步:生成 render 函数
push('export function render(_ctx, _cache) {')
indent()
push('return ')
// 第四步:生成 VNode 创建表达式
if (ast.codegenNode) {
genNode(ast.codegenNode, context)
} else {
push('null')
}
// 第五步:闭合
deindent()
push('}')
return context.code
}
4.3 genNode——递归生成节点代码
genNode 是代码生成的递归引擎,根据节点类型调用不同的生成函数:
function genNode(node, context) {
switch (node.type) {
case 2: // TEXT
genText(node, context)
break
case 4: // EXPRESSION
genExpression(node, context)
break
case 5: // INTERPOLATION
genInterpolation(node, context)
break
case 13: // VNODE_CALL
genVNodeCall(node, context)
break
// ... 其他节点类型
}
}
function genText(node, context) {
context.push(JSON.stringify(node.content))
}
function genExpression(node, context) {
context.push(node.isStatic ? JSON.stringify(node.content) : node.content)
}
function genInterpolation(node, context) {
context.push('_toDisplayString(')
genNode(node.content, context)
context.push(')')
}
function genVNodeCall(node, context) {
const { push } = context
const { tag, props, children, patchFlag, isBlock } = node
if (isBlock) {
push('(_openBlock(), _createElementBlock(')
} else {
push('_createElementVNode(')
}
// 依次生成:tag, props, children, patchFlag
push(tag)
push(', ')
push(props ? JSON.stringify(props) : 'null')
push(', ')
if (Array.isArray(children)) {
push('[')
children.forEach((child, i) => {
if (i > 0) push(', ')
genNode(child, context)
})
push(']')
} else if (children) {
genNode(children, context)
} else {
push('null')
}
if (patchFlag) {
push(', ')
push(String(patchFlag))
}
push(')')
if (isBlock) push(')')
}
4.4 两种生成模式
Vue 3 的代码生成支持两种模式:
module 模式(默认,用于构建工具):
import { createElementVNode as _createElementVNode } from "vue"
export function render(_ctx, _cache) {
return _createElementVNode("div", null, "hello")
}
function 模式(用于运行时编译):
const { createElementVNode: _createElementVNode } = Vue
return function render(_ctx) {
with (_ctx) {
return _createElementVNode("div", null, "hello")
}
}
区别在于:module 模式使用 ES Module 的 import/export,变量通过 _ctx.xxx 访问;function 模式使用 with(_ctx) 包裹,变量可以直接访问(因为 with 会将 _ctx 作为作用域链的一部分)。需要注意 with 语句在 JavaScript 严格模式下不可用,这也是 SFC 编译和生产构建推荐使用 module 模式的原因之一。
五、编译优化——让运行时尽可能少干活
编译器最大的价值不在于”能编译”,而在于”编译时能做多少优化”。Vue 3 的编译器实现了三大编译时优化策略。
5.1 静态提升(hoistStatic)
核心思想:把永远不会变化的节点从渲染函数中”提出来”,只创建一次。
没有静态提升时:
export function render(_ctx) {
return (openBlock(), createElementBlock("div", null, [
createElementVNode("p", null, "static text"), // 每次 render 都重新创建
createElementVNode("p", null, toDisplayString(_ctx.msg), 1)
]))
}
开启静态提升后:
// 提升到模块作用域——只在模块加载时创建一次
const _hoisted_1 = /*#__PURE__*/createElementVNode("p", null, "static text", -1)
export function render(_ctx) {
return (openBlock(), createElementBlock("div", null, [
_hoisted_1, // 直接引用,不再重新创建
createElementVNode("p", null, toDisplayString(_ctx.msg), 1)
]))
}
编译器在 Transform 阶段通过 walk 函数递归判断每个节点是否可以被提升:
function walk(node, context) {
for (const child of node.children) {
// 只有纯元素节点可以被提升
if (child.type === 1 && isStaticNode(child)) {
// 标记为 HOISTED
child.codegenNode.patchFlag = -1
// 存入 context.hoists 数组
child.codegenNode = context.hoist(child.codegenNode)
} else {
// 递归检查子节点
walk(child, context)
}
}
}
当连续的静态节点数量足够多时,Vue 3 还会进行预字符串化——把多个静态节点合并为一个 HTML 字符串,通过 innerHTML 一次性创建。触发预字符串化只需满足以下任一条件:连续静态节点数量达到阈值(NODE_COUNT = 20),或其中含有静态属性绑定的元素节点达到阈值(ELEMENT_WITH_BINDING_COUNT = 5)。注意预字符串化只处理纯静态子树,这些节点必须是纯元素节点(不包含组件等),具体数值可能随版本调整,建议以源码为准:
// 不做预字符串化:20 个 createElementVNode 调用
const _hoisted_1 = createElementVNode("p", null, "text1", -1)
const _hoisted_2 = createElementVNode("p", null, "text2", -1)
// ... 重复 20 次
// 预字符串化后:一个 createStaticVNode 调用
const _hoisted_1 = createStaticVNode("<p>text1</p><p>text2</p>...<p>text20</p>", 20)
这既减少了函数调用开销,也减小了生成代码的体积。
5.2 缓存事件处理函数(cacheHandlers)
考虑这个模板:
<button @click="handleClick">Click</button>
每次渲染时,如果 handleClick 是内联函数,就会创建一个新的函数引用,导致子组件收到的 props 总是”不同的”,从而触发不必要的更新。
开启 cacheHandlers 后,编译器会将事件处理函数缓存到组件实例上:
// 未缓存
export function render(_ctx) {
return createElementVNode("button", {
onClick: _ctx.handleClick // 每次 render 获取新引用
}, "Click")
}
// 缓存后
export function render(_ctx, _cache) {
return createElementVNode("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick(...args)))
}, "Click")
}
_cache[0] 在第一次渲染时创建闭包函数并缓存,后续渲染直接复用——函数引用不变,子组件就不会被误触发更新。
5.3 Block Tree 与 dynamicChildren
这是 Vue 3 最核心的运行时优化,而它的基础完全由编译器搭建。
传统 diff 的问题:虚拟 DOM 的 diff 算法需要逐层遍历整棵树,即使大部分节点是静态的,也无法跳过。
传统 diff:遍历整棵树 Block Tree:只 diff 动态节点
div div (Block)
├── p "static" ← 比较 ├── p "static" ← 跳过
├── p {{ msg }} ← 比较 └── dynamicChildren:
└── span "text" ← 比较 └── p {{ msg }} ← 只比较这个
Block Tree 的工作原理:
编译器在生成代码时,会在根元素或 v-if/v-for 等结构化指令处创建”Block”。渲染函数执行时,openBlock() 开启一个动态节点收集器,所有动态子节点(patchFlag > 0)都会被收集到 dynamicChildren 数组中:
// 编译生成的渲染函数
export function render(_ctx) {
return (openBlock(), createElementBlock("div", null, [
_hoisted_1, // 静态节点,patchFlag = -1,不会被收集
createElementVNode("p", null, toDisplayString(_ctx.msg), 1 /* TEXT */)
// ↑ patchFlag = 1 > 0,被收集到 dynamicChildren
]))
}
执行后生成的 VNode 结构:
const vnode = {
type: 'div',
children: [
{ type: 'p', children: 'static text' }, // 静态
{ type: 'p', children: ctx.msg, patchFlag: 1 } // 动态
],
dynamicChildren: [
{ type: 'p', children: ctx.msg, patchFlag: 1 } // 只收集动态节点
]
}
运行时更新时,如果发现节点有 dynamicChildren,就只遍历这个数组,直接跳过所有静态节点:
// 简化版运行时 patch 逻辑
function patchElement(n1, n2) {
if (n2.dynamicChildren) {
// 优化路径:只 diff 动态节点
patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren)
} else {
// 回退路径:全量 diff
patchChildren(n1, n2)
}
// 利用 patchFlag 精准更新 props
if (n2.patchFlag & PatchFlags.CLASS) {
// 只更新 class
}
if (n2.patchFlag & PatchFlags.TEXT) {
// 只更新文本
}
}
这样做的效果是:更新的复杂度从”与模板大小成正比”降低为”与动态节点数量成正比”。一个有 100 个节点但只有 3 个动态绑定的模板,更新时只需要 diff 3 个节点。
需要注意的是,v-if 和 v-for 会创建新的 Block 边界。因为条件渲染和列表渲染会导致子节点的结构发生变化(节点可能增删),在一个固定的 dynamicChildren 数组中无法追踪这种结构性变化。所以每个 v-if 分支和 v-for 循环都是一个独立的 Block,它们内部各自收集自己的动态节点。
Block Tree 的结构示意:
Root Block (div)
├── dynamicChildren: [p.dynamic]
│
└── v-if Block (条件为 show)
└── dynamicChildren: [span.dynamic]
与 Vue 2 的对比:Vue 2 在编译时只做了”标记静态根节点”的优化——如果一个节点的所有子节点都是静态的,就标记为 staticRoot,渲染时缓存其 VNode。但这个优化的粒度很粗:只有纯静态子树才能被跳过,一旦子树中包含任何动态内容,整棵子树都需要重新 diff。而 Vue 3 的 Block Tree + dynamicChildren 实现了节点级别的精准追踪,即使一棵大树中只有一个叶子节点是动态的,也只需要 diff 那一个节点。
5.4 三大优化的协同
让我们用一个完整的例子把三大优化串起来:
<template>
<div>
<h1>Vue 3 Compiler</h1>
<p class="info">This is static content.</p>
<p :class="dynamicCls">{{ message }}</p>
<button @click="handleClick">Update</button>
</div>
</template>
编译结果:
import { createElementVNode, toDisplayString, openBlock,
createElementBlock, normalizeClass } from "vue"
// 静态提升:h1 和 p.info 只创建一次
const _hoisted_1 = createElementVNode("h1", null, "Vue 3 Compiler", -1)
const _hoisted_2 = createElementVNode("p", { class: "info" },
"This is static content.", -1)
export function render(_ctx, _cache) {
return (openBlock(), createElementBlock("div", null, [
_hoisted_1, // 引用静态提升节点
_hoisted_2, // 引用静态提升节点
createElementVNode("p", {
class: normalizeClass(_ctx.dynamicCls)
}, toDisplayString(_ctx.message), 3 /* TEXT | CLASS */),
createElementVNode("button", {
onClick: _cache[0] || (_cache[0] = (...args) => _ctx.handleClick(...args))
// ↑ 事件处理函数缓存
}, "Update")
]))
}
运行时更新时:
h1和p.info完全跳过(静态提升,不在 dynamicChildren 中)- 第三个
p标签只检查 text 和 class(patchFlag = 3) button的事件处理函数引用不变,不触发多余更新
六、从模板到运行时——SFC 编译流程
6.1 单文件组件的编译全貌
前面讨论的编译器只处理 <template> 部分。但一个 .vue 文件(SFC,Single-File Component)还包含 <script> 和 <style>,它们是如何被处理的?
┌─────────────────────────────────────────────────────┐
│ App.vue (SFC) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ <template> │ │ <script> │ │ <style> │ │
│ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │
│ │ │ │ │
└─────────┼──────────────┼──────────────┼─────────────┘
│ │ │
v v v
@vue/compiler-dom @vue/compiler-sfc PostCSS/etc.
│ │ │
v v v
render function setup function CSS Module/Scoped
│ │ │
└──────────────┼──────────────┘
v
final component object
这个过程由 @vue/compiler-sfc 包负责协调。它会:
- 解析 SFC 结构:将
.vue文件拆分为template、script、style三个 descriptor。 - 分别编译:
template交给@vue/compiler-dom,script由 JS 编译器处理,style交给 CSS 处理器。 - 组装产物:将渲染函数、组件逻辑、样式整合为一个完整的组件模块。
6.2 <script setup> 的编译转换
<script setup> 是 Vue 3.2 引入的语法糖,它大幅简化了组合式 API 的使用。但它能如此简洁,完全是编译器在背后做了大量工作。
你写的代码:
<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
<MyComponent />
<button @click="increment">{{ count }}</button>
</template>
编译器实际生成的代码(简化):
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
import { toDisplayString, createElementVNode, openBlock,
createElementBlock, createVNode, Fragment } from 'vue'
export default {
__name: 'App',
setup(__props, { expose: __expose }) {
__expose()
const count = ref(0)
const increment = () => count.value++
// 编译器自动识别模板中使用的变量并返回
return (_ctx, _cache) => {
return (openBlock(), createElementBlock(Fragment, null, [
createVNode(MyComponent),
createElementVNode("button", {
onClick: increment
}, toDisplayString(count.value), 1)
]))
}
}
}
编译器在这里做了几件关键的事:
1. 自动推断暴露的绑定
编译器分析 <script setup> 中声明的所有顶层变量、函数、import,自动将它们暴露给模板。你不需要手动 return。
2. 组件自动注册
import MyComponent from './MyComponent.vue' 这行代码在普通 <script> 中只是导入,需要在 components 选项中注册。但在 <script setup> 中,编译器发现模板使用了 <MyComponent />,会自动将其识别为组件,无需注册。
3. 宏的编译时处理
defineProps、defineEmits、defineExpose 等编译器宏在编译时被转换为对应的运行时代码:
<script setup>
const props = defineProps({ title: String })
const emit = defineEmits(['update'])
</script>
编译后:
export default {
props: { title: String },
emits: ['update'],
setup(__props, { emit: __emit }) {
const props = __props
const emit = __emit
// ...
}
}
defineProps 和 defineEmits 在运行时并不存在——它们是纯粹的编译时指令,编译器会把它们转换为组件选项和 setup 参数的解构。
6.3 编译器的运行时机
Vue 的编译可以发生在两个时机:
┌─────────────────────────────────────────────────┐
│ 构建时编译 (Build-time) │
│ ───────────────────── │
│ 通过 Vite / Webpack + vue-loader 在打包时完成 │
│ 产物中不包含编译器,体积更小 │
│ 绝大多数项目使用这种方式 │
│ │
│ 运行时编译 (Runtime) │
│ ───────────────── │
│ 使用包含编译器的完整版 Vue (vue.global.js) │
│ 在浏览器中实时编译 template 选项 │
│ 适用于需要动态模板的特殊场景 │
└─────────────────────────────────────────────────┘
构建时编译是推荐方式:编译发生在开发/构建阶段,运行时只需要执行已编译好的渲染函数,无需携带编译器代码(减少约 30% 的 bundle 体积)。
6.4 Scoped CSS 的编译实现
你可能好奇,<style scoped> 是怎么做到样式隔离的?答案同样在编译器中。
当编译器遇到 <style scoped> 时,会做两件事:
第一步:给模板中的每个元素添加唯一属性
编译器会为当前 SFC 生成一个唯一的 hash(如 data-v-7a7a37b1),然后在模板编译时,给每个元素节点添加这个属性:
<!-- 编译前 -->
<template>
<div class="wrapper">
<p>hello</p>
</div>
</template>
<!-- 编译后的渲染结果 -->
<div class="wrapper" data-v-7a7a37b1>
<p data-v-7a7a37b1>hello</p>
</div>
第二步:给 CSS 选择器添加属性选择器
/* 编译前 */
.wrapper { color: red; }
p { font-size: 14px; }
/* 编译后 */
.wrapper[data-v-7a7a37b1] { color: red; }
p[data-v-7a7a37b1] { font-size: 14px; }
通过属性选择器的限定,样式只能匹配带有对应 hash 属性的元素,从而实现了组件级别的样式隔离。这个过程完全发生在编译时,运行时不需要任何额外的开销。
🤔 思考题
-
为什么 Vue 不在运行时做 PatchFlags 标记,而是在编译时? 提示:考虑运行时 diff 的时机和信息量。
-
如果一个组件完全使用
render函数(不用 template),Block Tree 优化还能生效吗? 提示:思考openBlock()和createElementBlock()的调用时机。 -
<script setup>中的defineProps为什么不需要 import 就能使用? 提示:想想编译器宏和普通函数的区别。 -
考虑这个模板:
<div v-if="show"><p>{{ msg }}</p></div>。当show从true变为false时,Block Tree 是如何处理的? 提示:v-if会创建新的 Block 边界。
📝 本章自测
-
Vue 3 编译器的三个阶段分别是什么?每个阶段的输入和输出是什么?
答案:Parse(输入:模板字符串,输出:AST)、Transform(输入:AST,输出:带有 codegenNode 和优化标记的增强 AST)、Generate(输入:增强 AST,输出:渲染函数代码字符串)。
-
解析阶段中,
ancestors栈的作用是什么?答案:记录从根节点到当前节点的路径。它用于判断闭合标签是否匹配(结束条件),以及确定当前节点的父元素(父子关系)。
-
PatchFlags 为什么使用二进制位而不是普通数字?
答案:二进制位可以通过按位或(
|)组合多个标记,通过按位与(&)检测是否包含某个标记,运算效率极高。例如TEXT | CLASS表示文本和 class 都是动态的,检测时flag & CLASS即可判断是否有动态 class。 -
静态提升和 Block Tree + dynamicChildren 分别在什么阶段生效?
答案:静态提升在编译阶段生效(生成代码时将静态节点提升到 render 函数外部);Block Tree 和 dynamicChildren 在运行时生效(渲染函数执行时收集动态节点,更新时只 diff 动态节点)。但 Block Tree 的基础是编译器在生成代码时插入了
openBlock()和createElementBlock()调用。 -
<script setup>中defineProps的编译结果是什么?答案:
defineProps({ title: String })会被编译为组件的props选项(props: { title: String }),同时在setup函数参数中通过__props提供访问。defineProps本身是编译器宏,不存在于运行时,不需要 import。
总结
本章我们深入了 Vue 3 编译器的内核,完整走过了模板从字符串到可执行代码的全过程:
- Parse 阶段通过有限状态机逐字符消费模板,用
ancestors栈维护嵌套关系,最终生成忠实描述模板语法结构的 AST。 - Transform 阶段采用插件式架构遍历 AST,为每个节点生成
codegenNode,标记 PatchFlags,收集可提升的静态节点。 - Generate 阶段递归遍历增强后的 AST,通过
push函数逐段拼接出渲染函数代码字符串。 - 编译优化是 Vue 3 性能的关键:静态提升避免重复创建不变的 VNode;事件缓存避免子组件不必要的更新;Block Tree + dynamicChildren 让运行时 diff 的复杂度只与动态节点数量相关。
- SFC 编译将
<template>、<script>、<style>分别处理后整合,<script setup>的简洁写法背后是编译器的自动推断和宏转换。
至此,你已经完整理解了 Vue 3 从模板编写到页面渲染的全链路:模板 → 编译器 → 渲染函数 → 虚拟 DOM → 响应式更新 → 真实 DOM。这条链路上的每一环,我们都在精通篇中做了深入解析。
编译器是连接”开发体验”和”运行时性能”的桥梁。Vue 之所以能在提供声明式模板语法的同时保持出色的运行时性能,核心原因就是编译器能够在构建阶段提前完成大量优化工作。理解了这一点,你就真正理解了 Vue 的设计哲学。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90