前言
编译技术是一个庞大的学习,不同用途的编译器或者编译技术的难度可能相差很大,对知识的掌握也相差甚远。作为前端工程师,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。Vue.js的模板和JSX都属于领域特点语言,它们的实现难度属于中低级别。
编译器其实就是一段程序,用来将一种语言A翻译成另外一种语言B,其中A通常叫做源代码,B常叫做目标代码,翻译的过程就是编译。一个完整的编译过程,通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤。其中词法分析、语法分析、语义分析通常语目标平台无关,仅是分析源代码;中间代码生成、优化也不一定语目标代码有关,取决于具体场景和实现;目标代码生成通常语目标平台有关。
Vue的模板编译器
Vue的模板编译器是用于将组件模板编译为渲染函数,它的工作流程大致分为三个步骤
- 分析模板,将其解析为模板AST(Parse)
- 将模板AST转换为用于描述渲染函数的JavaScript AST(Transformer)
- 根据JavaScript AST生成渲染函数代码(Generate)
AST 是 Abstract Syntax Tree 的首字母缩写,即抽象语法树。AST 是对源代码进行语法分析(Parsing) 后生成的一种中间表示(Intermediate Representation)。它去除了源代码中的无关细节(如括号、分号、空格等),只保留程序的结构和语义信息
Parse
主要工作
- 扫描模板字符流,识别:
- 元素标签(如
div) - 文本内容(如
Vue) - 插值表达式(如
{{ msg }}) - 指令(如
v-if、v-bind:id) - 注释、DOCTYPE 等
- 元素标签(如
- 构建嵌套的 AST 节点树,反映模板结构
- 对插值和绑定表达式进行 基础解析(生成简单表达式 AST
将模板字符串解析为模板抽象语法树,使用有限状态自动机(状态机)来高效、准确地将 HTML 模板字符串转换为 AST,即使用状态控制解析器在不同状态间流转解析。
<div>Vue</div>
解析器的入参是字符串模板,解析器会逐个读取字符串模板中的字符,并将根据一定的规则将整个字符串切割成一个个Token。比如上面的例子解析器就会切割成三个Token
- 开始标签:
<div> - 文本标签:
Vue - 结束标签:
</div>parse函数会逐个读取字符串,根据当前状态和当前读取的字符,进入到对应设定好的分支处理和状态变更,一直到解析完成。大致意思,用代码表示如下
const State = {
initial: 1, // 初始状态
tagOpen: 2, // 开始标签状态
tagName: 3, // 开始标签名称状态
text: 4, // 文本状态
tagEnd: 5, // 结束标签状态
tagEndName: 6 // 开始标签名称状态
}
// 一个辅助函数,用于判断是否是字母
function isAlpha(char) {
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
function tokenize(str) {
let currentState = State.initial
const chars = []
const tokens = []
while(str) {
const char = str[0]
swith (currentState) {
case State.initial:
if (char === '<') {
currentState = State.tagOpen
str = str.slice(1)
} else if (isAlpha(char)) {
currentState = State.text
chars.push(char)
str = str.slice(1)
}
break
case State.tagOpen:
if (isAlpha(char)) {
currentState = State.tagName
chars.push(char)
str = str.slice(1)
} else if (char === '/') {
currentState = State.tagEnd
str = str.slice(1)
}
break
case State.tagName:
if (isAlpha(char)) {
chars.push(char)
str = str.slice(1)
} else if (char === '>') {
currentState = State.initial
tokens.push({
type: 'tag',
name: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break
......
......
......
}
}
}
为 Vue.js 的模板构造 AST 是一件比较简单的事。HTML 是一种标记语言,它的格式非常固定,标签元素之间天然嵌套,形成父子关系。 因此,一棵用于描述 HTML 的 AST 将拥有与 HTML 标签非常相似的树型结构。 使用程序根据模板解析后生成的 Token 构造出对应的AST。
// 对模板字符串进行扫描,识别出不同的 token 类型
const token = tokenize(`<div><p>Vue</p><p>Template</p><p>{{ message }}</p></div>`)
// 根据模板字符串解析出的token
const tokens = [
{ type: "tag", name: "div" },
{ type: "tag", name: "p" },
{ type: "text", content: "Vue" },
{ type: "tagEnd", name: "p" },
{ type: "tag", name: "p" },
{ type: "text", content: "Template" },
{ type: "tagEnd", name: "p" },
{ type: "tag", name: "p" },
{ type: "interpolation", expression: "message" },
{ type: "tagEnd", name: "p" },
{ type: "tagEnd", name: "div" }
];
// 根据token构建出模板的AST
const ast = {
"type": 0, // ROOT
"children": [
{
"type": 1, // ELEMENT (<div>)
"tag": "div",
"props": [],
"children": [
{
"type": 1, // ELEMENT (<p>)
"tag": "p",
"props": [],
"children": [
{ "type": 2, "content": "Vue" } // TEXT
]
},
{
"type": 1, // ELEMENT (<p>)
"tag": "p",
"props": [],
"children": [
{ "type": 2, "content": "Template" } // TEXT
]
},
{
"type": 1, // ELEMENT (<p>)
"tag": "p",
"props": [],
"children": [
{
"type": 5, // INTERPOLATION ({{ message }})
"content": {
"type": 4, // SIMPLE_EXPRESSION
"content": "message",
"isStatic": false,
"constType": 0
}
}
]
}
]
}
]
}
正则表达式的本质就是有限自动机。正则表达式本质上是对有限自动机的一种“声明式”描述 比如这个正则表达式
a*b,标识任意数量a(包括0个),后面跟着一个b。 对应的DFA(确定性有限自动机)为
状态 0 --a--> 状态 0
状态 0 --b--> 状态 1(接受)
状态 0 --任意(非 a、非 b)--> 拒绝
状态 1 --任意--> 拒绝
Transform
主要工作
- 遍历模板AST节点(深度优先)
- 注入 Vue 特有逻辑:
- 处理指令:
v-if→ 条件渲染函数,v-for→ 循环函数,v-model→ 事件 + 绑定 - 解析属性绑定:
v-bind:id="x"→ 动态 props
- 处理指令:
- 静态分析与优化:
- 标记
static节点(完全静态) - 静态提升(hoist):将静态节点提取到 render 函数外部
- 生成
patchFlag(更新标记):如TEXT=1,PROPS=2 - 事件处理器缓存(
cacheHandlers)
- 标记
- 上下文绑定:
- 将
message转换为_ctx.message(绑定到组件上下文)
- 将
- 生成
codegenNode:- 为每个节点生成对应的
VNode创建调用结构(如_createElementVNode("p", ..., 1))
- 为每个节点生成对应的
- 注册并收集运行时辅助函数(Helpers)
- 编译器内部维护一个
helper(name)函数 和一个context.helpersSet(挂载在根 AST 上),每当某个转换逻辑需要用到运行时能力时,就调用context.helper(HELPER_NAME),如遇到插值{{ x }}→ 调用helper(TO_DISPLAY_STRING)、 生成动态元素 VNode → 调用helper(CREATE_ELEMENT_VNODE)
- 编译器内部维护一个
Transorm用于将模板 AST 转为 J avaScript AST,我们需要将模板编译为渲染函数。而渲染函数是由 JavaScript 代码来描述的,因此,我们需要将模板 AST 转换为用于描述渲染函数 的 JavaScript AST
为了对 AST 进行转换,我们需要能访问 AST 的每一个节点,这样才有机会对特定节点进行修改、替换、删除等操作。由于 AST 是树型数据结构,所以我们需要编写一个深度优先的遍历算法,从而实现对 AST 中节点的访问。
在转换 AST 节点的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待其所 有子节点全部转换完毕后再执行;(这里有点像洋葱模型)
{
type: 0, // ROOT
children: [
{
type: 1, // <div>
tag: 'div',
props: [],
children: [
/* 第一个 <p>Vue</p> */
{
type: 1,
tag: 'p',
props: [],
children: [{ type: 2, content: 'Vue' }],
static: true, // ✅ 完全静态
hoisted: { /* VNode 调用对象 */ }, // 被提升(实际指向 _hoisted_1)
codegenNode: {
type: 13, // VNODE_CALL
tag: '"p"',
props: null,
children: '"Vue"',
patchFlag: -1, // HOISTED
isBlock: false
}
},
/* 第二个 <p>Template</p> */
{
type: 1,
tag: 'p',
props: [],
children: [{ type: 2, content: 'Template' }],
static: true,
hoisted: { /* 指向 _hoisted_2 */ },
codegenNode: {
type: 13,
tag: '"p"',
props: null,
children: '"Template"',
patchFlag: -1,
isBlock: false
}
},
/* 第三个 <p>{{ message }}</p> */
{
type: 1,
tag: 'p',
props: [],
children: [
{
type: 5, // INTERPOLATION
content: {
type: 4,
content: 'message',
isStatic: false,
// 表达式在 transform 中被包装为 _ctx.message
ast: { /* JS AST: Identifier("message") */ }
},
// 插值本身无 codegenNode,由父元素处理
}
],
static: false, // ❌ 含动态内容
codegenNode: {
type: 13, // VNODE_CALL
tag: '"p"',
props: null,
children: {
type: 8, // COMPOUND_EXPRESSION
children: [
{ type: 4, content: '_ctx.message' } // 已绑定上下文
]
},
patchFlag: 1, // TEXT —— 仅文本可能变化
isBlock: false
}
}
],
static: false, // 因含动态子节点
codegenNode: {
type: 13, // VNODE_CALL
tag: '"div"',
props: null,
children: [ /* 三个子节点的 codegenNode 引用 */ ],
patchFlag: 0,
isBlock: true // 根元素或含动态内容的元素会创建 Block
}
}
],
// 根节点额外字段:用于 hoist 静态节点
hoists: [
{ /* _hoisted_1: <p>Vue</p> 的 VNode 调用 */ },
{ /* _hoisted_2: <p>Template</p> 的 VNode 调用 */ }
],
// 其他元信息
helpers: [ 'createElementVNode', 'openBlock', 'createElementBlock', 'toDisplayString' ]
}
Generate
将 Transform 后的 AST 编译为可执行的 JavaScript 渲染函数代码字符串;代码生成的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应 的代码生成函数
主要工作
- 生成辅助函数导入:
// 将 `helpers` 转为 `import { ... } from "vue"`
import { createElementVNode as _createElementVNode, ... } from "vue"
- 生成 hoisted 静态节点:
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Vue", -1)
- 生成 render 函数体:
- 遍历
codegenNode,递归生成函数调用代码 - 处理 block(
_openBlock(),_createElementBlock()) - 插入
patchFlag和动态绑定表达式
- 遍历
- 输出完整代码字符串
import {
createElementVNode as _createElementVNode,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
toDisplayString as _toDisplayString
} from "vue"
// 静态提升:仅创建一次
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Vue", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("p", null, "Template", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1,
_hoisted_2,
// 动态节点:每次渲染需更新文本
_createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
]))
}
编译优化
实际上,模板的结构非常稳定。通过编译手段,我们可以分析出很多关键信息,例如哪些节点是静态的,哪些节点是动态的。结合这些关键信息,编译器可以直接生成原生 DOM 操作的代码,这样甚至能够抛掉虚拟 DOM,从而避免虚拟 DOM 带来的性能开销。但是,考虑到渲染函数的灵活性,以及 Vue.js 2 的兼容问题,Vue.js 3 最终还是选择了保留虚拟 DOM。
Vue 3 的编译优化是其性能优势的核心之一,它通过在编译阶段对模板进行静态分析,将大量原本需要在运行时完成的工作提前完成,从而显著减少运行时开销、提升更新效率。总体思想就是在编译的时候做尽可能多的事,在运行的时候做尽可能少的事
Patch Flags(更新标记)
为动态节点打上补丁标志,让运行时精准知道哪些部分可能变化,避免不必要的全量 diff
- 为每个动态节点打上
patchFlag(位掩码),标识其动态类型。 - 运行时根据
patchFlag决定执行哪些更新操作。
常见 Patch Flags
| Flag(值) | 名称 | 含义 |
|---|---|---|
1 | TEXT | 文本内容可能变化(如 {{ msg }}) |
2 | CLASS | class 绑定可能变化 |
4 | STYLE | style 绑定可能变化 |
8 | PROPS | 其他 props(如 id、自定义 prop)可能变化 |
16 | FULL_PROPS | props 对象整体动态(如 v-bind="obj") |
32 | HYDRATE_EVENTS | 服务端渲染事件绑定 |
64 | STABLE_FRAGMENT | 稳定的 Fragment(如静态 v-for) |
128 | KEYED_FRAGMENT | 带 key 的 Fragment(如 v-for with key) |
256 | UNKEYED_FRAGMENT | 无 key 的 Fragment |
-1 | HOISTED | 静态提升节点(永不更新) |
-2 | BAIL | 无法优化,需全量 diff |
- 更新时只比对可能变化的部分,跳过静态属性
- 减少内存分配和 DOM 操作
Block Tree 与动态节点追踪
它与 Patch Flags 紧密配合,在组件或元素有多个动态子节点时,避免遍历所有子节点来查找变化。
在传统虚拟DOM中,如果父组件更新时,即使只有一个子节点变化,也需要整个子树递归diff,如果子节点有 1000 个,999 个静态,1 个动态 → 仍需检查 1000 次!
在Vue3中,使用动态节点收集 + Block作用域来解决这一问题。
首先编译器在Transform阶段就识别出哪些节点是动态的,哪些是静态的,据此我们可以做以下操作
- 遍历AST,标记每个节点是否
static - 对于非静态节点或含动态子节点的节点
- 设置
isBlock = true - 生成
codegenNode时使用CREATE_ELEMENT_BLOCK
- 设置
- 收集动态子节点
- 在生成
codegenNode时,递归收集所有patchFlag > 0或isBlock的后代 - 这些节点被记录在 Block 的
dynamicChildren中(运行时填充)
- 在生成
<div> <!-- Block (根) -->
<p>Static</p>
<section> <!-- 静态,非 Block -->
<span>Deep static</span>
</section>
<p>{{ a }}</p> <!-- 动态 -->
<div> <!-- Block(含动态子) -->
<em>{{ b }}</em> <!-- 动态 -->
<i>Static in block</i>
</div>
</div>
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Static", -1);
const _hoisted_2 = /*#__PURE__*/_createElementVNode("span", null, "Deep static", -1);
const _hoisted_3 = /*#__PURE__*/_createElementVNode("i", null, "Static in block", -1);
export function render(_ctx) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1,
_createElementVNode("section", null, [_hoisted_2]),
_createElementVNode("p", null, _toDisplayString(_ctx.a), 1),
(_openBlock(), _createElementBlock("div", null, [
_createElementVNode("em", null, _toDisplayString(_ctx.b), 1),
_hoisted_3
]))
]))
}
Block(通过_createElementBlock创建)是一个带有dynamicChildren数组的VNode,该数组显式记录了其子树中所有动态子节点,包括深层嵌套的。另外:根节点总是 Block(因为 render 函数返回的是 Block)
- 如果一个元素包含动态子节点,它会被编译为一个 Block(使用
_createElementBlock)。 - Block 会收集其所有动态子节点(包括深层嵌套的),存入
dynamicChildren数组。 - 更新时,运行时只遍历
dynamicChildren,忽略静态子节点
编译时:识别动态节点,构建 Block 结构,生成 dynamicChildren 收集逻辑
运行时:通过 dynamicChildren 实现精准更新,跳过静态子树
Block Tree + Patch Flags = Vue 3 虚拟 DOM 的“智能 diff”引擎
v-once 包裹的动态节点 不会被父级 Block 收集。因此,被 v-once 包裹的动态节点在组件更新时,不会参与 Diff 操作。
另外,和指令相关如v-if,v-for等节点会影响动态节点收集,这里没有进一步说明。v-if、v-for 等结构化指令会影响 DOM 层级结构,使之不稳定。这会间接导致基于 Block 树的比对算法失效。而解决方式很简单,只需要让带有 vif、v-for 等指令的节点也作为 Block 角色即可
静态提升
避免重复创建永远不会变化的VNode。
- 如果一个节点及其所有子节点不含任何动态内容(无插值、无指令、无组件、无事件监听器等),则标记为 静态(static)。
- 编译器将这些静态节点的 VNode 创建调用提取到 render 函数外部,作为常量(hoisted)。
- 渲染时直接复用同一个 VNode 对象,跳过创建和 diff
// 静态提升:只创建一次
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "Static Text", -1);
const _hoisted_2 = /*#__PURE__*/_createElementVNode("span", { class: "icon" }, null, -1);
关键点
patchFlag: -1表示HOISTED(永不更新)/*#__PURE__*/注释帮助打包工具进行 Tree-shaking
事件处理器缓存(Event Handler Caching)
避免在每次渲染时重复创建内联函数(如 @click="() => doX(id)")
- 如果事件处理器是内联函数表达式,且启用了
cacheHandlers(默认在 DOM 编译中开启),编译器会将其缓存到_cache数组中。 - 只要组件实例不变,就复用同一个函数引用。
优势
- 避免不必要的事件监听器重新绑定
- 减少垃圾回收压力
- 提升列表渲染性能(尤其
v-for中的内联函数)
其他优化
静态 Props 提取
- 静态属性(如
<div id="app">)被编译为字面量对象,而非响应式绑定。
Slot 编译优化
- 作用域插槽被编译为函数,静态插槽被提升。
v-for key 分析
- 带 key 的
v-for生成KEYED_FRAGMENT,启用高效 diff 算法。
表达式简化
- 常量表达式(如
{{ 1 + 2 }})在编译时直接计算为"3"。
结语
Vue 3 编译器通过静态分析将模板的结构稳定性和动态变化点提前分离,使得运行时无需猜测、无需遍历、无需全量比对,从而在保持声明式语法简洁性的同时,实现了接近手动优化的性能。其核心目标是:在编译时尽可能多地提取静态信息,将运行时开销降至最低