编译技术是一门庞大的学科,我们无法用几个章节对其做完善的讲解。
但作为前端工程师,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。
其中,实现公式计算器甚至只涉及编译前端技术,而领域特定语言根据其具体使用场景和目标平台的不同,难度会有所不同。
Vue.js 的模板和 JSX 都属于领域特定语言,它们的实现难度属于中、低级别,只要掌握基本的编译技术理论即可实现这些功能。
15.1 模板 DSL 的编译器
编译器是一个将源代码(语言 A)翻译为目标代码(语言 B)的程序。
一个完整的编译流程涵盖了词法分析、语法分析、语义分析、中间代码生成、优化和目标代码生成等环节。
编译前端它通常与目标平台无关,仅负责分析源代码。
编译后端则通常与目标平台有关,并不一定会包含中间代码生成和优化这两个环节,这取决于具体的场景和实现。
中间代码生成和优化这两个环节有时也叫"中端"。
针对 Vue.js 的模板编译器,源代码是组件的模板,目标代码是浏览器或其他可以运行的 JavaScript 代码的平台:
可以看到,Vue.js 模板编译器的目标代码其实就是渲染函数。
过程中 Vue.js 的模板编译器首先对模板进行词法和语法分析,得到模板 AST。
然后,将模板AST 转换(transform)成 JavaScript AST。
最后,根据 JavaScript AST 生成JavaScript 代码,即渲染函数代码。
AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。
所谓模板 AST,其实就是用来描述模板的抽象语法树。例如,考虑以下模板:
html
<div>
<h1 v-if="ok">Vue Template</h1>
</div>
该模板被编译成如下 AST:
javascript
const ast = {
// 逻辑根节点
type: 'Root',
children: [
// div 标签节点
{
type: 'Element',
tag: 'div',
children: [
// h1 标签节点
{
type: 'Element',
tag: 'h1',
props: [
// v-if 指令节点
{
type: 'Directive', // 类型为 Directive 代表指令
name: 'if', // 指令名称为 if,不带有前缀 v-
exp: {
// 表达式节点
type: 'Expression',
content: 'ok'
}
}
]
}
]
}
]
}
其中,AST 仅是一个具有层级结构的对象。其具有与模板同构的嵌套结构。
每一棵 AST 都有一个逻辑上的根节点,其类型为 Root。模板中真正的根节点则作为 Root 节点的 children 存在。
观察上面的 AST,我们可以得出如下结论:
- 不同类型的节点是通过节点的 type 属性进行区分的。例如标签节点的 type 值为 'Element'。
- 标签节点的子节点存储在其 children 数组中。
- 标签节点的属性节点和指令节点会存储在 props 数组中。
- 不同类型的节点会使用不同的对象属性进行描述。例如指令节点拥有 name 属性,用来表达指令的名称,而表达式节点拥有 content 属性,用来描述表达式的内容。
在此基础上,我们可以封装 'parse' 函数对模板进行词法和语法分析,得到模板 AST:
javascript
const template = `
<div>
<h1 v-if="ok">Vue Template</h1>
</div>
`
const templateAST = parse(template)
有了模板 AST 后,我们就可以对其进行语义分析,并对模板 AST 进行转换了。
例如检查 'v-else' 指令是否存在匹配的 'v-if' 指令,或属性值是否为静态的。
在语义分析后,我们将模板 AST 转换为 JavaScript AST。
因为 Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是 JavaScript 代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript AST。
javascript
const templateAST = parse(template)
const jsAST = transform(templateAST)
在得到 JavaScript AST 后,我们可以根据它生成渲染函数,这可以通过 'generate' 函数完成:
javascript
const templateAST = parse(template)
const jsAST = transform(templateAST)
const code = generate(jsAST)
以上就是 Vue.js 模板编译为渲染函数的完整流程。
在上面这段代码中,generate 函数会将渲染函数的代码以字符串的形式返回,并存储在 code 常量中。
下图描绘将 Vue.js 模板编译为渲染函数的完整流程:
15.2 parser 的实现原理与状态机
在上一节中,我们讲解了 Vue.js 模板编译器的基本结构和工作流程,它主要由三个部分组成:
- 用来将模板字符串解析为模板 AST 的解析器(parser)。
- 用来将模板 AST 转换为 JavaScript AST 的转换器(transformer)
- 用来根据 JavaScript AST 生成渲染函数代码的生成器(generator)。
本节,我们将详细讨论解析器 parser 的实现原理。
解析器接收字符串模板作为输入,逐字符阅读字符串模板,根据特定规则将字符串划分为词法记号(Token)。例如,解析以下模板:
vue
<p>Vue</p>
解析器将其切割为三个 Token:
- 开头标签
**<p>**
。 - 文本节点
**Vue**
。 - 结束标签
**</p>**
。
那么,解析器如何切割模板?哪些规则起作用?答案在于有限状态机。
有限状态机意味着解析器根据输入字符自动在有限个状态间转换。
以上面的模板为例,parse 函数会逐个读取字符,解析过程如下:
- 初始于"状态1"(初始状态)。
- 在"状态1"下,读取第一个字符 <,转移至"状态2"(标签开始)。
- 在"状态2"下,读取字符 p,由于其为字母,转移至"状态3"(标签名称)。
- 在"状态3"下,读取字符 > ,回到"状态1",记录标签名称 p。
- 在"状态1"下,读取字符 V,转移至"状态4"(文本)。
- 在"状态4"下,读取字符直到遇到 < ,再次转至"状态2",记录文本内容 Vue。
- 在"状态2"下,读取字符 /,进入"状态5"(结束标签)。
- 在"状态5"下,读取字符 p,进入"状态6"(结束标签名称)。
- 在"状态6"下,读取字符 >,回到"状态1",记录结束标签名称。
如此,经过一系列状态迁移,我们得到了所需的 Token。在状态迁移图中,有的圆圈是单线的,而有的圆圈是双线的。双线圆圈代表合法 Token 的状态。
按照有限状态自动机的状态迁移过程,我们可以很容易地编写对应的代码实现。
因此,有限状态自动机可以帮助我们完成对模板的标记化(tokenized),最终我们将得到一系列 Token。上图中描述的状态机的实现如下:
javascript
// 定义状态机的状态
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')
}
// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
// switch 语句匹配当前状态
switch (currentState) {
// 状态机当前处于初始状态
case State.initial:
// 遇到字符 <
if (char === '<') {
// 1. 状态机切换到标签开始状态
currentState = State.tagOpen
// 2. 消费字符 <
str = str.slice(1)
} else if (isAlpha(char)) {
// 1. 遇到字母,切换到文本状态
currentState = State.text
// 2. 将当前字母缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前处于标签开始状态
case State.tagOpen:
if (isAlpha(char)) {
// 1. 遇到字母,切换到标签名称状态
currentState = State.tagName
// 2. 将当前字符缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
} else if (char === '/') {
// 1. 遇到字符 /,切换到结束标签状态
currentState = State.tagEnd
// 2. 消费字符 /
str = str.slice(1)
}
break
// 状态机当前处于标签名称状态
case State.tagName:
if (isAlpha(char)) {
// 1. 遇到字母,由于当前处于标签名称状态,所以不需要切换状态,
// 但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '>') {
// 1.遇到字符 >,切换到初始状态
currentState = State.initial
// 2. 同时创建一个标签 Token,并添加到 tokens 数组中
// 注意,此时 chars 数组中缓存的字符就是标签名称
tokens.push({
type: 'tag',
name: chars.join(''),
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 同时消费当前字符 >
str = str.slice(1)
}
break
// 状态机当前处于文本状态
case State.text:
if (isAlpha(char)) {
// 1. 遇到字母,保持状态不变,但应该将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '<') {
// 1. 遇到字符 <,切换到标签开始状态
currentState = State.tagOpen
// 2. 从 文本状态 --> 标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组
// 注意,此时 chars 数组中的字符就是文本内容
tokens.push({
type: 'text',
content: chars.join(''),
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前处于标签结束状态
case State.tagEnd:
if (isAlpha(char)) {
// 1. 遇到字母,切换到结束标签名称状态
currentState = State.tagEndName
// 2. 将当前字符缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前处于结束标签名称状态
case State.tagEndName:
if (isAlpha(char)) {
// 1. 遇到字母,不需要切换状态,但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '>') {
// 1. 遇到字符 >,切换到初始状态
currentState = State.initial
// 2. 从 结束标签名称状态 --> 初始状态,应该保存结束标签名称 Token
// 注意,此时 chars 数组中缓存的内容就是标签名称
tokens.push({
type: 'tagEnd',
name: chars.join(''),
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 消费当前字符
str = str.slice(1)
}
break
}
}
// 最后,返回 tokens
return tokens
}
上面代码可优化的点非常多。实际上,我们可以通过正则表达式来精简 tokenize 函数的代码。
正则表达式本质就是有限自动机 。编写正则表达式的时候,其实就是在编写有限自动机。
使用上面给出的 tokenize 函数来解析模板 Vue,我们 将得到三个 Token:
javascript
const tokens = tokenize(`<p>Vue</p>`)
// [
// { type: 'tag', name: 'p' }, // 开始标签
// { type: 'text', content: 'Vue' }, // 文本节点
// { type: 'tagEnd', name: 'p' } // 结束标签
// ]
我们现在明白模板编译器如何将模板字符串切割为一个个 Token 的过程。
但是我们并非总是需要所有 Token。例如,在解析模板的过程中,结束标签 Token 可以省略。这都取决于具体需求灵活实现。
通过有限自动机,我们能够将模板解析为一个个 Token,进而可以用它们构建一棵 AST 了。
15.3 构建 AST
不同编译器可能存在差异,但是他们的共性则是会将源代码转换成目标代码
但是,不同编译器实现思路可能完全不同,这其中可能就包括 AST 的构造方式。
对于通用编程语言(GPL),例如 JavaScript 这类脚本语言,构建 AST 通常使用递归下降算法,需要解决一些复杂问题,比如运算符优先级。
然而,对于 DSL,如 Vue.js 模板,由于没有运算符,不存在运算符优先级问题。
DSL 与 GPL 的区别在于,GPL 是图灵完备的,我们可以用 GPL 实现 DSL;而 DSL 不要求图灵完备,只需满足特定用途即可。
为 Vue.js 模板构造 AST 相对简单。
HTML 是一种标记语言,格式非常固定,标签之间天然嵌套形成父子关系,因此树型结构 AST 能较好描述 HTML 结构:
html
<div>
<p>Vue</p>
<p>Template</p>
</div>
这段模板的 AST 设计为:
javascript
const ast = {
// AST 的逻辑根节点
type: 'Root',
children: [
// 模板的 div 根节点
{
type: 'Element',
tag: 'div',
children: [
// div 节点的第一个子节点 p
{
type: 'Element',
tag: 'p',
// p 节点的文本节点
children: [
{
type: 'Text',
content: 'Vue',
},
],
},
// div 节点的第二个子节点 p
{
type: 'Element',
tag: 'p',
// p 节点的文本节点
children: [
{
type: 'Text',
content: 'Template',
},
],
},
],
},
],
}
你会发现,AAST 在结构上与模板是"同构"的,它们都具有树型结构:
了解了 AST 的结构后,我们需要使用模板解析出的 Token 构造出这样一棵 AST。首先,使用 tokenize 函数将模板标记化。这段模板的 tokens 如下:
javascript
const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)
// 执行后的 tokens
const tokens = [
{ type: 'tag', name: 'div' }, // div 开始标签节点
{ type: 'tag', name: 'p' }, // p 开始标签节点
{ type: 'text', content: 'Vue' }, // 文本节点
{ type: 'tagEnd', name: 'p' }, // p 结束标签节点
{ type: 'tag', name: 'p' }, // p 开始标签节点
{ type: 'text', content: 'Template' }, // 文本节点
{ type: 'tagEnd', name: 'p' }, // p 结束标签节点
{ type: 'tagEnd', name: 'div' }, // div 结束标签节点
]
构建 AST 实际上就是扫描 Token 列表的过程。
我们从第一个 Token 开始,依次扫描整个 Token 列表,直到所有 Token 都被处理。
在此过程中,我们需要维护一个栈 elementStack,这个栈将用于维护元素间的父子关系。
每遇到一个开始标签节点,我们构造一个 Element 类型的 AST 节点并将其入栈。
当遇到结束标签节点时,弹出当前栈顶节点。
这样,栈顶节点始终充当父节点的角色。所有扫描过的节点都会作为当前栈顶节点的子节点,添加到栈顶节点的 children 属性下。
还是拿上例来说,下图给出了在扫描 Token 列表之前,Token 列表、父级元素栈和 AST 三者的状态:
上图中左侧的是 Token 列表,我们将会按照从上到下的顺序扫描 Token 列表。
中间和右侧分别是栈 elementStack 的状态和 AST 的状态。可以看到,它们最初都只有 Root 根节点。
接着,我们对 Token 列表进行扫描。首先,扫描到第一个 Token,即"开始标签(div)":
由于当前扫描到的 Token 是一个开始标签节点,因此我们创建一个类型为 Element 的 AST 节点 Element(div),然后将该节点作为当前栈顶节点的子节点。
由于当前栈顶节点是 Root 根节点,所以我们将新建的 Element(div) 节点作为 Root 根节点的子节点添加到 AST 中,最后将新建的 Element(div) 节点压入 elementStack 栈。
接着,我们扫描下一个 Token:
扫描到的第二个 Token 也是一个开始标签节点,于是我们再创建一个类型为 Element 的 AST 节点 Element(p),然后将该节点作为当前栈顶节点的子节点。
由于当前栈顶节点为 Element(div) 节点,所以我们将新建的 Element(p) 节点作为 Element(div) 节点的子节点添加到 AST 中,最后将新建的 Element(p) 节点压入 elementStack 栈。
接着,我们扫描下一个 Token:
扫描到的第三个 Token 是一个文本节点,于是我们创建一个类型为 Text 的 AST 节点 Text(Vue),然后将该节点作为当前栈顶节点的子节点。
由于当前栈顶节点为 Element(p) 节点,所以我们将新建的 Text(p) 节点作为 Element(p)节点的子节点添加到 AST 中。
接着,扫描下一个 Token:
此时扫描到的 Token 是一个结束标签,所以我们需要将栈顶的 Element(p)节点从 elementStack 栈中弹出。
接着,扫描下一个 Token:
此时扫描到的 Token 是一个开始标签。我们为它新建一个 AST 节点 Element(p),并将其作为当前栈顶节点 Element(div) 的子节点。最后,将Element(p) 压入 elementStack 栈中,使其成为新的栈顶节点。
接着,扫描下一个 Token:
此时扫描到的 Token 是一个文本节点,所以只需要为其创建一个 相应的 AST 节点 Text(Template) 即可,然后将其作为当前栈顶节点 Element(p) 的子节点添加到 AST 中。
接着,扫描下一个 Token:
此时扫描到的 Token 是一个结束标签,于是我们将当前的栈顶节 点 Element(p) 从 elementStack 栈中弹出。
接着,扫描下一个 Token:
此时,扫描到了最后一个 Token,它是一个 div 结束标签,所以我们需要再次将当前栈顶节点 Element(div) 从 elementStack 栈中弹出。
至此,所有 Token 都被扫描完毕,AST 构建完成。如下图所示:
扫描 Token 列表并构建 AST 的具体实现如下:
javascript
// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str)
// 创建 Root 根节点
const root = {
type: 'Root',
children: [],
}
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root]
// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
while (tokens.length) {
// 获取当前栈顶节点作为父节点 parent
const parent = elementStack[elementStack.length - 1]
// 当前扫描的 Token
const t = tokens[0]
switch (t.type) {
case 'tag':
// 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
const elementNode = {
type: 'Element',
tag: t.name,
children: [],
}
// 将其添加到父级节点的 children 中
parent.children.push(elementNode)
// 将当前节点压入栈
elementStack.push(elementNode)
break
case 'text':
// 如果当前 Token 是文本,则创建 Text 类型的 AST 节点
const textNode = {
type: 'Text',
content: t.content,
}
// 将其添加到父节点的 children 中
parent.children.push(textNode)
break
case 'tagEnd':
// 遇到结束标签,将栈顶节点弹出
elementStack.pop()
break
}
// 消费已经扫描过的 token
tokens.shift()
}
// 最后返回 AST
return root
}
javascript
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
运行上面代码,会得到和本节开头给出 AST 结果,但是还有很多问题没处理,例如自闭合标签,在16章详细讲解。
15.4 AST 的转换与插件化架构
AST 的转换,指的是对 AST 进行一系列操作,将其转换为新的 AST 的过程。
新的 AST 可以是原语言或原 DSL 的描述,也可以是其他语言或其他 DSL 的描述。
例如,我们可以对模板 AST 进行操作,将其转换为JavaScript AST。
转换后的 AST 可以用于代码生成。这其实就是 Vue.js 的模板编译器将模板编译为渲染函数的过程:
上面 transform 函数就是用来完成 AST 转换工作的。
15.4.1 节点的访问
如果要对 AST 进行转换,我们应该要能遍历到其每一个节点,这样才更好操作特定节点。
由于 AST 是树型数据结构,所以我们需要编写一个深度优先的遍历算法,从而实现对 AST 中节点的访问。
不过,在开始编写转换代码之前,我们有必要编写一个 dump 工具函数,用来打印当前 AST 中节点的信息:
javascript
function dump(node, indent = 0) {
// 节点的类型
const type = node.type
// 节点的描述,如果是根节点,则没有描述
// 如果是 Element 类型的节点,则使用 node.tag 作为节点的描述
// 如果是 Text 类型的节点,则使用 node.content 作为节点的描述
const desc = node.type === 'Root' ? '' : node.type === 'Element' ? node.tag : node.content
// 打印节点的类型和描述信息
console.log(`${'-'.repeat(indent)}${type}: ${desc}`)
// 递归地打印子节点
if (node.children) {
node.children.forEach(n => dump(n, indent + 2))
}
}
我们沿用上一节例子,查看 dump 函数会输出什么结果:
javascript
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
console.log(dump(ast))
运行上面这段代码,将得到如下输出:
javascript
Root:
--Element: div
----Element: p
------Text: Vue
----Element: p
------Text: Template
接下来,我们实现对 AST 的节点访问,即从根节点开始深度遍历:
javascript
function traverseNode(ast) {
// 当前节点,ast 本身就是 Root 节点
const currentNode = ast
// 如果有子节点,则递归地调用 traverseNode 函数进行遍历
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}
有了 traverseNdoe 函数之后,我们即可实现对 AST 中节点的访问。
例如,我们可以实现一个转换功能,将 AST 中所有 p 标签转换为 h1 标签:
javascript
function traverseNode(ast) {
// 当前节点,ast 本身就是 Root 节点
const currentNode = ast
// 对当前节点进行操作
if (currentNode.type === 'Element' && currentNode.tag === 'p') {
// 将所有 p 标签转换为 h1 标签
currentNode.tag = 'h1'
}
// 如果有子节点,则递归地调用 traverseNode 函数进行遍历
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}
上述代码,我们通过检查当前节点的 type 属性和 tag 属性,来确保被操作的节点是 p 标签。
然后将符合条件的节点的 tag 属性变为 'h1',我们可以使用 dump 函数打印转换后的 AST 的信息:
javascript
// 封装 transform 函数,用来对 AST 进行转换
function transform(ast) {
// 调用 traverseNode 完成转换
traverseNode(ast)
// 打印 AST 信息
console.log(dump(ast))
}
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
运行上面这段代码,我们将得到如下输出:
javascript
Root:
--Element: div
----Element: h1
------Text: Vue
----Element: h1
------Text: Template
可以看到,所有 p 标签都已经变成了 h1 标签。
我们还可以对 AST 进行其他转换。例如,实现一个转换,将文本节点的内容重复两次:
javascript
function traverseNode(ast) {
// 当前节点,ast 本身就是 Root 节点
const currentNode = ast
// 对当前节点进行操作
if (currentNode.type === 'Element' && currentNode.tag === 'p') {
// 将所有 p 标签转换为 h1 标签
currentNode.tag = 'h1'
}
// 如果节点的类型为 Text
if (currentNode.type === 'Text') {
// 重复其内容两次,这里我们使用了字符串的 repeat() 方法
currentNode.content = currentNode.content.repeat(2)
}
// 如果有子节点,则递归地调用 traverseNode 函数进行遍历
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}
上述代码,我们一旦检测到当前节点类型为 Text 类型,则调用 repeat(2) 方法将文本节点的内容重复两次,最终得到如下输出:
javascript
Root:
--Element: div
----Element: h1
------Text: VueVue
----Element: h1
------Text: TemplateTemplate
可以看到,文本内容被重复了两次。
接下来我们对 traverseNode 函数使用回调函数方式进行解耦:
javascript
// 接收第二个参数 context
function traverseNode(ast, context) {
const currentNode = ast
// context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
transforms[i](currentNode, context)
}
const children = currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
traverseNode(children[i], context)
}
}
}
上述代码,我们首先为 traverseNode 函数增加了第二个参数 context(下文介绍)。
接着将回调函数存储到 transforms 数组,然后遍历该数组执行其中的函数,并将 currentNode 和 context 作为参数传递。
有了修改后的 traverseNode 函数,我们可以如下所示使用它:
javascript
function transform(ast) {
// 在 transform 函数内创建 context 对象
const context = {
// 注册 nodeTransforms 数组
nodeTransforms: [
transformElement, // transformElement 函数用来转换标签节点
transformText, // transformText 函数用来转换文本节点
],
}
// 调用 traverseNode 完成转换
traverseNode(ast, context)
// 打印 AST 信息
console.log(dump(ast))
}
上面 transformElement 函数和 transformText 函数的实现如下:
javascript
function transformElement(node) {
if (node.type === 'Element' && node.tag === 'p') {
node.tag = 'h1'
}
}
function transformText(node) {
if (node.type === 'Text') {
node.content = node.content.repeat(2)
}
}
解耦之后,我们只需要编写多个类似的转换函数,将它们注册到 context.nodeTransforms 中即可。可解决 traverseNode 函数可能会过于"臃肿"的问题。
15.4.2 转换上下文与节点操作
上文,我们将转换函数注册到 context.nodeTransforms 数组中,为什么要特意在外面构造层对象呢?直接定义数组不行吗?
这时候,就需要提到 context 的概念了,我们可以把 context 看作程序在某个范围内的"全局变量"。它不是一个具象的东西,而是依赖于具体场景:
- React 中,我们可以使用 React.createContext 函数创建一个上下文对象,该上下文对象允许我们将数据通过组件树一层层传递下去。
- Vue 中,我们通过 provide/inject 等能力,向一整棵组件树提供数据。这些数据可以称为上下文。
- Koa 中,中间件函数接收的 context 参数也是一种上下文对象,所有中间件都可以通过 context 来访问相同的数据。
通过上面三个例子,我们能认识到,上下文对象其实就是程序在某个范围内的"全局变量",同样,我们可以将全局对象看做全局上下文
回到我们的 context.nodeTransforms 数组,所有的 AST 函数同样可以通过 通过 context 来共享数据,该上下文可存储程序的当前状态,比如当前转换的节点,转换节点的父节点,当前节点处于父节点的第几个子节点等等。
所以我们来构造转换上下文信息的函数,如下代码所示:
javascript
function transform(ast) {
const context = {
// 增加 currentNode,用来存储当前正在转换的节点
currentNode: null,
// 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引
childIndex: 0,
// 增加 parent,用来存储当前转换节点的父节点
parent: null,
nodeTransforms: [transformElement, transformText],
}
traverseNode(ast, context)
console.log(dump(ast))
}
上述代码,我们为转换上下文对象扩展了一些重要信息:
- currentNode:用来存储当前正在转换的节点。
- childIndex:用来存储当前节点在父节点的 children 中的位置索引。
- parent:用来存储当前转换节点的父节点。
紧接着我们需要在合适的地方设置转换上下文的数据,如下 traverseNode 函数的代码所示:
javascript
function traverseNode(ast, context) {
// 设置当前转换的节点信息 context.currentNode
context.currentNode = ast
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
transforms[i](context.currentNode, context)
}
const children = context.currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
context.parent = context.currentNode
// 设置位置索引
context.childIndex = i
// 递归地调用时,将 context 透传
traverseNode(children[i], context)
}
}
}
上述代码,在递归调用 traverseNode 函数进行子节点转换之前,我们必须设置 context.parent 和 context.childIndex 的值,以保证接下来递归转换 context 信息的正确。
有了上下文数据后,我们这时如果希望实现节点替换的功能,例如将所有文本节点替换成元素节点。
我们需要在上下文对象中添加 context.replaceNode 函数,该函数接收新的 AST 节点作为参数,并使用新节点替换当前正在转换的节点:
javascript
function transform(ast) {
const context = {
currentNode: null,
parent: null,
// 用于替换节点的函数,接收新节点作为参数
replaceNode(node) {
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndex
// 然后使用新节点替换即可
context.parent.children[context.childIndex] = node
// 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
context.currentNode = node
},
nodeTransforms: [transformElement, transformText],
}
traverseNode(ast, context)
console.log(dump(ast))
}
在上述 replaceNode 函数中,我们首先通过 context.childIndex 属性取得当前节点的位置索引。
然后通过 context.parent.children 取得当前节点所在集合,最后配合使用 context.childIndex 与 context.parent.children 即可完成节点替换。
另外,由于当前节点已经替换为新节点了,所以我们应该使用新节点更新 context.currentNode 属性的值。
接下来,我们可以在转换函数中使用 replaceNode 函数对 AST 中的节点进行替换了,例如我们将文本节点转换为元素节点:
javascript
// 转换函数的第二个参数就是 context 对象
function transformText(node, context) {
if (node.type === 'Text') {
// 如果当前转换的节点是文本节点,则调用 context.replaceNode 函数将其替换为元素节点
context.replaceNode({
type: 'Element',
tag: 'span',
})
}
}
上述函数,首先检查当前转换的节点是否是文本节点,如果是,则调用 context.replaceNode 函数将其替换为新的 span 标签节点。
我们在内部可以使用 context 对象上的任意属性和方法。
下面例子验节点替换功能:
javascript
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
运行上面这段代码,其转换前后的结果分别是:
javascript
// 转换前
Root:
--Element: div
----Element: p
------Text: VueVue
----Element: p
------Text: TemplateTemplate
// 转换后
Root:
--Element: div
----Element: h1
------Element: span
----Element: h1
------Element: span
可以看到转换后的 AST 中的文本节点全部变成 span 标签节点了。
除了替换节点,我们可能还希望移除当前访问的节点,我们可以通过实现context.removeNode 函数来达到目的:
javascript
function transform(ast) {
const context = {
currentNode: null,
parent: null,
replaceNode(node) {
context.currentNode = node
context.parent.children[context.childIndex] = node
},
// 用于删除当前节点。
removeNode() {
if (context.parent) {
// 调用数组的 splice 方法,根据当前节点的索引删除当前节点
context.parent.children.splice(context.childIndex, 1)
// 将 context.currentNode 置空
context.currentNode = null
}
},
nodeTransforms: [transformElement, transformText],
}
traverseNode(ast, context)
console.log(dump(ast))
}
移除当前节点只需要取得当前位置索引 context.childIndex,调用 数组的 splice 方法将其从所属的 children 列表中移除即可。
另外当节点移除,我们也不要忘记将 context.currentNode 的值置空。
当前被移除后,后续转换函数也不再需要处理该节点,我们需调整下 traverseNode 函数:
javascript
function traverseNode(ast, context) {
context.currentNode = ast
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
transforms[i](context.currentNode, context)
// 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
// 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
if (!context.currentNode) return
}
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)
}
}
}
我们增加了一行代码,检查 context.currentNode 是否存在。
由于任何转换函数都可能移除当前访问节点,所以每个转换函数执行完毕,都应检查当前节点是否存在,如果不存在,则直接 return 即可,无需做后续处理。
此时有了 context.removeNode 函数之后,我们实现一个移除文本节点的转换函数:
javascript
function transformText(node, context) {
if (node.type === 'Text') {
// 如果是文本节点,直接调用 context.removeNode 函数将其移除即可
context.removeNode()
}
}
配合上面的 transformText 转换函数,运行下面的用例:
javascript
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
转换前后输出结果是:
javascript
// 转换前
Root:
--Element: div
----Element: p
------Text: VueVue
----Element: p
------Text: TemplateTemplate
// 转换后
Root:
--Element: div
----Element: h1
----Element: h1
在转换后的 AST 中,将不再有任何文本节点。
15.4.3 进入与退出
转换 ast 节点过程中,可能需要等全部子节点转换完毕后,再决定是否对当前节点进行转换,我们目前设计并不支持这种能力。
上文的转换工作流,是一种从根节点开始,顺序执行的工作流:
Root 根节点第一个被处理,节点层次越深,对它的处理将越靠后。
这种顺序执行的问题是,当节点被处理后,意味着父节点早已处理完毕,我们无法回头重新处理父节点。
更理想的转换工作流是:
上图将节点访问分为两个阶段,即进入阶段和退出阶段。
当转换函数处于进入阶段时,它会先进入父节点,再进入子节点。
而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。
这样,只要我们在退出节点阶段对当前访问的节点进行处理,就一定能够保证其子节点全部处理完毕。
我们需要重新设计 traverseNode 转换函数:
javascript
function traverseNode(ast, context) {
context.currentNode = ast
// 1. 增加退出阶段的回调函数数组
const exitFns = []
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的回调函数
const onExit = transforms[i](context.currentNode, context)
if (onExit) {
// 将退出阶段的回调函数添加到 exitFns 数组中
exitFns.push(onExit)
}
if (!context.currentNode) return
}
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)
}
}
// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
上述代码,我们增加一个数组 exitFns,用来存储由转换函数返回的回调函数。
在 traverseNode 函数的最后,执行这些缓存在 exitFns 数组中的回调函数。
这样保证当退出阶段的回调函数执行时,当前访问的节点的子节点已经全部处理过了。
有了这些能力,我们可以将转换逻辑编写在退出阶段的回调函数,保证对当前访问节点进行转换之前,保证其子节点一定被全部处理完毕了:
javascript
function transformElement(node, context) {
// 进入节点
// 返回一个会在退出节点时执行的回调函数
return () => {
// 在这里编写退出节点的逻辑,当这里的代码运行时,当前转换节点的子节点一定处理完毕了
}
}
注意:因为退出阶段的回调函数是反序执行,如果注册多个转换函数,它们注册顺序将决定代码执行结果。
假设我们注册的两个转换函数分别是 transformA 和 transformB:
javascript
function transform(ast) {
const context = {
// 省略部分代码
// 注册两个转换函数,transformA 先于 transformB
nodeTransforms: [transformA, transformB],
}
traverseNode(ast, context)
console.log(dump(ast))
}
上述代码,转换函数 transformA 先注册,进入阶段,transformA 先于 transformB 执行。退出阶段 transformA 晚于 transformB 执行:
javascript
-- transformA 进入阶段执行
---- transformB 进入阶段执行
---- transformB 退出阶段执行
-- transformA 退出阶段执行
这样设计好处是,转换函数 transformA 可等待 transformB 执行完毕后,根据具体情况决定如何工作。
如果 transformA 与 transformB 的顺序调换,那么转换函数执行顺序也将变化:
javascript
-- transformB 进入阶段执行
---- transformA 进入阶段执行
---- transformA 退出阶段执行
-- transformB 退出阶段执行
由此可见,如果将转换逻辑编写在退出阶段,不仅能保证所有子节点被处理完毕,也能保证后续注册的转换函数先执行完毕。
15.5 将模板 AST 转为 JavaScript AST
我们最后需要将模板编译为渲染函数,而渲染函数是 JavaScript 代码描述,因此我们先需要将模板 AST 转换为描述渲染函数的 JavaScript AST。
以上文给出的模板为例:
html
<div>
<p>Vue</p>
<p>Template</p>
</div>
与这段模板等价的渲染函数是:
javascript
function render() {
return h('div', [h('p', 'Vue'), h('p', 'Template')])
}
上面的渲染函数对应的 JavaScript AST 就是我们这节要转换的目标。
那它对应的 JavaScript AST 是什么样呢?
与模板 AST 是模板的描述一样,JavaScript AST 则是 JavaScript 代码的描述,本质上我们需要设计数据结构来描述这段渲染函数。
首先观察上面函数,它是一个函数声明,一个函数声明语句由以下几部分组成:
- id:函数名称,它是一个标识符 Identifier。
- params:函数的参数,它是一个数组。
- body:函数体,由于函数体可以包含多个语句,因此它也是一个数组。
为简化问题,我们不考虑箭头函数、生成器函数、async 函数等情况。
根据以上这些信息,我们就可以设计一个基本的数据结构来描述函数声明语句:
javascript
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
// 函数的名称是一个标识符,标识符本身也是一个节点
id: {
type: 'Identifier',
name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
},
params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
// 渲染函数的函数体只有一个语句,即 return 语句
body: [
{
type: 'ReturnStatement',
return: null, // 暂时留空,在后续讲解中补全
},
],
}
如上代码,我们使用一个对象来描述一个 JavaScript AST 节点。
每个节点都具有 type 字段,该字段用来代表节点的类型。对于函数声明语句来说,它的类型是 FunctionDecl。
接着,我们使用 id 字段来存储函数的名称。函数的名称应该是一个合法的标识符,因此 id 字段本身也是一个类型为 Identifier 的节点。
我们也根据实际可进行调整,例如我们们完全可以将 id 字段设计为一个字符串类型的值。这样做虽然不完全符合 JavaScript 的语义,但是能够满足我们的需求。
对于函数的参数,我们使用 params 数组来存储。目前,我们设计的渲染函数还不需要参数,因此暂时设为空数组。
最后,我们使用 body 字段来描述函数的函数体。一个函数的函数体内可以存在多个语句,所以我们使用一个数组来描述它。该数组内的每个元素都对应一条语句,对于渲染函数来说,目前它只有一个返回语句,所以我们使用一个类型为 ReturnStatement 的节点来描述该返回语句。
我们来看一下渲染函数的返回值。渲染函数返回的是虚拟 DOM 节点,体现在 h 函数的调用。
我们可以使用 CallExpression 类型的节点来描述函数调用语句:
javascript
const CallExp = {
type: 'CallExpression',
// 被调用函数的名称,它是一个标识符
callee: {
type: 'Identifier',
name: 'h',
},
// 参数
arguments: [],
}
类型为 CallExpression 的节点拥有两个属性:
- callee:用来描述被调用函数的名字称,它本身是一个标识符节点。
- arguments:被调用函数的形式参数,多个参数的话用数组来描述。
我们再次观察渲染函数的返回值:
javascript
function render() {
// h 函数的第一个参数是一个字符串字面量
// h 函数的第二个参数是一个数组
return h('div', [
/*...*/
])
}
可以看到,h 函数的第一个参数是一个字符串字面量,我们可以使用类型为 StringLiteral 的节点来描述它:
javascript
const Str = {
type: 'StringLiteral',
value: 'div',
}
h 函数的第二个参数是一个数组,我们可以使用类型为ArrayExpression 的节点来描述它:
javascript
const Arr = {
type: 'ArrayExpression',
// 数组中的元素
elements: [],
}
使用上述 CallExpression、StringLiteral、ArrayExpression 等节点来填充渲染函数的返回值:
javascript
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
// 函数的名称是一个标识符,标识符本身也是一个节点
id: {
type: 'Identifier',
name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
},
params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
// 渲染函数的函数体只有一个语句,即 return 语句
body: [
{
type: 'ReturnStatement',
// 最外层的 h 函数调用
return: {
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 第一个参数是字符串字面量 'div'
{
type: 'StringLiteral',
value: 'div',
},
// 第二个参数是一个数组
{
type: 'ArrayExpression',
elements: [
// 数组的第一个元素是 h 函数的调用
{
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 该 h 函数调用的第一个参数是字符串字面量
{ type: 'StringLiteral', value: 'p' },
// 第二个参数也是一个字符串字面量
{ type: 'StringLiteral', value: 'Vue' },
],
},
// 数组的第二个元素也是 h 函数的调用
{
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 该 h 函数调用的第一个参数是字符串字面量
{ type: 'StringLiteral', value: 'p' },
// 第二个参数也是一个字符串字面量
{ type: 'StringLiteral', value: 'Template' },
],
},
],
},
],
},
},
],
}
如上这段 JavaScript AST 的代码所示,它是对渲染函数代码的完整描述。
接下来我们编写转换函数,将模板 AST 转换为上述 JavaScriptAST。
在开始之前,我们需要编写一些用来创建 JavaScript AST 节点的辅助函数:
javascript
// 用来创建 StringLiteral 节点
function createStringLiteral(value) {
return {
type: 'StringLiteral',
value,
}
}
// 用来创建 Identifier 节点
function createIdentifier(name) {
return {
type: 'Identifier',
name,
}
}
// 用来创建 ArrayExpression 节点
function createArrayExpression(elements) {
return {
type: 'ArrayExpression',
elements,
}
}
// 用来创建 CallExpression 节点
function createCallExpression(callee, arguments) {
return {
type: 'CallExpression',
callee: createIdentifier(callee),
arguments,
}
}
有了这些辅助函数,我们可以更容易地编写转换代码。
为了把模板 AST 转换为 JavaScript AST,我们同样需要两个转换函数:transformElement 和 transformText,它们分别用来处理标签节点和文本节点:
javascript
// 转换文本节点
function transformText(node) {
// 如果不是文本节点,则什么都不做
if (node.type !== 'Text') {
return
}
// 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,
// 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可
// 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下
node.jsNode = createStringLiteral(node.content)
}
// 转换标签节点
function transformElement(node) {
// 将转换代码编写在退出阶段的回调函数中,
// 这样可以保证该标签节点的子节点全部被处理完毕
return () => {
// 如果被转换的节点不是元素节点,则什么都不做
if (node.type !== 'Element') {
return
}
// 1. 创建 h 函数调用语句,
// h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点
// 作为第一个参数
const callExp = createCallExpression('h', [createStringLiteral(node.tag)])
// 2. 处理 h 函数调用的参数
node.children.length === 1
? // 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数
callExp.arguments.push(node.children[0].jsNode)
: // 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数
callExp.arguments.push(
// 数组的每个元素都是子节点的 jsNode
createArrayExpression(node.children.map(c => c.jsNode))
)
// 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下
node.jsNode = callExp
}
}
上述总体实现并不复杂。有两点需要注意:
- 在转换标签节点时,我们需要将转换逻辑编写在退出阶段的回调函数内,这样才能保证其子节点全部被处理完毕。
- 无论是文本节点还是标签节点,它们转换后的 JavaScript AST 节点都存储在节点的 node.jsNode 属性下。
使用上面两个转换函数即可完成标签节点和文本节点的转换,即把模板转换成 h 函数的调用。
但是转换后的 AST 只是描述 render 函数的返回值,我们需要补全 JavaScript AST,即把 Render 函数本身的函数声明语句节点附加到上面。
这需要我们编写 transformRoot 函数来实现对 Root 根节点的转换:
javascript
// 转换 Root 根节点
function transformRoot(node) {
// 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕
return () => {
// 如果不是根节点,则什么都不做
if (node.type !== 'Root') {
return
}
// node 是根节点,根节点的第一个子节点就是模板的根节点
// 当然,这里我们暂时不考虑模板存在多个根节点的情况
const vnodeJSAST = node.children[0].jsNode
// 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句
node.jsNode = {
type: 'FunctionDecl',
id: { type: 'Identifier', name: 'render' },
params: [],
body: [
{
type: 'ReturnStatement',
return: vnodeJSAST,
},
],
}
}
}
经过这一步处理后,模板 AST 将转换为对应的 JavaScript AST。
我们可以通过根节点的 node.jsNode 来访问转换后的 JavaScript AST。
15.6 代码生成
这节我们讨论如何根据 JavaScript AST 生成渲染函数代码。即代码生成。
代码生成本质上是字符串拼接的艺术。我们需要访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。
我们将实现 generate 函数来完成编译器的最后一步,代码生成:
javascript
function compile(template) {
// 模板 AST
const ast = parse(template)
// 将模板 AST 转换为 JavaScript AST
transform(ast)
// 代码生成
const code = generate(ast.jsNode)
return code
}
代码生成也需要上下文对象。该上下文对象用来维护代码生成过程中程序的运行状态:
javascript
function generate(node) {
const context = {
// 存储最终生成的渲染函数代码
code: '',
// 在生成代码时,通过调用 push 函数完成代码的拼接
push(code) {
context.code += code
},
}
// 调用 genNode 函数完成代码生成的工作,
genNode(node, context)
// 返回渲染函数代码
return context.code
}
上述代码,首先我们定义了上下文对象 context,它包含 context.code 属性,用来存储最终生成的渲染函数代码。
还定义了 context.push 函数,用来完成代码拼接。
接着调用 genNode 函数完成代码生成的工作,最后将最终生成的渲染函数代码返回。
另外我们可以扩展 context 对象,增加换行和缩进的工具函数,增强可读性:
javascript
function generate(node) {
const context = {
code: '',
push(code) {
context.code += code
},
// 当前缩进的级别,初始值为 0,即没有缩进
currentIndent: 0,
// 该函数用来换行,即在代码字符串的后面追加 \n 字符,
// 另外,换行时应该保留缩进,所以我们还要追加 currentIndent * 2 个空格字符
newline() {
context.code += '\n' + ` `.repeat(context.currentIndent)
},
// 用来缩进,即让 currentIndent 自增后,调用换行函数
indent() {
context.currentIndent++
context.newline()
},
// 取消缩进,即让 currentIndent 自减后,调用换行函数
deIndent() {
context.currentIndent--
context.newline()
},
}
genNode(node, context)
return context.code
}
上述代码,我们增加了 context.currentIndent 属性,它代表缩进的级别,初始值为 0,代表没有缩进。
还增加了 context.newline() 函数,每次调用该函数时,都会在代码字符串后面追加换行符 \n。
由于换行时需要保留缩进,所以我们还要追加 context.currentIndent * 2 个空格字符。这里我们假设缩进为两个空格字符,后续设计成可配置。
同时,我们还增加了 context.indent() 函数用来完成代码缩进,它实现的原理是让缩进级别 context.currentIndent 进行自增,再调用 context.newline() 函数。
与之对应的 context.deIndent() 函数则用来取消缩进,即让缩进级别context.currentIndent 进行自减,再调用 context.newline() 函数。
有了这些基础能力之后,我们就可以开始编写 genNode 函数来完成代码生成的工作了。
只需要匹配各种类型的 JavaScriptAST 节点,并调用对应的生成函数即可:
javascript
function genNode(node, context) {
switch (node.type) {
case 'FunctionDecl':
genFunctionDecl(node, context)
break
case 'ReturnStatement':
genReturnStatement(node, context)
break
case 'CallExpression':
genCallExpression(node, context)
break
case 'StringLiteral':
genStringLiteral(node, context)
break
case 'ArrayExpression':
genArrayExpression(node, context)
break
}
}
在 genNode 函数内部,我们使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。
- 对于 FunctionDecl 节点,使用 genFunctionDecl 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 ReturnStatement 节点,使用 genReturnStatement 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 CallExpression 节点,使用 genCallExpression 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 StringLiteral 节点,使用 genStringLiteral 函数为该类型节点生成对应的 JavaScript 代码。
- 对于 ArrayExpression 节点,使用 genArrayExpression 函数为该类型节点生成对应的 JavaScript 代码。
目前只涉及这五种类型的 JavaScript 节点,后续有需求,再添加对应逻辑即可。
接下来我们来实现函数声明语句的代码生成,即 genFunctionDecl 函数:
javascript
function genFunctionDecl(node, context) {
// 从 context 对象中取出工具函数
const { push, indent, deIndent } = context
// node.id 是一个标识符,用来描述函数的名称,即 node.id.name
push(`function ${node.id.name} `)
push(`(`)
// 调用 genNodeList 为函数的参数生成代码
genNodeList(node.params, context)
push(`) `)
push(`{`)
// 缩进
indent()
// 为函数体生成代码,这里递归地调用了 genNode 函数
node.body.forEach(n => genNode(n, context))
// 取消缩进
deIndent()
push(`}`)
}
genFunctionDecl 函数可以为函数声明类型的节点生成对应的 JavaScript 代码。以渲染函数的声明节点为例,它最终生成的代码将会是:
javascript
function render () {
... 函数体
}
另外在 genFunctionDecl 函数内调用了 genNodeList 函数,为函数参数生成对应的代码。它的实现如下:
javascript
function genNodeList(nodes, context) {
const { push } = context
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
genNode(node, context)
if (i < nodes.length - 1) {
push(', ')
}
}
}
genNodeList 函数接收一个节点数组作为参数,并为每一个节点递归地调用 genNode 函数完成代码生成工作。
每处理完一个节点,都会在生成的代码后面拼接逗号字符(,):
javascript
// 如果节点数组为
const node = [节点 1, 节点 2, 节点 3]
// 那么生成的代码将类似于
'节点 1,节点 2,节点 3'
// 如果在这段代码的前后分别添加圆括号,那么它将可用于函数的参数声明
('节点 1,节点 2,节点 3')
// 如果在这段代码的前后分别添加方括号,那么它将是一个数组
['节点 1,节点 2,节点 3']
由上可知,genNodeList 函数会在节点代码之间补充逗号字符。
实际上,genArrayExpression 函数使用这个特点实现对数组表达式的代码生成:
javascript
function genArrayExpression(node, context) {
const { push } = context
// 追加方括号
push('[')
// 调用 genNodeList 为数组元素生成代码
genNodeList(node.elements, context)
// 补全方括号
push(']')
}
由于目前渲染函数没有接收参数,所以 genNodeList 函数不会为其生成任何代码。
对于 genFunctionDecl 函数,另外注意,由于函数体本身也是一个节点数组,所以我们需要遍历它并递归地调用 genNode 函数生成代码。
对于 ReturnStatement 和 StringLiteral 类型的节点来说,实现如下:
javascript
function genReturnStatement(node, context) {
const { push } = context
// 追加 return 关键字和空格
push(`return `)
// 调用 genNode 函数递归地生成返回值代码
genNode(node.return, context)
}
function genStringLiteral(node, context) {
const { push } = context
// 对于字符串字面量,只需要追加与 node.value 对应的字符串即可
push(`'${node.value}'`)
}
最后还剩下 genCallExpression 函数:
javascript
function genCallExpression(node, context) {
const { push } = context
// 取得被调用函数名称和参数列表
const { callee, arguments: args } = node
// 生成函数调用代码
push(`${callee.name}(`)
// 调用 genNodeList 生成参数代码
genNodeList(args, context)
// 补全括号
push(`)`)
}
在 genCallExpression 函数内,我们也用到了 genNodeList 函数,为函数调用时的参数生成对应的代码。
配合上述生成器函数的实现,我们将得到符合预期的渲染函数代码:
javascript
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
const code = generate(ast.jsNode)
最终得到的代码字符串如下:
javascript
function render () {
return h('div', [h('p', 'Vue'), h('p', 'Template')])
}
15.7 总结
我们首先讨论 Vue.js 模板编译器,它用于将模板编译为渲染函数,工作流程分为三个步骤:
- 分析模板,将其解析为模板 AST。
- 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
- 根据 JavaScript AST 生成渲染函数代码。
接着,我们讨论了 parser 的实现原理,以及如何用有限状态自动机构造一个词法分析器。词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个 Token,形成一个 Token 列表。我们将使用该 Token 列表来构造用于描述模板的 AST。具体做法是,扫描 Token 列表并维护一个开始标签栈。每当扫描到一个开始标签节点,就将其压入栈顶。栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有 Token 扫描完毕后,即可构建出一棵树型 AST。
然后,我们讨论了 AST 的转换与插件化架构。AST 是树型数据结构,为了访问AST 中的节点,我们采用深度优先的方式对 AST 进行遍历。在遍历过程中,我们可以对 AST 节点进行各种操作,从而实现对 AST 的转换。为了解耦节点的访问和操作,我们设计了插件化架构,将节点的操作封装到独立的转换函数中。这些转换函数可以通过 context.nodeTransforms 来注册。这里的 context 称为转换上下文。上下文对象中通常会维护程序的当前状态,例如当前访问的节点、当前访问的节点的父节点、当前访问的节点的位置索引等信息。有了上下文对象及其包含的重要信息后,我们即可轻松地实现节点的替换、删除等能力。但有时,当前访问节点的转换工作依赖于其子节点的转换结果,所以为了优先完成子节点的转换,我们将整个转换过程分为"进入阶段"与"退出阶段"。每个转换函数都分两个阶段执行,这样就可以实现更加细粒度的转换控制。
之后,我们讨论了如何将模板 AST 转换为用于描述渲染函数的 JavaScript AST。模板 AST 用来描述模板,类似地,JavaScript AST 用于描述 JavaScript 代码。只有把模板 AST 转换为 JavaScript AST 后,我们才能据此生成最终的渲染函数代码。最后,我们讨论了渲染函数代码的生成工作。代码生成是模板编译器的最后一步工作,生成的代码将作为组件的渲染函数。代码生成的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应的代码生成函数。为了让生成的代码具有更强的可读性,我们还讨论了如何对生成的代码进行缩进和换行。我们将用于缩进和换行的代码封装为工具函数,并且定义到代码生成过程中的上下文对象中。