前言
大家好,我是小林。
作为 Vue 开发者,我们每天都在和 <template>
语法打交道。但大家是否曾深入思考过:从我们写下一行模板代码,到它最终被渲染成浏览器中的 DOM 元素,这中间到底发生了什么?Vue 在背后为我们完成了一项复杂但至关重要的工作------编译。
这个过程常常像一个"黑箱",我们知其然,却不一定知其所以然。本文的灵感来源于霍春阳老师的 《Vue.js 设计与实现》,旨在带大家打开这个"黑箱"。我们将以前端工程师的视角,从零开始,一步步带大家亲手实现一个简易但核心功能完备的模板编译器。
通过这次实践,大家将不仅能回答"是什么"和"怎么做",更能理解"为什么"这么设计,从而彻底掌握 Vue 模板编译的底层原理。
编译原理
我们可以打开 vue-template-explorer,输入 <div id="one">Hello World</div>
我们得到一份js代码:
js
const _Vue = Vue
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
return (_openBlock(), _createElementBlock("div", { id: "one" }, "Hello World"))
}
}
如上,从模板代码转为了Javascript代码(Vue的渲染函数),这便是编译的过程。
这个过程,就好像把一份产品设计蓝图,翻译成一份给建筑队看的、步骤清晰的施工方案一样。编译器,就是那个专业的"翻译官"。
Vue 的这位"翻译官"工作流程非常清晰,大致可以分为三步,这也是我们后续亲手实现时的核心骨架:
- 解析 (Parse) :先把模板字符串读懂,拆解成一个个有意义的"词汇"(词法分析),然后根据"语法"将这些词汇组织成一个树形的结构,我们称之为抽象语法树 (AST)。这个过程就像分析一个长句子,先把它拆成主谓宾等单词,再理清句子结构。
- 转换 (Transform):接着,对这棵 AST 进行一些"深度加工"。比如标记出哪些内容是动态的,哪些是静态的,为后续的性能优化做准备。加工之后,我们会得到一棵更适合生成 JavaScript 代码的"新树"。
- 生成 (Generate):最后,根据这棵加工完毕的 AST,拼接出我们最终看到的渲染函数代码字符串。
我们接下来要实现的,就是这个专门为 Vue 模板语言服务的、以 Parse -> Transform -> Generate
为核心的编译器。
通用的编译原理简介
从更广义的计算机科学角度看,一个完整的编译器通常包括 词法分析
、语法分析
、语义分析
、中间代码生成
、代码优化
和 目标代码生成
等主要阶段。
- 词法分析 :指将源代码解析成一个个有意义的词法单元,也叫
token
(如关键字、标识符、运算符等),去除空格和注释。 - 语法分析:根据语言的语法规则,将词法单元(token)组织成抽象语法树(AST),检查程序的结构是否符合语法规范。
- 语义分析:对语法树进行上下文相关的检查,例如类型检查、变量声明验证等,确保程序的逻辑意义正确。
- 中间代码生成:将语法树转换为一种介于源语言和目标语言之间的中间表示形式(如三地址码),便于后续优化和翻译。
- 代码优化:对中间代码进行等价变换,以提高程序的运行效率或减小代码体积,可在多个层次进行(如局部优化、循环优化等)。
- 目标代码生成:将优化后的中间代码转换为目标机器的汇编代码或机器代码,并进行寄存器分配、指令选择等底层处理。
其中词法分析
、语法分析
、语义分析
一般称为编译前端
,它通常和目标平台无关,只负责分析源代码。而中间代码生成
、代码优化
和 目标代码生成
则是编译后端
,负责目标平台的代码生成,有时候中间代码生成
、代码优化
也成为中端
。
用一个流程图来表示:
(Lexical Analysis)"] --> B["语法分析
(Syntax Analysis)"] B --> C["语义分析
(Semantic Analysis)"] C --> D["中间代码生成
(Intermediate Code Generation)"] end subgraph Middle["编译中端 (优化层)"] direction TB D --> E["代码优化
(Code Optimization)"] end subgraph Backend["编译后端 (平台相关)"] direction TB E --> F["目标代码生成
(Target Code Generation)"] F --> G["目标机器代码
(x86/ARM 等)"] end Frontend -.->|"输出: 抽象语法树 (AST)"| Middle Middle -.->|"处理: 中间表示 (IR)"| Backend Backend -.->|"输出: 机器码"| G end subgraph Vue模板编译器["Vue 模板编译器流程"] direction TB V1["模板字符串"] --> V2["词法分析"] V2 --> V3["语法分析"] V3 --> V4["模板AST"] V4 --> V5["转换 (Transform)
优化 & 生成 JS AST"] V5 --> V6["生成渲染函数代码
(Render Function)"] V6 --> V7["JavaScript 代码"] end 编译器通用架构 --> Vue模板编译器 style Frontend fill:#e1f5fe,stroke:#039be5,color:#000 style Middle fill:#f3e5f5,stroke:#8e24aa,color:#000 style Backend fill:#f1f8e9,stroke:#689f38,color:#000 style Vue模板编译器 fill:#fff3e0,stroke:#fb8c00,color:#000
动手实现Vue模板编译器
为了最快上手了解原理,我会实现一个非常简易的编译器,这意味着会忽略大量细节,只揭示原理。
我们可以用简单的代码表示模板编译的过程:
js
// 将模板字符串转为模板AST
const templateAST = parse(templateCode)
// 将模板AST转为jsAST
const jsAST = transform(templateAST)
// 将jsAST生成js代码
const code = generate(jsAST)
parse的实现
parse
阶段的目标是将模板字符串转换为一份结构化的数据,即模板抽象语法树 (AST)。这个过程分为两步:
- 词法分析 (Tokenization):将模板字符串切割成一个个独立的词法单元 (Token)。
- 语法分析 (Parsing):将词法单元流组装成一棵树状的 AST。
模板AST结构
在动手之前,我们首先要明确转换的目标------模板 AST 长什么样。我们可以借助 astexplorer 这个工具,选择 @vue/compiler-dom
解析器(注意 vue-template-compiler
是 Vue 2 的版本),输入一段简单的模板,例如 <template><div>Hello World</div></template>
,便可以得到官方的 AST 结构:
json
{
"type": 0,
"children": [
{
"type": 1,
"ns": 0,
"tag": "template",
"tagType": 0,
"props": [],
"isSelfClosing": false,
"children": [
{
"type": 1,
"ns": 0,
"tag": "div",
"tagType": 0,
"props": [],
"isSelfClosing": false,
"children": [
{
"type": 2,
"content": "Hello World",
"loc": {
"start": {
"column": 9,
"line": 2,
"offset": 19
},
"end": {
"column": 20,
"line": 2,
"offset": 30
},
"source": "Hello World"
}
}
],
"loc": {
"start": {
"column": 4,
"line": 2,
"offset": 14
},
"end": {
"column": 26,
"line": 2,
"offset": 36
},
"source": "<div>Hello World</div>"
}
}
],
"loc": {
"start": {
"column": 1,
"line": 1,
"offset": 0
},
"end": {
"column": 12,
"line": 3,
"offset": 48
},
"source": "<template>\n <div>Hello World</div>\n</template>"
}
}
],
"helpers": [],
"components": [],
"directives": [],
"hoists": [],
"imports": [],
"cached": 0,
"temps": 0,
"loc": {
"start": {
"column": 1,
"line": 1,
"offset": 0
},
"end": {
"column": 1,
"line": 4,
"offset": 49
},
"source": "<template>\n <div>Hello World</div>\n</template>\n"
}
}
可以看到,这是一个非常详尽的树形结构。它用 type
字段标识节点类型(0
是根、1
是元素、2
是文本),用 tag
表示标签名,children
数组体现层级关系。此外,还有 loc
(位置信息)、helpers
(编译辅助信息)等。
为了聚焦核心原理,我们不必实现一个如此面面俱到的结构,稍后将构建一个简化但足以说明问题的版本。
词法分析
词法分析的第一步,是将模板字符串分解为一个个独立的词法单元 (Token)。
这个过程的核心是有限状态机 (Finite State Machine, FSM)。模板字符串本质上是一个字符流,我们需要一个"状态"来记忆当前正在解析的内容(例如,是标签还是纯文本?),从而决定遇到下一个字符时该如何行动。FSM 正是解决此类问题的经典模型。
虽然 WHATWG 发布的 HTML 解析规范 定义了非常详尽的状态,但 Vue 模板有其特殊性,且为了简化目的,我们仅需关注以下几个核心状态即可:
- 初始状态
- 标签开始状态
- 标签名称状态
- 文本状态
- 结束标签状态
- 结束标签名称状态
让我们以 <p>Vue</p>
为例,看看状态机是如何工作的:
-
初始状态 (initial)
- 当前字符 :
<
- 动作 : 检测到
<
,意味着一个标签即将开始。状态切换为tagOpen
。 - 消费字符 : 移除
<
,剩余p>Vue</p>
。
- 当前字符 :
-
标签开始状态 (tagOpen)
- 当前字符 :
p
- 动作 : 检测到字母,判定为标签名。状态切换为
tagName
。 - 操作 : 将
p
存入临时缓存。 - 消费字符 : 移除
p
,剩余>Vue</p>
。
- 当前字符 :
-
标签名称状态 (tagName)
- 当前字符 :
>
- 动作 : 检测到
>
,标志着开始标签的结束。 - 操作 :
- 从缓存中取出
p
,创建tag
类型的 Token。 - 清空缓存,状态回到
initial
,准备解析下一段。
- 从缓存中取出
- 消费字符 : 移除
>
,剩余Vue</p>
。
- 当前字符 :
-
初始状态 (initial) - 再次进入
- 当前字符 :
V
- 动作 : 检测到字母,判定为文本内容。状态切换为
text
。 - 操作 : 将
V
存入临时缓存。 - 消费字符 : 移除
V
,剩余ue</p>
。
- 当前字符 :
-
文本状态 (text) - 连续处理
- 当前字符 :
u
→ 存入缓存,消费。 - 当前字符 :
e
→ 存入缓存,消费。 - 当前字符 :
<
→ 检测到<
,标志着文本段的结束。 - 操作 :
- 从缓存中取出
Vue
,创建text
类型的 Token。 - 清空缓存,状态切换为
tagOpen
。
- 从缓存中取出
- 消费字符 : 移除
<
,剩余/p>
。
- 当前字符 :
-
标签开始状态 (tagOpen) - 再次进入
- 当前字符 :
/
- 动作 : 检测到
/
,判定为结束标签。状态切换为tagEnd
。 - 消费字符 : 移除
/
,剩余p>
。
- 当前字符 :
-
结束标签状态 (tagEnd)
- 当前字符 :
p
- 动作 : 检测到字母,判定为结束标签的名称。状态切换为
tagEndName
。 - 操作 : 将
p
存入临时缓存。 - 消费字符 : 移除
p
,剩余>
。
- 当前字符 :
-
结束标签名称状态 (tagEndName)
- 当前字符 :
>
- 动作 : 检测到
>
,标志着结束标签的结束。 - 操作 :
- 从缓存中取出
p
,创建tagEnd
类型的 Token。 - 状态回到
initial
。
- 从缓存中取出
- 消费字符 : 移除
>
,字符串为空。
- 当前字符 :
至此,解析完成。我们得到了一个清晰的 Token 数组:
javascript
[
{ type: 'tag', tagName: 'p' }, // 开始标签
{ type: 'text', content: 'Vue' }, // 文本内容
{ type: 'tagEnd', tagName: 'p' } // 结束标签
]
以下是上述逻辑的代码实现:
js
const state = {
initial: 1, // 初始状态
tagOpen: 2, // 标签开始状态
tagName: 3, // 标签名称状态
text: 4, // 文本状态
tagEnd: 5, // 结束标签状态
tagEndName: 6 // 结束标签名称状态
}
function isLetter (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]
switch (currentState) {
// 初始状态
case state.initial:
// 下一个字符是 `<` 进入标签开始状态
if (char === "<") {
currentState = state.tagOpen
// 消费字符串
str = str.slice(1)
} else
// 下一个字符是字母,进入文本状态
if (isLetter(char)) {
currentState = state.text
chars.push(char)
// 消费字符串
str = str.slice(1)
}
break
case state.tagOpen:
// 下一个字符是字母,进入标签名称状态
if (isLetter(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 (isLetter(char) || (char >= '0' && char <= '9')) {
chars.push(char)
str = str.slice(1)
} else
// 下一个字符是 `>` 说明一个词法已经解析完毕,进入初始状态
if (char === '>') {
currentState = state.initial
// 创建token
tokens.push({
type: 'tag',
tagName: chars.join('')
})
// 重置字符缓存数组
chars.length = 0
str = str.slice(1)
}
break
case state.text:
// 下一个字符是字母保持状态继续消费字符
if (isLetter(char)) {
chars.push(char)
str = str.slice(1)
} else
// 下一个字符是 `<` 说明一个词法已经解析完毕,进入初始状态
if (char === "<") {
currentState = state.tagOpen
// 创建token
tokens.push({
type: 'text',
content: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break
case state.tagEnd:
if (isLetter(char)) {
currentState = state.tagEndName
chars.push(char)
str = str.slice(1)
}
break
case state.tagEndName:
// 结束标签名也可以包含字母和数字
if (isLetter(char) || (char >= '0' && char <= '9')) {
chars.push(char)
str = str.slice(1)
} else
// 下一个字符是 `>` 说明一个词法已经解析完毕,进入初始状态
if (char === ">") {
currentState = state.initial
// 创建token
tokens.push({
type: 'tagEnd',
tagName: chars.join('')
})
chars.length = 0
str = str.slice(1)
}
break
}
}
return tokens
}
语法分析
得到线性的 Token 流之后,下一步就是将它构造成一棵具有层级关系的模板抽象语法树 (AST)。
这个过程,我们需要一个"栈"来辅助。模板天然的嵌套结构(如 <div>
内嵌 <p>
)与栈"先进后出"的特性完美契合。当遇到一个开始标签(如 <div>
),我们将其对应的 AST 节点入栈 ,表示我们进入了该元素的上下文;当遇到结束标签(如 </div>
),我们再将其出栈,表示返回上一层。这样,栈顶的元素就永远代表当前正在处理的节点的父节点。
假设有如下模板:
html
<div><p>vue</p><p>Template</p></div>
我们期望生成一个简化的 AST:
js
{
"type": "Root",
"children": [
{
"type": "element",
"tag": "div",
"children": [
{
"type": "element",
"tag": "p",
"children": [
{
"type": "text",
"content": "vue"
}
]
},
{
"type": "element",
"tag": "p",
"children": [
{
"type": "text",
"content": "Template"
}
]
}
]
}
]
}
上一节的 tokenize
函数会输出以下 Token 数组:
js
[
{ type: 'tag', tagName: 'div' },
{ type: 'tag', tagName: 'p' },
{ type: 'text', content: 'vue' },
{ type: 'tagEnd', tagName: 'p' },
{ type: 'tag', tagName: 'p' },
{ type: 'text', content: 'Template' },
{ type: 'tagEnd', tagName: 'p' },
{ type: 'tagEnd', tagName: 'div' }
]
接下来,我们遍历这个数组,并借助栈来构建 AST:
-
处理
{ type: 'tag', tagName: 'div' }
-
当前栈 :
[Root]
-
父节点 :
Root
-
动作 :
- 创建
div
元素节点。 - 将其添加到父节点(
Root
)的children
中。 - 将
div
节点入栈,stack
变为[Root, div]
。
- 创建
-
AST 状态 :
javascript{ type: 'Root', children: [ { type: 'element', tag: 'div', children: [] } ] }
-
-
处理
{ type: 'tag', tagName: 'p' }
- 当前栈 :
[Root, div]
- 父节点 :
div
- 动作 :
- 创建
p
元素节点。 - 将其添加到父节点(
div
)的children
中。 - 将
p
节点入栈,stack
变为[Root, div, p]
。
- 创建
- 当前栈 :
-
处理
{ type: 'text', content: 'vue' }
- 当前栈 :
[Root, div, p]
- 父节点 :
p
- 动作 :
- 创建文本节点。
- 将其添加到父节点(
p
)的children
中。栈状态不变。
- 当前栈 :
-
处理
{ type: 'tagEnd', tagName: 'p' }
- 当前栈 :
[Root, div, p]
- 动作 :
- 栈顶元素
p
与结束标签p
匹配。 p
节点出栈,stack
变为[Root, div]
。
- 栈顶元素
- 当前栈 :
-
处理
{ type: 'tag', tagName: 'p' }
(第二个 p 标签)- 当前栈 :
[Root, div]
- 父节点 :
div
- 动作 :
- 创建新的
p
元素节点。 - 将其添加到父节点(
div
)的children
中。 - 新
p
节点入栈,stack
变为[Root, div, p]
。
- 创建新的
- 当前栈 :
-
处理
{ type: 'text', content: 'Template' }
- 当前栈 :
[Root, div, p]
- 父节点 :
p
- 动作 :
- 创建文本节点,并添加到父节点(
p
)的children
中。
- 创建文本节点,并添加到父节点(
- 当前栈 :
-
处理
{ type: 'tagEnd', tagName: 'p' }
- 当前栈 :
[Root, div, p]
- 动作 :
- 栈顶
p
节点出栈,stack
变为[Root, div]
。
- 栈顶
- 当前栈 :
-
处理
{ type: 'tagEnd', tagName: 'div' }
- 当前栈 :
[Root, div]
- 动作 :
- 栈顶
div
节点出栈,stack
变为[Root]
。
- 栈顶
- 当前栈 :
遍历结束,AST 构建完成。下面,我们将上述逻辑转化为代码:
js
function parse (template) {
const tokens = tokenize(template)
const stack = []
const ast = {
type: 'Root',
children: []
}
stack.push(ast)
while (tokens.length) {
const token = tokens.shift()
const parent = stack[stack.length - 1]
if (token.type === 'tag') {
const node = {
type: 'element',
tag: token.tagName,
children: []
}
stack.push(node)
parent.children.push(node)
}
if (token.type === 'tagEnd') {
if (parent.type === 'element' && parent.tag === token.tagName) {
stack.pop()
}
}
if (token.type === 'text') {
parent.children.push({
type: 'text',
content: token.content,
})
}
}
return ast
}
const ast = parse(template)
运行以上代码,我们就能得到一颗与预期相符的简易模板 AST:
json
{
"type": "Root",
"children": [
{
"type": "element",
"tag": "div",
"children": [
{
"type": "element",
"tag": "p",
"children": [
{
"type": "text",
"content": "vue"
}
]
},
{
"type": "element",
"tag": "p",
"children": [
{
"type": "text",
"content": "Template"
}
]
}
]
}
]
}
转换与生成
拿到模板 AST 之后,transform
和 generate
阶段的目标是:
- 转换 (Transform) :将模板 AST 进一步转换成更适合生成渲染函数的 JavaScript AST。这个过程还会进行各种优化,但在此我们聚焦于结构转换本身。
- 生成 (Generate):将 JavaScript AST 最终拼接成可执行的渲染函数代码字符串。
模板 AST 的遍历与转换
要转换 AST,我们首先需要一套能够遍历 并操作树中任意节点的机制。
1. 深度优先遍历
AST 本质是一棵树,最自然的遍历方式是深度优先搜索 (DFS)。我们可以轻松写出一个基础的遍历函数:
js
function traverseNode (node) {
console.log(`node type:${node.type}`)
if (node.children) {
for (const childNode of node.children) {
traverseNode(childNode)
}
}
}
为了更直观地感受树的层级,我们可以实现一个 dump
函数来可视化这棵树:
js
function dump (node, indent = 0) {
const type = node.type
const desc = type === 'Root' ? '' : (type === 'element' ? node.tag : node.content)
console.log(`${'-'.repeat(indent)}${type}: ${desc}`)
if (node.children) {
for (const childNode of node.children) {
dump(childNode, indent + 2)
}
}
}
运行 dump(ast)
,输出结果清晰地展示了 AST 的结构:
makefile
Root:
--element: div
----element: p
------text: vue
----element: p
------text: Template
2. 引入访问者模式
简单的遍历只能读取节点,但我们的目标是转换它。一个直接的想法是在遍历时加入转换逻辑:
js
function traverseNode (node) {
// 转换 p -> h1
if (node.type === 'element' && node.tag === 'p') {
node.tag = 'h1'
}
// ... 其他转换
if (node.children) {
// ...
}
}
但这种方式很快会遇到瓶颈:
- 缺乏上下文 :如果转换逻辑依赖父节点(例如,"仅当父节点是
<p>
时,才转换文本内容"),当前节点并不知道它的父亲是谁。 - 操作受限:除了修改,我们可能还想替换或删除节点,这在简单的递归中难以管理。
- 逻辑耦合 :所有转换逻辑都堆在
traverseNode
中,难以维护和扩展。
为了解决这些问题,我们引入一种强大的设计模式------访问者模式 (Visitor Pattern)。
核心思想是将数据结构(AST)与作用于数据的操作(转换逻辑)解耦 。我们将创建一个 context
对象来维护遍历的上下文(如当前节点、父节点等),并把所有转换函数统一管理。
3. 设计遍历上下文 (Context)
我们的 context
需要包含:
- 节点信息:
currentNode
、parent
、childIndex
。 - 节点操作:
replaceNode
、removeNode
等方法。 - 转换函数集:一个
transforms
数组,用于存放所有的转换逻辑。
js
const context = {
currentNode: null,
parent: null,
childIndex: 0,
transforms: [
/* ... 转换函数 ... */
],
replaceNode(node) {
this.currentNode = node
this.parent.children[this.childIndex] = node
},
removeNode() {
if (this.parent) {
this.parent.children.splice(this.childIndex, 1)
this.currentNode = null
}
}
}
4. 强大的转换函数
每个转换函数都接收 node
和 context
作为参数。我们还约定,转换函数可以返回一个退出回调 。这个回调会在当前节点的所有子节点都处理完毕后,再"退出"当前节点时执行。这种自底向上的执行顺序,为处理需要依赖子节点信息的逻辑提供了绝佳的时机。
例如,我们要实现两个需求:
- 当文本节点的父节点是
<p>
时,将其内容替换为'react'
。 - 当
<p>
标签的子节点包含文本'vue'
时,删除该<p>
标签。
js
// 转换函数 1: 替换文本
function transformReactText(node, context) {
if (node.type === 'text' && context.parent.tag === 'p') {
// 使用 context.replaceNode 进行替换
context.replaceNode({ type: 'text', content: 'react' })
// 返回一个退出回调
return () => {
console.log('执行了 react 文本替换的退出回调')
}
}
}
// 转换函数 2: 删除 p 标签
function transformPTag(node, context) {
if (node.type === 'element' && node.tag === 'p') {
const hasVueText = node.children.some(
n => n.type === 'text' && n.content === 'vue'
)
if (hasVueText) {
// 使用 context.removeNode 进行删除
context.removeNode()
return () => {
console.log('执行了 p 标签删除的退出回调')
}
}
}
}
5. 实现 traverseNode
现在,我们重写 traverseNode
来调度这一切。
js
function traverseNode(node, context) {
context.currentNode = node
const exitCallbacks = []
// 1. 执行所有转换函数,并收集退出回调
for (const transform of context.transforms) {
const onExit = transform(context.currentNode, context)
if (onExit) {
exitCallbacks.push(onExit)
}
// 如果当前节点被移除了,立即停止后续操作
if (!context.currentNode) {
return
}
}
// 2. 深度遍历子节点
const children = context.currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = context.currentNode
context.childIndex = i
traverseNode(children[i], context)
}
}
// 3. 在退出时执行所有回调函数 (自底向上)
let i = exitCallbacks.length
while (i--) {
exitCallbacks[i]()
}
}
注意 :上面
traverseNode
的实现是一个简化版。在真实的 Vue 编译器中,当转换函数可以删除节点时,直接遍历children
数组会导致索引错乱。一个健壮的实现需要遍历数组的副本,并在每次循环时重新计算当前节点在原数组中的索引,以应对children
数组的动态变化。
至此,我们拥有了一套强大且可扩展的 AST 遍历和转换机制。
定义目标:JavaScript AST
转换的最终目的是生成 JavaScript 代码。因此,我们需要先将模板 AST 转换成一份 JavaScript AST。
为了简化,我们假设 <div><p>vue</p><p>Template</p></div>
最终生成的渲染函数是:
js
function render() {
return h('div', [
h('p', 'vue'),
h('p', 'Template'),
])
}
我们可以再次使用 astexplorer 来查看这段代码对应的 JS AST 结构。它非常庞大,但核心节点类型很清晰:
Program
: 整个程序的根节点。FunctionDeclaration
: 函数声明,包含id
(函数名) 和body
(函数体)。Identifier
: 标识符,如函数名render
或h
。BlockStatement
: 代码块,即{...}
。ReturnStatement
:return
语句。CallExpression
: 函数调用,包含callee
(被调用者) 和arguments
(参数数组)。ArrayExpression
: 数组字面量,包含elements
数组。Literal
: 字面量,如字符串'div'
或数字。
剔除位置信息等枝叶后,我们得到一个简化的目标 AST 结构:
json
{
"type": "Program",
"body": [
{
"type": "FunctionDeclaration",
"id": { "type": "Identifier", "name": "render" },
"params": [],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "CallExpression",
"callee": { "type": "Identifier", "name": "h" },
"arguments": [
{ "type": "Literal", "value": "div" },
{
"type": "ArrayExpression",
"elements": [
{
"type": "CallExpression",
/* ... h('p', 'vue') ... */
},
{
"type": "CallExpression",
/* ... h('p', 'Template') ... */
}
]
}
]
}
}
]
}
}
]
}
实现 AST 转换
目标已明确,工具也已备好。现在,让我们编写具体的转换函数,在遍历模板 AST 的同时,构建出我们期望的 JavaScript AST。
首先,定义一些创建 JS AST 节点的辅助函数:
js
const createLiteral = (value) => ({ type: 'Literal', value })
const createIdentifier = (name) => ({ type: 'Identifier', name })
const createArrayExpression = (elements) => ({ type: 'ArrayExpression', elements })
const createCallExpression = (callee, args) => ({
type: 'CallExpression',
callee: createIdentifier(callee),
arguments: args
})
接下来,我们为不同类型的模板 AST 节点编写转换逻辑。每个转换函数都会在模板 AST 节点上附加一个 jsNode
属性,用于存放转换后的 JS AST 节点。
js
// 转换文本节点
function transformText(node) {
if (node.type !== 'text') return
// 文本节点直接转换为字符串字面量
node.jsNode = createLiteral(node.content)
}
// 转换元素节点
function transformElement(node) {
// 在退出阶段执行,确保所有子节点都已处理完毕
return () => {
if (node.type !== 'element') return
// 1. 创建 h 函数调用
const callExp = createCallExpression('h', [createLiteral(node.tag)])
// 2. 处理子节点作为 h 的第二个参数
if (node.children.length > 0) {
if (node.children.length === 1) {
// 单个子节点,直接作为参数
callExp.arguments.push(node.children[0].jsNode)
} else {
// 多个子节点,包裹在数组中作为参数
const elements = node.children.map(c => c.jsNode)
callExp.arguments.push(createArrayExpression(elements))
}
}
node.jsNode = callExp
}
}
// 转换根节点
function transformRoot(node) {
// 在退出阶段执行,确保根节点的子节点已处理
return () => {
if (node.type !== 'Root') return
// 这是整个程序的 JS AST 根节点
node.jsNode = {
type: "Program",
body: [{
type: "FunctionDeclaration",
id: createIdentifier("render"),
params: [],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
// 根节点的第一个子元素(通常是模板的根元素)的 JS AST
// 就是 return 语句的参数
argument: node.children[0].jsNode
}]
}
}]
}
}
}
最后,我们将所有部分封装进一个 transform
函数中:
js
function transform(ast) {
const context = {
// ... (同上)
transforms: [transformText, transformElement, transformRoot],
// ... (同上)
}
traverseNode(ast, context)
}
执行 transform(ast)
后,ast.jsNode
中就保存了我们期望的完整 JavaScript AST。
实现代码生成
万事俱备,只欠东风。最后一步,就是将这棵精心构建的 JavaScript AST 转换成真正的可执行代码字符串。
这个过程同样是深度优先遍历,针对不同类型的 JS AST 节点,拼接相应的字符串。
js
function generate(node) {
const context = {
code: '',
push(str) {
this.code += str
}
}
genNode(node, context)
return context.code
}
function genNode(node, context) {
switch (node.type) {
case "Program":
node.body.forEach(n => genNode(n, context))
break
case "FunctionDeclaration":
context.push(`function ${node.id.name}(`) // params (omitted)
context.push(`)`)
genNode(node.body, context)
break
case "BlockStatement":
context.push(`{`)
node.body.forEach(n => genNode(n, context))
context.push(`}`)
break
case "ReturnStatement":
context.push(`return `)
genNode(node.argument, context)
break
case "CallExpression":
genNode(node.callee, context)
context.push(`(`)
node.arguments.forEach((arg, i) => {
genNode(arg, context)
if (i < node.arguments.length - 1) {
context.push(`,`)
}
})
context.push(`)`)
break
case "ArrayExpression":
context.push(`[`)
node.elements.forEach((el, i) => {
genNode(el, context)
if (i < node.elements.length - 1) {
context.push(`,`)
}
})
context.push(`]`)
break
case "Identifier":
context.push(node.name)
break
case "Literal":
context.push(`"${node.value}"`)
break
}
}
最终章:compile
现在,我们将 parse
、transform
、generate
三个阶段串联起来,构成我们编译器的最终形态:
js
function compile(template) {
// 1. 解析 (Parse)
const ast = parse(template)
// 2. 转换 (Transform)
transform(ast)
// 3. 生成 (Generate)
const code = generate(ast.jsNode)
return code
}
// 让我们见证奇迹!
const template = `<div><p>vue</p><p>Template</p></div>`
const code = compile(template)
console.log(code)
运行代码,控制台将输出我们最终的劳动成果,一个完整的渲染函数:
function render(){return h("div",[h("p","vue"),h("p","Template")])}
进阶:更优的解析器
在本文中,为了清晰地展示"词法分析"和"语法分析"两个阶段,我们先将模板转换成 Token 流,再将 Token 流组装成 AST。
实际上,一个更高效的实现方式是将这两个步骤合二为一 。我们可以不生成中间的 Token 数组,而是编写一个递归下降解析器,在消耗模板字符串的同时,直接自顶向下地构建 AST。这种方法通常性能更优,也是许多现代解析器的选择。
Vue 的模板解析器正是采用了这种策略。它通过前进、后退地扫描模板字符串,精确地解析标签、属性、文本和指令,并直接构造出模板 AST。
这个主题足以写成另一篇文章。如果大家的求知欲已被点燃,不妨参阅霍春阳老师原著的相关章节,或直接挑战对应的源码实现,相信大家会有更深的领悟。
临门一脚:h 函数与虚拟 DOM
我们已经成功生成了目标代码:function render(){return h("div",[h("p","vue"),h("p","Template")])}
。
但大家可能会问:这个神秘的 h
函数究竟是什么?
它正是连接"编译时"与"运行时"的关键桥梁 。h
函数(hyperscript
的缩写,意为"能生成超文本的脚本")是 Vue 运行时提供的一个核心工具。
当 render
函数被执行时,它并不会直接创建真实的 DOM 元素。相反,它会调用 h
函数来创建一种用以"描述"DOM 结构的轻量级 JavaScript 对象。我们称之为 "虚拟 DOM" (Virtual DOM Node,简称 VNode)。
例如,h('p', 'vue')
的执行结果可能是一个类似 { tag: 'p', children: 'vue' }
的 VNode 对象。
因此,渲染函数的执行结果,就是一整棵由 VNode 组成的虚拟 DOM 树。Vue 的"运行时"系统在拿到这棵树后,会通过高效的 Diff 算法,将其与上一次渲染的 VNode 树进行比对,计算出最少的变更,然后才精确地更新到真实的浏览器 DOM 上。
至此,从模板到页面的完整链路被彻底打通:
模板字符串
-> 编译器
-> 渲染函数
-> h()
-> 虚拟DOM树
-> 运行时/渲染器
-> 真实DOM
结语
今天,我们一同打开了 Vue 模板编译的"黑箱"。尽管我们实现的编译器非常迷你,但它完整地贯穿了"解析-转换-生成"这一核心思想。希望通过这次亲手实践,大家不仅收获了可运行的代码,更能领会其背后的设计哲学。
一个生产级的模板编译器远比我们实现的要复杂,它需要处理指令、动态属性、组件、插槽等众多细节。但万变不离其宗,愿本文能成为大家深入探索 Vue 源码世界的一块坚实敲门砖。
如果大家意犹未尽,不妨尝试阅读Vue源码,体验生产级别的代码。编译原理的世界广阔而有趣,后续还有相关的文章~
我是小林,我们下期再见!