编译器核心技术概览

编译技术是一门庞大的学科,我们无法对其做完善的讲解。但不同用途的编译器或编译技术的难度可能相差很大,对知识的掌握要求也会相差很多。如果你要实现诸如 C、JavaScript 这类通用用途语言(general purpose language),那么就需要掌握较多编译技术知识。例如,理解上下文无关文法,使用巴科斯范式(BNF),扩展巴科斯范式(EBNF)书写语法规则,完成语法推导,理解和消除左递归,递归下降算法,甚至类型系统方面的知识等。但作为前端工程师,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。其中,实现公式计算器甚至只涉及编译前端技术,而领域特定语言根据其具体使用场景和目标平台的不同,难度会有所不同。Vue.js 的模板和 JSX 都属于领域特定语言,它们的实现难度属于中、低级别,只要掌握基本的编译技术理论即可实现这些功能。

1、模板 DSL 的编译器

编译器其实只是一段程序,它用来将“一种语言 A”翻译成“另外一种语言 B”。其中,语言 A 通常叫作源代码(source code),语言 B 通常叫作目标代码(object code 或 target code)。编译器将源代码翻译为目标代码的过程叫作编译(compile)。完整的编译过程通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤,如下图所示:
在这里插入图片描述
可以看到,整个编译过程分为编译前端和编译后端。编译前端包含词法分析、语法分析和语义分析,它通常与目标平台无关,仅负责分析源代码。编译后端则通常与目标平台有关,编译后端涉及中间代码生成和优化以及目标代码生成。但是,编译后端并不一定会包含中间代码生成和优化这两个环节,这取决于具体的场景和实现。中间代码生成和优化这两个环节有时也叫“中端”。

上图展示了“教科书”式的编译模型,但 Vue.js 的模板作为 DSL,其编译流程会有所不同。对于 Vue.js 模板编译器来说,源代码就是组件的模板,而目标代码是能够在浏览器平台上运行的 JavaScript 代码,或其他拥有 JavaScript 运行时的平台代码,如下图所示:
在这里插入图片描述
可以看到,Vue.js 模板编译器的目标代码其实就是渲染函数。详细而言,Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。接着,将模板 AST 转换(transform)成 JavaScript AST。最后,根据 JavaScript AST 生成 JavaScript 代码,即渲染函数代码。下图给出了Vue.js 模板编译器的工作流程:
在这里插入图片描述
AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。所谓模板 AST,其实就是用来描述模板的抽象语法树。举个例子,假设我们有如下模板:

01 <div>
02   <h1 v-if="ok">Vue Template</h1>
03 </div>

这段模板会被编译为如下所示的 AST:

01 const ast = {
02   // 逻辑根节点
03   type: 'Root',
04   children: [
05     // div 标签节点
06     {
07       type: 'Element',
08       tag: 'div',
09       children: [
10         // h1 标签节点
11         {
12           type: 'Element',
13           tag: 'h1',
14           props: [
15             // v-if 指令节点
16             {
17               type: 'Directive', // 类型为 Directive 代表指令
18               name: 'if'// 指令名称为 if,不带有前缀 v-
19               exp: {
20                 // 表达式节点
21                 type: 'Expression',
22                 content: 'ok'
23               }
24             }
25           ]
26         }
27       ]
28     }
29   ]
30 }

可以看到,AST 其实就是一个具有层级结构的对象。模板 AST 具有与模板同构的嵌套结构。每一棵 AST 都有一个逻辑上的根节点,其类型为 Root。模板中真正的根节点则作为 Root 节点的 children 存在。观察上面的 AST,我们可以得出如下结论:

  • 不同类型的节点是通过节点的 type 属性进行区分的。例如标签节点的 type 值为 ‘Element’。
  • 标签节点的子节点存储在其 children 数组中。
  • 标签节点的属性节点和指令节点会存储在 props 数组中。
  • 不同类型的节点会使用不同的对象属性进行描述。例如指令节点拥有 name 属性,用来表达指令的名称,而表达式节点拥有 content 属性,用来描述表达式的内容。

我们可以通过封装 parse 函数来完成对模板的词法分析和语法分析,得到模板 AST,如下图所示:
在这里插入图片描述
我们也可以用下面的代码来表达模板解析的过程:

01 const template = `
02   <div>
03     <h1 v-if="ok">Vue Template</h1>
04   </div>
05 `
06
07 const templateAST = parse(template)

可以看到,parse 函数接收字符串模板作为参数,并将解析后得到的 AST 作为返回值返回。

有了模板 AST 后,我们就可以对其进行语义分析,并对模板AST 进行转换了。什么是语义分析呢?举几个例子:

  • 检查 v-else 指令是否存在相符的 v-if 指令。
  • 分析属性值是否是静态的,是否是常量等。
  • 插槽是否会引用上层作用域的变量。

在语义分析的基础上,我们即可得到模板 AST。接着,我们还需要将模板 AST 转换为 JavaScript AST。因为 Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是JavaScript 代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript AST。

我们可以封装 transform 函数来完成模板 AST 到 JavaScript AST 的转换工作,如下所示:
在这里插入图片描述
同样,我们也可以用下面的代码来表达:

01 const templateAST = parse(template)
02 const jsAST = transform(templateAST)

我们会在下一节详细讲解 JavaScript AST 的结构。

有了 JavaScript AST 后,我们就可以根据它生成渲染函数了,这一步可以通过封装 generate 函数来完成,如下图所示:
在这里插入图片描述
我们也可以用下面的代码来表达代码生成的过程:

01 const templateAST = parse(template)
02 const jsAST = transform(templateAST)
03 const code = generate(jsAST)

在上面这段代码中,generate 函数会将渲染函数的代码以字符串的形式返回,并存储在 code 常量中。下图给出了完整的流程:
在这里插入图片描述

2、parser 的实现原理与状态机

在上一节中,我们讲解了 Vue.js 模板编译器的基本结构和工作流程,它主要由三个部分组成:

  • 用来将模板字符串解析为模板 AST 的解析器(parser);
  • 用来将模板 AST 转换为 JavaScript AST 的转换器(transformer);
  • 用来根据 JavaScript AST 生成渲染函数代码的生成器(generator)。

本节,我们将详细讨论解析器 parser 的实现原理。

解析器的入参是字符串模板,解析器会逐个读取字符串模板中的字符,并根据一定的规则将整个字符串切割为一个个Token。这里的 Token 可以视作词法记号,后续我们将使用Token 一词来代表词法记号进行讲解。举例来说,假设有这样一段模板:

01 <p>Vue</p>

解析器会把这段字符串模板切割为三个 Token:

  • 开始标签:<p>
  • 文本节点:Vue。
  • 结束标签:</p>

那么,解析器是如何对模板进行切割的呢?依据什么规则?这就不得不提到有限状态自动机。千万不要被这个名词吓到,它理解起来并不难。所谓“有限状态”,就是指有限个状态,而“自动机”意味着随着字符的输入,解析器会自动地在不同状态间迁移。拿上面的模板来说,当我们分析这段模板字符串时,parse 函数会逐个读取字符,状态机会有一个初始状态,我们记为“初始状态 1”。下图给出了状态迁移的过程:
在这里插入图片描述
我们用自然语言来描述下图给出的状态迁移过程:

  • 状态机始于“初始状态 1”。
  • 在“初始状态 1”下,读取模板的第一个字符 <,状态机会进入下一个状态,即“标签开始状态 2”。
  • 在“标签开始状态 2”下,读取下一个字符 p。由于字符 p 是字母,所以状态机会进入“标签名称状态 3”。
  • 在“标签名称状态 3”下,读取下一个字符 >,此时状态机会从“标签名称状态 3”迁移回“初始状态 1”,并记录在“标签名称状态”下产生的标签名称 p。
  • 在“初始状态 1”下,读取下一个字符 V,此时状态机会进入“文本状态 4”。
  • 在“文本状态 4”下,继续读取后续字符,直到遇到字符 <时,状态机会再次进入“标签开始状态 2”,并记录在“文本状态 4”下产生的文本内容,即字符串“Vue”。
  • 在“标签开始状态 2”下,读取下一个字符 /,状态机会进入“结束标签状态 5”。
  • 在“结束标签状态 5”下,读取下一个字符 p,状态机会进入“结束标签名称状态 6”。
  • 在“结束标签名称状态 6”下,读取最后一个字符 >,它是结束标签的闭合字符,于是状态机迁移回“初始状态 1”,并记录在“结束标签名称状态 6”下生成的结束标签名称。

经过这样一系列的状态迁移过程之后,我们最终就能够得到相应的 Token 了。观察上图可以发现,有的圆圈是单线的,而有的圆圈是双线的。双线代表此时状态机是一个合法的Token。

另外,上图给出的状态机并不严谨。实际上,解析 HTML 并构造 Token 的过程是有规范可循的。在 WHATWG 发布的关于浏览器解析 HTML 的规范中,详细阐述了状态迁移。下图截取了该规范中定义的在“初始状态”下状态机的状态迁移过程:
在这里插入图片描述
可以看到,在“初始状态”(Data State)下,当遇到字符 <时,状态机会迁移到 tag open state,即“标签开始状态”。如果遇到字符 < 以外的字符,规范中也都有对应的说明,应该让状态机迁移到怎样的状态。不过 Vue.js 的模板作为一个DSL,并非必须遵守该规范。但 Vue.js 的模板毕竟是类 HTML 的实现,因此,尽可能按照规范来做,不会有什么坏处。更重要的一点是,规范中已经定义了非常详细的状态迁移过程,这对于我们编写解析器非常有帮助。

按照有限状态自动机的状态迁移过程,我们可以很容易地编写对应的代码实现。因此,有限状态自动机可以帮助我们完成对模板的标记化(tokenized),最终我们将得到一系列 Token。下图中描述的状态机的实现如下:

01 // 定义状态机的状态
02 const State = {
03   initial: 1,    // 初始状态
04   tagOpen: 2,    // 标签开始状态
05   tagName: 3,    // 标签名称状态
06   text: 4,       // 文本状态
07   tagEnd: 5,     // 结束标签状态
08   tagEndName: 6  // 结束标签名称状态
09 }
10 // 一个辅助函数,用于判断是否是字母
11 function isAlpha(char) {
12   return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
13 }
14
15 // 接收模板字符串作为参数,并将模板切割为 Token 返回
16 function tokenize(str) {
17   // 状态机的当前状态:初始状态
18   let currentState = State.initial
19   // 用于缓存字符
20   const chars = []
21   // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
22   const tokens = []
23   // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
24   while(str) {
25     // 查看第一个字符,注意,这里只是查看,没有消费该字符
26     const char = str[0]
27     // switch 语句匹配当前状态
28     switch (currentState) {
29       // 状态机当前处于初始状态
30       case State.initial:
31         // 遇到字符 <
32         if (char === '<') {
33           // 1. 状态机切换到标签开始状态
34           currentState = State.tagOpen
35           // 2. 消费字符 <
36           str = str.slice(1)
37         } else if (isAlpha(char)) {
38           // 1. 遇到字母,切换到文本状态
39           currentState = State.text
40           // 2. 将当前字母缓存到 chars 数组
41           chars.push(char)
42           // 3. 消费当前字符
43           str = str.slice(1)
44         }
45         break
46       // 状态机当前处于标签开始状态
47       case State.tagOpen:
48         if (isAlpha(char)) {
49           // 1. 遇到字母,切换到标签名称状态
50           currentState = State.tagName
51           // 2. 将当前字符缓存到 chars 数组
52           chars.push(char)
53           // 3. 消费当前字符
54           str = str.slice(1)
55         } else if (char === '/') {
56           // 1. 遇到字符 /,切换到结束标签状态
57           currentState = State.tagEnd
58           // 2. 消费字符 /
59           str = str.slice(1)
60         }
61         break
62       // 状态机当前处于标签名称状态
63       case State.tagName:
64         if (isAlpha(char)) {
65           // 1. 遇到字母,由于当前处于标签名称状态,所以不需要切换状态,
66           // 但需要将当前字符缓存到 chars 数组
67           chars.push(char)
68           // 2. 消费当前字符
69           str = str.slice(1)
70         } else if (char === '>') {
71           // 1.遇到字符 >,切换到初始状态
72           currentState = State.initial
73           // 2. 同时创建一个标签 Token,并添加到 tokens 数组中
74           // 注意,此时 chars 数组中缓存的字符就是标签名称
75           tokens.push({
76             type: 'tag',
77             name: chars.join('')
78           })
79           // 3. chars 数组的内容已经被消费,清空它
80           chars.length = 0
81           // 4. 同时消费当前字符 >
82           str = str.slice(1)
83         }
84         break
85       // 状态机当前处于文本状态
86       case State.text:
87         if (isAlpha(char)) {
88           // 1. 遇到字母,保持状态不变,但应该将当前字符缓存到 chars 数组
89           chars.push(char)
90           // 2. 消费当前字符
91           str = str.slice(1)
92         } else if (char === '<') {
93           // 1. 遇到字符 <,切换到标签开始状态
94           currentState = State.tagOpen
95           // 2. 从 文本状态 --> 标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组
96           // 注意,此时 chars 数组中的字符就是文本内容
97           tokens.push({
98             type: 'text',
99             content: chars.join('')
100           })
101           // 3. chars 数组的内容已经被消费,清空它
102           chars.length = 0
103           // 4. 消费当前字符
104           str = str.slice(1)
105         }
106         break
107       // 状态机当前处于标签结束状态
108       case State.tagEnd:
109         if (isAlpha(char)) {
110           // 1. 遇到字母,切换到结束标签名称状态
111           currentState = State.tagEndName
112           // 2. 将当前字符缓存到 chars 数组
113           chars.push(char)
114           // 3. 消费当前字符
115           str = str.slice(1)
116         }
117         break
118       // 状态机当前处于结束标签名称状态
119       case State.tagEndName:
120         if (isAlpha(char)) {
121           // 1. 遇到字母,不需要切换状态,但需要将当前字符缓存到 chars 数组
122           chars.push(char)
123           // 2. 消费当前字符
124           str = str.slice(1)
125         } else if (char === '>') {
126           // 1. 遇到字符 >,切换到初始状态
127           currentState = State.initial
128           // 2. 从 结束标签名称状态 --> 初始状态,应该保存结束标签名称 Token
129           // 注意,此时 chars 数组中缓存的内容就是标签名称
130           tokens.push({
131             type: 'tagEnd',
132             name: chars.join('')
133           })
134           // 3. chars 数组的内容已经被消费,清空它
135           chars.length = 0
136           // 4. 消费当前字符
137           str = str.slice(1)
138         }
139         break
140     }
141   }
142
143   // 最后,返回 tokens
144   return tokens
145 }

上面这段代码看上去比较冗长,可优化的点非常多。这段代码高度还原了上图中展示的状态机,配合代码中的注释会更容易理解。

使用上面给出的 tokenize 函数来解析模板 <p>Vue</p>,我们将得到三个 Token:

01 const tokens = tokenize(`<p>Vue</p>`)
02 // [
03 //   { type: 'tag', name: 'p' },        // 开始标签
04 //   { type: 'text', content: 'Vue' },  // 文本节点
05 //   { type: 'tagEnd', name: 'p' }      // 结束标签
06 // ]

现在,你已经明白了状态机的工作原理,以及模板编译器将模板字符串切割为一个个 Token 的过程。但拿上述例子来说,我们并非总是需要所有 Token。例如,在解析模板的过程中,结束标签 Token 可以省略。这时,我们就可以调整 tokenize 函数的代码,并选择性地忽略结束标签 Token。当然,有时我们也可能需要更多的 Token,这都取决于具体的需求,然后据此灵活地调整代码实现。

总而言之,通过有限自动机,我们能够将模板解析为一个个Token,进而可以用它们构建一棵 AST 了。但在具体构建 AST 之前,我们需要思考能否简化 tokenize 函数的代码。实际上,我们可以通过正则表达式来精简 tokenize 函数的代码。上文之所以没有从最开始就采用正则表达式来实现,是因为正则表达式的本质就是有限自动机。当你编写正则表达式的时候,其实就是在编写有限自动机。

3、构造 AST

实际上,不同用途的编译器之间可能会存在非常大的差异。它们唯一的共同点是,都会将源代码转换成目标代码。但如果深入细节即可发现,不同编译器之间的实现思路甚至可能完全不同,其中就包括 AST 的构造方式。对于通用用途语言(GPL)来说,例如 JavaScript 这样的脚本语言,想要为其构造 AST,较常用的一种算法叫作递归下降算法,这里面需要解决 GPL 层面才会遇到的很多问题,例如最基本的运算符优先级问题。然而,对于像 Vue.js 模板这样的 DSL 来说,首先可以确定的一点是,它不具有运算符,所以也就没有所谓的运算符优先级问题。DSL 与 GPL 的区别在于,GPL 是图灵完备的,我们可以使用 GPL 来实现 DSL。而 DSL 不要求图灵完备,它只需要满足特定场景下的特定用途即可。

为 Vue.js 的模板构造 AST 是一件很简单的事。HTML 是一种标记语言,它的格式非常固定,标签元素之间天然嵌套,形成父子关系。因此,一棵用于描述 HTML 的 AST 将拥有与HTML 标签非常相似的树型结构。举例来说,假设有如下模板:

01 <div><p>Vue</p><p>Template</p></div>

在上面这段模板中,最外层的根节点是 div 标签,它有两个 p 标签作为子节点。同时,这两个 p 标签都具有一个文本节点作为子节点。我们可以将这段模板对应的 AST 设计为:

01 const ast = {
02   // AST 的逻辑根节点
03   type: 'Root',
04   children: [
05     // 模板的 div 根节点
06     {
07       type: 'Element',
08       tag: 'div',
09       children: [
10         // div 节点的第一个子节点 p
11         {
12           type: 'Element',
13           tag: 'p',
14           // p 节点的文本节点
15           children: [
16             {
17               type: 'Text',
18               content: 'Vue'
19             }
20           ]
21         },
22         // div 节点的第二个子节点 p
23         {
24           type: 'Element',
25           tag: 'p',
26           // p 节点的文本节点
27           children: [
28             {
29               type: 'Text',
30               content: 'Template'
31             }
32           ]
33         }
34       ]
35     }
36   ]
37 }

可以看到,AST 在结构上与模板是“同构”的,它们都具有树型结构,如上图所示:
在这里插入图片描述
了解了 AST 的结构,接下来我们的任务是,使用程序根据模板解析后生成的 Token 构造出这样一棵 AST。首先,我们使用上一节讲解的 tokenize 函数将本节开头给出的模板进行标记化。解析这段模板得到的 tokens 如下所示:

01 const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)

执行上面这段代码,我们将得到如下 tokens:

01 const tokens = [
02   {type: "tag", name: "div"},          // div 开始标签节点
03   {type: "tag", name: "p"},            // p 开始标签节点
04   {type: "text", content: "Vue"},      // 文本节点
05   {type: "tagEnd", name: "p"},         // p 结束标签节点
06   {type: "tag", name: "p"},            // p 开始标签节点
07   {type: "text", content: "Template"}, // 文本节点
08   {type: "tagEnd", name: "p"},         // p 结束标签节点
09   {type: "tagEnd", name: "div"}        // div 结束标签节点
10 ]

根据 Token 列表构建 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§,然后将该节点作为当前栈顶节点的子节点。由于当前栈顶节点为Element(div) 节点,所以我们将新建的 Element§ 节点作为Element(div) 节点的子节点添加到 AST 中,最后将新建的Element§ 节点压入 elementStack 栈。

接着,扫描下一个 Token,如下图所示:
在这里插入图片描述
此时扫描到的 Token 是一个结束标签,所以我们需要将栈顶的Element§ 节点从 elementStack 栈中弹出。接着,扫描下一个 Token,如下图所示:
在这里插入图片描述
此时扫描到的 Token 是一个开始标签。我们为它新建一个 AST 节点 Element§,并将其作为当前栈顶节点 Element(div) 的子节点。最后,将 Element§ 压入 elementStack 栈中,使其成为新的栈顶节点。

接着,扫描下一个 Token,如下图所示:
在这里插入图片描述
此时扫描到的 Token 是一个文本节点,所以只需要为其创建一个相应的 AST 节点 Text(Template) 即可,然后将其作为当前栈顶节点 Element§ 的子节点添加到 AST 中。

接着,扫描下一个 Token,如下图所示:
在这里插入图片描述
此时扫描到的 Token 是一个结束标签,于是我们将当前的栈顶节点 Element§ 从 elementStack 栈中弹出。

接着,扫描下一个 Token,如下图所示:
在这里插入图片描述
此时,扫描到了最后一个 Token,它是一个 div 结束标签,所以我们需要再次将当前栈顶节点 Element(div) 从elementStack 栈中弹出。至此,所有 Token 都被扫描完毕,AST 构建完成。下图给出了最终状态:
在这里插入图片描述
如上图所示,在所有 Token 扫描完毕后,一棵 AST 就构建完成了。

扫描 Token 列表并构建 AST 的具体实现如下:

01 // parse 函数接收模板作为参数
02 function parse(str) {
03   // 首先对模板进行标记化,得到 tokens
04   const tokens = tokenize(str)
05   // 创建 Root 根节点
06   const root = {
07     type: 'Root',
08     children: []
09   }
10   // 创建 elementStack 栈,起初只有 Root 根节点
11   const elementStack = [root]
12
13   // 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
14   while (tokens.length) {
15     // 获取当前栈顶节点作为父节点 parent
16     const parent = elementStack[elementStack.length - 1]
17     // 当前扫描的 Token
18     const t = tokens[0]
19     switch (t.type) {
20       case 'tag':
21         // 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
22         const elementNode = {
23           type: 'Element',
24           tag: t.name,
25           children: []
26         }
27         // 将其添加到父级节点的 children 中
28         parent.children.push(elementNode)
29         // 将当前节点压入栈
30         elementStack.push(elementNode)
31         break
32       case 'text':
33         // 如果当前 Token 是文本,则创建 Text 类型的 AST 节点
34         const textNode = {
35           type: 'Text',
36           content: t.content
37         }
38         // 将其添加到父节点的 children 中
39         parent.children.push(textNode)
40         break
41       case 'tagEnd':
42         // 遇到结束标签,将栈顶节点弹出
43         elementStack.pop()
44         break
45     }
46     // 消费已经扫描过的 token
47     tokens.shift()
48   }
49
50   // 最后返回 AST
51   return root
52 }

上面这段代码很好地还原了上文中介绍的构建 AST 的思路,我们可以使用如下代码对其进行测试:

01 const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)

运行这句代码,我们将得到与本节开头给出的 AST 一致的结果。这里有必要说明一点,当前的实现仍然存在诸多问题,例如无法处理自闭合标签等。

4、AST 的转换与插件化架构

在上一节中,我们完成了模板 AST 的构造。本节,我们将讨论关于 AST 的转换。所谓 AST 的转换,指的是对 AST 进行一系列操作,将其转换为新的 AST 的过程。新的 AST 可以是原语言或原 DSL 的描述,也可以是其他语言或其他 DSL 的描述。例如,我们可以对模板 AST 进行操作,将其转换为 JavaScript AST。转换后的 AST 可以用于代码生成。这其实就是 Vue.js 的模板编译器将模板编译为渲染函数的过程,如下图所示:
在这里插入图片描述
其中 transform 函数就是用来完成 AST 转换工作的。

4.1、节点的访问

为了对 AST 进行转换,我们需要能访问 AST 的每一个节点,这样才有机会对特定节点进行修改、替换、删除等操作。由于AST 是树型数据结构,所以我们需要编写一个深度优先的遍历算法,从而实现对 AST 中节点的访问。不过,在开始编写转换代码之前,我们有必要编写一个 dump 工具函数,用来打印当前 AST 中节点的信息,如下面的代码所示:

01 function dump(node, indent = 0) {
02   // 节点的类型
03   const type = node.type
04   // 节点的描述,如果是根节点,则没有描述
05   // 如果是 Element 类型的节点,则使用 node.tag 作为节点的描述
06   // 如果是 Text 类型的节点,则使用 node.content 作为节点的描述
07   const desc = node.type === 'Root'
08     ? ''
09     : node.type === 'Element'
10       ? node.tag
11       : node.content
12
13   // 打印节点的类型和描述信息
14   console.log(`${'-'.repeat(indent)}${type}: ${desc}`)
15
16   // 递归地打印子节点
17   if (node.children) {
18     node.children.forEach(n => dump(n, indent + 2))
19   }
20 }

我们沿用上一节中给出的例子,看看使用 dump 函数会输出怎样的结果:

01 const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
02 console.log(dump(ast))

运行上面这段代码,将得到如下输出:

01 Root:
02 --Element: div
03 ----Element: p
04 ------Text: Vue
05 ----Element: p
06 ------Text: Template

可以看到,dump 函数以清晰的格式来展示 AST 中的节点。在后续编写 AST 的转换代码时,我们将使用 dump 函数来展示转换后的结果。

接下来,我们将着手实现对 AST 中节点的访问。访问节点的方式是,从 AST 根节点开始,进行深度优先遍历,如下面的代码所示:

01 function traverseNode(ast) {
02   // 当前节点,ast 本身就是 Root 节点
03   const currentNode = ast
04   // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
05   const children = currentNode.children
06   if (children) {
07     for (let i = 0; i < children.length; i++) {
08       traverseNode(children[i])
09     }
10   }
11 }

traverseNode 函数用来以深度优先的方式遍历 AST,它的实现与 dump 函数几乎相同。有了 traverseNdoe 函数之后,我们即可实现对 AST 中节点的访问。例如,我们可以实现一个转换功能,将 AST 中所有 p 标签转换为 h1 标签,如下面的代码所示:

01 function traverseNode(ast) {
02   // 当前节点,ast 本身就是 Root 节点
03   const currentNode = ast
04
05   // 对当前节点进行操作
06   if (currentNode.type === 'Element' && currentNode.tag === 'p') {
07     // 将所有 p 标签转换为 h1 标签
08     currentNode.tag = 'h1'
09   }
10
11   // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
12   const children = currentNode.children
13   if (children) {
14     for (let i = 0; i < children.length; i++) {
15       traverseNode(children[i])
16     }
17   }
18 }

在上面这段代码中,我们通过检查当前节点的 type 属性和 tag 属性,来确保被操作的节点是 p 标签。接着,我们将符合条件的节点的 tag 属性值修改为 ‘h1’,从而实现 p 标签到 h1 标签的转换。我们可以使用 dump 函数打印转换后的 AST 的信息,如下面的代码所示:

01 // 封装 transform 函数,用来对 AST 进行转换
02 function transform(ast) {
03   // 调用 traverseNode 完成转换
04   traverseNode(ast)
05   // 打印 AST 信息
06   console.log(dump(ast))
07 }
08
09 const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
10 transform(ast)

运行上面这段代码,我们将得到如下输出:

01 Root:
02 --Element: div
03 ----Element: h1
04 ------Text: Vue
05 ----Element: h1
06 ------Text: Template

可以看到,所有 p 标签都已经变成了 h1 标签。

我们还可以对 AST 进行其他转换。例如,实现一个转换,将文本节点的内容重复两次:

01 function traverseNode(ast) {
02   // 当前节点,ast 本身就是 Root 节点
03   const currentNode = ast
04
05   // 对当前节点进行操作
06   if (currentNode.type === 'Element' && currentNode.tag === 'p') {
07     // 将所有 p 标签转换为 h1 标签
08     currentNode.tag = 'h1'
09   }
10
11   // 如果节点的类型为 Text
12   if (currentNode.type === 'Text') {
13     // 重复其内容两次,这里我们使用了字符串的 repeat() 方法
14     currentNode.content = currentNode.content.repeat(2)
15   }
16
17   // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
18   const children = currentNode.children
19   if (children) {
20     for (let i = 0; i < children.length; i++) {
21       traverseNode(children[i])
22     }
23   }
24 }

如上面的代码所示,我们增加了对文本类型节点的处理代码。一旦检查到当前节点的类型为 Text,则调用 repeat(2) 方法将文本节点的内容重复两次。最终,我们将得到如下输出:

01 Root:
02 --Element: div
03 ----Element: h1
04 ------Text: VueVue
05 ----Element: h1
06 ------Text: TemplateTemplate

可以看到,文本节点的内容全部重复了两次。

不过,随着功能的不断增加,traverseNode 函数将会变得越来越“臃肿”。这时,我们很自然地想到,能否对节点的操作和访问进行解耦呢?答案是“当然可以”,我们可以使用回调函数的机制来实现解耦,如下面 traverseNode 函数的代码所示:

01 // 接收第二个参数 context
02 function traverseNode(ast, context) {
03   const currentNode = ast
04
05   // context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
06   const transforms = context.nodeTransforms
07   for (let i = 0; i < transforms.length; i++) {
08     // 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
09     transforms[i](currentNode, context)
10   }
11
12   const children = currentNode.children
13   if (children) {
14     for (let i = 0; i < children.length; i++) {
15       traverseNode(children[i], context)
16     }
17   }
18 }

在上面这段代码中,我们首先为 traverseNode 函数增加了第二个参数 context。关于 context 的内容,下文会详细介绍。接着,我们把回调函数存储到 transforms 数组中,然后遍历该数组,并逐个调用注册在其中的回调函数。最后,我们将当前节点 currentNode 和 context 对象分别作为参数传递给回调函数。

有了修改后的 traverseNode 函数,我们就可以如下所示使用它了:

01 function transform(ast) {
02   // 在 transform 函数内创建 context 对象
03   const context = {
04     // 注册 nodeTransforms 数组
05     nodeTransforms: [
06       transformElement, // transformElement 函数用来转换标签节点
07       transformText     // transformText 函数用来转换文本节点
08     ]
09   }
10   // 调用 traverseNode 完成转换
11   traverseNode(ast, context)
12   // 打印 AST 信息
13   console.log(dump(ast))
14 }

其中,transformElement 函数和 transformText 函数的实现如下:

01 function transformElement(node) {
02   if (node.type === 'Element' && node.tag === 'p') {
03     node.tag = 'h1'
04   }
05 }
06
07 function transformText(node) {
08   if (node.type === 'Text') {
09     node.content = node.content.repeat(2)
10   }
11 }

可以看到,解耦之后,节点操作封装到了 transformElement 和 transformText 这样的独立函数中。我们甚至可以编写任意多个类似的转换函数,只需要将它们注册到context.nodeTransforms 中即可。这样就解决了功能增加所导致的 traverseNode 函数“臃肿”的问题。

4.2、转换上下文与节点操作

在上文中,我们将转换函数注册到 context.nodeTransforms 数组中。那么,为什么要使用 context 对象呢?直接定义一个数组不可以吗?为了搞清楚这个问题,就不得不提到关于上下文的知识。你可能或多或少听说过关于 Context(上下文)的内容,我们可以把 Context 看作程序在某个范围内的“全局变量”。实际上,上下文并不是一个具象的东西,它依赖于具体的使用场景。我们举几个例子来直观地感受一下。

  • 在编写 React 应用时,我们可以使用 React.createContext 函数创建一个上下文对象,该上下文对象允许我们将数据通过组件树一层层地传递下去。无论组件树的层级有多深,只要组件在这棵组件树的层级内,那么它就能够访问上下文对象中的数据。
  • 在编写 React 应用时,我们可以使用 React.createContext 函数创建一个上下文对象,该上下文对象允许我们将数据通过组件树一层层地传递下去。无论组件树的层级有多深,只要组件在这棵组件树的层级内,那么它就能够访问上下文对象中的数据。
  • 在编写 Koa 应用时,中间件函数接收的 context 参数也是一种上下文对象,所有中间件都可以通过 context 来访问相同的数据。

通过上述三个例子我们能够认识到,上下文对象其实就是程序在某个范围内的“全局变量”。换句话说,我们也可以把全局变量看作全局上下文。

回到我们本节讲解的 context.nodeTransforms 数组,这里的context 可以看作 AST 转换函数过程中的上下文数据。所有AST 转换函数都可以通过 context 来共享数据。上下文对象中通常会维护程序的当前状态,例如当前转换的节点是哪一个?当前转换的节点的父节点是谁?甚至当前节点是父节点的第几个子节点?等等。这些信息对于编写复杂的转换函数非常有用。所以,接下来我们要做的就是构造转换上下文信息,如下面的代码所示:

01 function transform(ast) {
02   const context = {
03     // 增加 currentNode,用来存储当前正在转换的节点
04     currentNode: null,
05     // 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引
06     childIndex: 0,
07     // 增加 parent,用来存储当前转换节点的父节点
08     parent: null,
09     nodeTransforms: [
10       transformElement,
11       transformText
12     ]
13   }
14
15   traverseNode(ast, context)
16   console.log(dump(ast))
17 }

在上面这段代码中,我们为转换上下文对象扩展了一些重要信息:

  • currentNode:用来存储当前正在转换的节点。
  • childIndex:用来存储当前节点在父节点的 children 中的位置索引。
  • parent:用来存储当前转换节点的父节点。

紧接着,我们需要在合适的地方设置转换上下文对象中的数据,如下面 traverseNode 函数的代码所示:

01 function traverseNode(ast, context) {
02   // 设置当前转换的节点信息 context.currentNode
03   context.currentNode = ast
04
05   const transforms = context.nodeTransforms
06   for (let i = 0; i < transforms.length; i++) {
07     transforms[i](context.currentNode, context)
08   }
09
10   const children = context.currentNode.children
11   if (children) {
12     for (let i = 0; i < children.length; i++) {
13       // 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
14       context.parent = context.currentNode
15       // 设置位置索引
16       context.childIndex = i
17       // 递归地调用时,将 context 透传
18       traverseNode(children[i], context)
19     }
20   }
21 }

观察上面这段代码,其关键点在于,在递归地调用traverseNode 函数进行子节点的转换之前,我们必须设置context.parent 和 context.childIndex 的值,这样才能保证在接下来的递归转换中,context 对象所存储的信息是正确的。

有了上下文数据后,我们就可以实现节点替换功能了。什么是节点替换呢?在对 AST 进行转换的时候,我们可能希望把某些节点替换为其他类型的节点。例如,将所有文本节点替换成一个元素节点。为了完成节点替换,我们需要在上下文对象中添加 context.replaceNode 函数。该函数接收新的 AST 节点作为参数,并使用新节点替换当前正在转换的节点,如下面的代码所示:

01 function transform(ast) {
02   const context = {
03     currentNode: null,
04     parent: null,
05     // 用于替换节点的函数,接收新节点作为参数
06     replaceNode(node) {
07       // 为了替换节点,我们需要修改 AST
08       // 找到当前节点在父节点的 children 中的位置:context.childIndex
09       // 然后使用新节点替换即可
10       context.parent.children[context.childIndex] = node
11       // 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
12       context.currentNode = node
13     },
14     nodeTransforms: [
15       transformElement,
16       transformText
17     ]
18   }
19
20   traverseNode(ast, context)
21   console.log(dump(ast))
22 }

观察上面代码中的 replaceNode 函数。在该函数内,我们首先通过 context.childIndex 属性取得当前节点的位置索引,然后通过 context.parent.children 取得当前节点所在集合,最后配合使用 context.childIndex 与 context.parent.children 即可完成节点替换。另外,由于当前节点已经替换为新节点了,所以我们应该使用新节点更新 context.currentNode 属性的值。

接下来,我们就可以在转换函数中使用 replaceNode 函数对AST 中的节点进行替换了。如下面 transformText 函数的代码所示,它能够将文本节点转换为元素节点:

01 // 转换函数的第二个参数就是 context 对象
02 function transformText(node, context) {
03   if (node.type === 'Text') {
04     // 如果当前转换的节点是文本节点,则调用 context.replaceNode 函数将其替换为元素节点
05     context.replaceNode({
06       type: 'Element',
07       tag: 'span'
08     })
09   }
10 }

如上面的代码所示,转换函数的第二个参数就是 context 对象,所以我们可以在转换函数内部使用该对象上的任意属性或函数。在 transformText 函数内部,首先检查当前转换的节点是否是文本节点,如果是,则调用 context.replaceNode 函数将其替换为新的 span 标签节点。

下面的例子用来验证节点替换功能:

01 const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
02 transform(ast)

运行上面这段代码,其转换前后的结果分别是:

01 // 转换前
02 Root:
03 --Element: div
04 ----Element: p
05 ------Text: VueVue
06 ----Element: p
07 ------Text: TemplateTemplate
08
09 // 转换后
10 Root:
11 --Element: div
12 ----Element: h1
13 ------Element: span
14 ----Element: h1
15 ------Element: span

可以看到,转换后的 AST 中的文本节点全部变为 span 标签节点了。

除了替换节点,有时我们还希望移除当前访问的节点。我们可以通过实现 context.removeNode 函数来达到目的,如下面的代码所示:

01 function transform(ast) {
02   const context = {
03     currentNode: null,
04     parent: null,
05     replaceNode(node) {
06       context.currentNode = node
07       context.parent.children[context.childIndex] = node
08     },
09     // 用于删除当前节点。
10     removeNode() {
11       if (context.parent) {
12         // 调用数组的 splice 方法,根据当前节点的索引删除当前节点
13         context.parent.children.splice(context.childIndex, 1)
14         // 将 context.currentNode 置空
15         context.currentNode = null
16       }
17     },
18     nodeTransforms: [
19       transformElement,
20       transformText
21     ]
22   }
23
24   traverseNode(ast, context)
25   console.log(dump(ast))
26 }

移除当前访问的节点也非常简单,只需要取得其位置索引context.childIndex,再调用数组的 splice 方法将其从所属的children 列表中移除即可。另外,当节点被移除之后,不要忘记将 context.currentNode 的值置空。这里有一点需要注意,由于当前节点被移除了,所以后续的转换函数将不再需要处理该节点。因此,我们需要对 traverseNode 函数做一些调整,如下面的代码所示:

01 function traverseNode(ast, context) {
02   context.currentNode = ast
03
04   const transforms = context.nodeTransforms
05   for (let i = 0; i < transforms.length; i++) {
06     transforms[i](context.currentNode, context)
07     // 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
08     // 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
09     if (!context.currentNode) return
10   }
11
12   const children = context.currentNode.children
13   if (children) {
14     for (let i = 0; i < children.length; i++) {
15       context.parent = context.currentNode
16       context.childIndex = i
17       traverseNode(children[i], context)
18     }
19   }
20 }

在修改后的 traverseNode 函数中,我们增加了一行代码,用于检查 context.currentNode 是否存在。由于任何转换函数都可能移除当前访问的节点,所以每个转换函数执行完毕后,都应该检查当前访问的节点是否已经被移除,如果被某个转换函数移除了,则 traverseNode 直接返回即可,无须做后续的处理。

有了 context.removeNode 函数之后,我们即可实现用于移除文本节点的转换函数,如下面的代码所示:

01 function transformText(node, context) {
02   if (node.type === 'Text') {
03     // 如果是文本节点,直接调用 context.removeNode 函数将其移除即可
04     context.removeNode()
05   }
06 }

配合上面的 transformText 转换函数,运行下面的用例:

01 const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
02 transform(ast)

转换前后输出结果是:

01 // 转换前
02 Root:
03 --Element: div
04 ----Element: p
05 ------Text: VueVue
06 ----Element: p
07 ------Text: TemplateTemplate
08
09 // 转换后
10 Root:
11 --Element: div
12 ----Element: h1
13 ----Element: h1

可以看到,在转换后的 AST 中,将不再有任何文本节点。

4.3、进入与退出

在转换 AST 节点的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待其所有子节点全部转换完毕后再执行。然而,我们目前设计的转换工作流并不支持这一能力。上文中介绍的转换工作流,是一种从根节点开始、顺序执行的工作流,如下图所示:
在这里插入图片描述
从上图中可以看到,Root 根节点第一个被处理,节点层次越深,对它的处理将越靠后。这种顺序处理的工作流存在的问题是,当一个节点被处理时,意味着它的父节点已经被处理完毕了,并且我们无法再回过头重新处理父节点。

更加理想的转换工作流应该如下图所示:
在这里插入图片描述
由上可知,对节点的访问分为两个阶段,即进入阶段和退出阶段。当转换函数处于进入阶段时,它会先进入父节点,再进入子节点。而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。这样,只要我们在退出节点阶段对当前访问的节点进行处理,就一定能够保证其子节点全部处理完毕。

为了实现如上图所示的转换工作流,我们需要重新设计转换函数的能力,如下面 traverseNode 函数的代码所示:

01 function traverseNode(ast, context) {
02   context.currentNode = ast
03   // 1. 增加退出阶段的回调函数数组
04   const exitFns = []
05   const transforms = context.nodeTransforms
06   for (let i = 0; i < transforms.length; i++) {
07     // 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的回调函数
08     const onExit = transforms[i](context.currentNode, context)
09     if (onExit) {
10       // 将退出阶段的回调函数添加到 exitFns 数组中
11       exitFns.push(onExit)
12     }
13     if (!context.currentNode) return
14   }
15
16   const children = context.currentNode.children
17   if (children) {
18     for (let i = 0; i < children.length; i++) {
19       context.parent = context.currentNode
20       context.childIndex = i
21       traverseNode(children[i], context)
22     }
23   }
24
25   // 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
26   // 注意,这里我们要反序执行
27   let i = exitFns.length
28   while (i--) {
29     exitFns[i]()
30   }
31 }

在上面这段代码中,我们增加了一个数组 exitFns,用来存储由转换函数返回的回调函数。接着,在 traverseNode 函数的最后,执行这些缓存在 exitFns 数组中的回调函数。这样就保证了,当退出阶段的回调函数执行时,当前访问的节点的子节点已经全部处理过了。有了这些能力之后,我们在编写转换函数时,可以将转换逻辑编写在退出阶段的回调函数中,从而保证在对当前访问的节点进行转换之前,其子节点一定全部处理完毕了,如下面的代码所示:

01 function transformElement(node, context) {
02   // 进入节点
03
04   // 返回一个会在退出节点时执行的回调函数
05   return () => {
06     // 在这里编写退出节点的逻辑,当这里的代码运行时,当前转换节点的子节点一定处理完毕了
07   }
08 }

另外还有一点需要注意,退出阶段的回调函数是反序执行的。这意味着,如果注册了多个转换函数,则它们的注册顺序将决定代码的执行结果。假设我们注册的两个转换函数分别是transformA 和 transformB,如下面的代码所示:

01 function transform(ast) {
02   const context = {
03     // 省略部分代码
04
05     // 注册两个转换函数,transformA 先于 transformB
06     nodeTransforms: [
07       transformA,
08       transformB
09     ]
10   }
11
12   traverseNode(ast, context)
13   console.log(dump(ast))
14 }

在上面这段代码中,转换函数 transformA 先于 transformB 被注册。这意味着,在执行转换时,transformA 的“进入阶段”会先于 transformB 的“进入阶段”执行,而 transformA 的“退出阶段”将晚于 transformB 的“退出阶段”执行:

01 -- transformA 进入阶段执行
02 ---- transformB 进入阶段执行
03 ---- transformB 退出阶段执行
04 -- transformA 退出阶段执行

这么设计的好处是,转换函数 transformA 将有机会等待transformB 执行完毕后,再根据具体情况决定应该如何工作。

如果将 transformA 与 transformB 的顺序调换,那么转换函数的执行顺序也将改变:

01 -- transformB 进入阶段执行
02 ---- transformA 进入阶段执行
03 ---- transformA 退出阶段执行
04 -- transformB 退出阶段执行

由此可见,当把转换逻辑编写在转换函数的退出阶段时,不仅能够保证所有子节点全部处理完毕,还能够保证所有后续注册的转换函数执行完毕。

5、将模板 AST 转为 JavaScript AST

在上一节中,我们讨论了如何对 AST 进行转换,并实现了一个基本的插件架构,即通过注册自定义的转换函数实现对 AST 的操作。本节,我们将讨论如何将模板 AST 转换为 JavaScript AST,为后续讲解代码生成做铺垫。

为什么要将模板 AST 转换为 JavaScript AST 呢?原因我们已经多次提到:我们需要将模板编译为渲染函数。而渲染函数是由 JavaScript 代码来描述的,因此,我们需要将模板 AST 转换为用于描述渲染函数的 JavaScript AST。

以上一节给出的模板为例:

01 <div><p>Vue</p><p>Template</p></div>

与这段模板等价的渲染函数是:

01 function render() {
02   return h('div', [
03     h('p', 'Vue'),
04     h('p', 'Template')
05   ])
06 }

上面这段渲染函数的 JavaScript 代码所对应的 JavaScript AST 就是我们的转换目标。那么,它对应的 JavaScript AST 是什么样子的呢?与模板 AST 是模板的描述一样,JavaScript AST 是JavaScript 代码的描述。所以,本质上我们需要设计一些数据结构来描述渲染函数的代码。

首先,我们观察上面这段渲染函数的代码。它是一个函数声明,所以我们首先要描述 JavaScript 中的函数声明语句。一个函数声明语句由以下几部分组成:

  • id:函数名称,它是一个标识符 Identifier。
  • params:函数的参数,它是一个数组。
  • body:函数体,由于函数体可以包含多个语句,因此它也是一个数组。

为了简化问题,这里我们不考虑箭头函数、生成器函数、async 函数等情况。那么,根据以上这些信息,我们就可以设计一个基本的数据结构来描述函数声明语句:

01 const FunctionDeclNode = {
02   type: 'FunctionDecl' // 代表该节点是函数声明
03   // 函数的名称是一个标识符,标识符本身也是一个节点
04   id: {
05     type: 'Identifier',
06     name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
07   },
08   params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
09   // 渲染函数的函数体只有一个语句,即 return 语句
10   body: [
11     {
12       type: 'ReturnStatement',
13       return: null // 暂时留空,在后续讲解中补全
14     }
15   ]
16 }

如上面的代码所示,我们使用一个对象来描述一个 JavaScript AST 节点。每个节点都具有 type 字段,该字段用来代表节点的类型。对于函数声明语句来说,它的类型是 FunctionDecl。接着,我们使用 id 字段来存储函数的名称。函数的名称应该是一个合法的标识符,因此 id 字段本身也是一个类型为 Identifier 的节点。当然,我们在设计 JavaScript AST 的时候,可以根据实际需要进行调整。例如,我们完全可以将 id 字段设计为一个字符串类型的值。这样做虽然不完全符合 JavaScript 的语义,但是能够满足我们的需求。对于函数的参数,我们使用 params 数组来存储。目前,我们设计的渲染函数还不需要参数,因此暂时设为空数组。最后,我们使用 body 字段来描述函数的函数体。一个函数的函数体内可以存在多个语句,所以我们使用一个数组来描述它。该数组内的每个元素都对应一条语句,对于渲染函数来说,目前它只有一个返回语句,所以我们使用一个类型为 ReturnStatement 的节点来描述该返回语句。

介绍完函数声明语句的节点结构后,我们再来看一下渲染函数的返回值。渲染函数返回的是虚拟 DOM 节点,具体体现在 h 函数的调用。我们可以使用 CallExpression 类型的节点来描述函数调用语句,如下面的代码所示:

01 const CallExp = {
02   type: 'CallExpression',
03   // 被调用函数的名称,它是一个标识符
04   callee: {
05     type: 'Identifier',
06     name: 'h'
07   },
08   // 参数
09   arguments: []
10 }

类型为 CallExpression 的节点拥有两个属性:

  • callee:用来描述被调用函数的名字称,它本身是一个标识符节点。
  • arguments:被调用函数的形式参数,多个参数的话用数组来描述。

我们再次观察渲染函数的返回值:

01 function render() {
02   // h 函数的第一个参数是一个字符串字面量
03   // h 函数的第二个参数是一个数组
04   return h('div', [/*...*/])
05 }

可以看到,最外层的 h 函数的第一个参数是一个字符串字面量,我们可以使用类型为 StringLiteral 的节点来描述它:

01 const Str = {
02   type: 'StringLiteral',
03   value: 'div'
04 }

最外层的 h 函数的第二个参数是一个数组,我们可以使用类型为 ArrayExpression 的节点来描述它:

01 const Arr = {
02   type: 'ArrayExpression',
03   // 数组中的元素
04   elements: []
05 }

使用上述 CallExpression、StringLiteral、ArrayExpression 等节点来填充渲染函数的返回值,其最终结果如下面的代码所示:

01 const FunctionDeclNode = {
02   type: 'FunctionDecl' // 代表该节点是函数声明
03   // 函数的名称是一个标识符,标识符本身也是一个节点
04   id: {
05     type: 'Identifier',
06     name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
07   },
08   params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
09   // 渲染函数的函数体只有一个语句,即 return 语句
10   body: [
11     {
12       type: 'ReturnStatement',
13       // 最外层的 h 函数调用
14       return: {
15         type: 'CallExpression',
16         callee: { type: 'Identifier', name: 'h' },
17         arguments: [
18           // 第一个参数是字符串字面量 'div'
19           {
20             type: 'StringLiteral',
21             value: 'div'
22           },
23           // 第二个参数是一个数组
24           {
25             type: 'ArrayExpression',
26             elements: [
27               // 数组的第一个元素是 h 函数的调用
28               {
29                 type: 'CallExpression',
30                 callee: { type: 'Identifier', name: 'h' },
31                 arguments: [
32                   // 该 h 函数调用的第一个参数是字符串字面量
33                   { type: 'StringLiteral', value: 'p' },
34                   // 第二个参数也是一个字符串字面量
35                   { type: 'StringLiteral', value: 'Vue' },
36                 ]
37               },
38               // 数组的第二个元素也是 h 函数的调用
39               {
40                 type: 'CallExpression',
41                 callee: { type: 'Identifier', name: 'h' },
42                 arguments: [
43                   // 该 h 函数调用的第一个参数是字符串字面量
44                   { type: 'StringLiteral', value: 'p' },
45                   // 第二个参数也是一个字符串字面量
46                   { type: 'StringLiteral', value: 'Template' },
47                 ]
48               }
49             ]
50           }
51         ]
52       }
53     }
54   ]
55 }

如上面这段 JavaScript AST 的代码所示,它是对渲染函数代码的完整描述。接下来我们的任务是,编写转换函数,将模板AST 转换为上述 JavaScript AST。不过在开始之前,我们需要编写一些用来创建 JavaScript AST 节点的辅助函数,如下面的代码所示:

01 // 用来创建 StringLiteral 节点
02 function createStringLiteral(value) {
03   return {
04     type: 'StringLiteral',
05     value
06   }
07 }
08 // 用来创建 Identifier 节点
09 function createIdentifier(name) {
10   return {
11     type: 'Identifier',
12     name
13   }
14 }
15 // 用来创建 ArrayExpression 节点
16 function createArrayExpression(elements) {
17   return {
18     type: 'ArrayExpression',
19     elements
20   }
21 }
22 // 用来创建 CallExpression 节点
23 function createCallExpression(callee, arguments) {
24   return {
25     type: 'CallExpression',
26     callee: createIdentifier(callee),
27     arguments
28   }
29 }

有了这些辅助函数,我们可以更容易地编写转换代码。

为了把模板 AST 转换为 JavaScript AST,我们同样需要两个转换函数:transformElement 和 transformText,它们分别用来处理标签节点和文本节点。具体实现如下:

01 // 转换文本节点
02 function transformText(node) {
03   // 如果不是文本节点,则什么都不做
04   if (node.type !== 'Text') {
05     return
06   }
07   // 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,
08   // 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可
09   // 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下
10   node.jsNode = createStringLiteral(node.content)
11 }
12
13 // 转换标签节点
14 function transformElement(node) {
15   // 将转换代码编写在退出阶段的回调函数中,
16   // 这样可以保证该标签节点的子节点全部被处理完毕
17   return () => {
18     // 如果被转换的节点不是元素节点,则什么都不做
19     if (node.type !== 'Element') {
20       return
21     }
22
23     // 1. 创建 h 函数调用语句,
24     // h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点
25     // 作为第一个参数
26     const callExp = createCallExpression('h', [
27       createStringLiteral(node.tag)
28     ])
29     // 2. 处理 h 函数调用的参数
30     node.children.length === 1
31       // 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数
32       ? callExp.arguments.push(node.children[0].jsNode)
33       // 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数
34       : callExp.arguments.push(
35         // 数组的每个元素都是子节点的 jsNode
36         createArrayExpression(node.children.map(c => c.jsNode))
37       )
38     // 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下
39     node.jsNode = callExp
40   }
41 }

如上面的代码及注释所示,总体实现并不复杂。有两点需要注意:

  • 在转换标签节点时,我们需要将转换逻辑编写在退出阶段的回调函数内,这样才能保证其子节点全部被处理完毕;
  • 无论是文本节点还是标签节点,它们转换后的 JavaScript AST 节点都存储在节点的 node.jsNode 属性下。

使用上面两个转换函数即可完成标签节点和文本节点的转换,即把模板转换成 h 函数的调用。但是,转换后得到的 AST 只是用来描述渲染函数 render 的返回值的,所以我们最后一步要做的就是,补全 JavaScript AST,即把用来描述 render 函数本身的函数声明语句节点附加到 JavaScript AST 中。这需要我们编写 transformRoot 函数来实现对 Root 根节点的转换:

01 // 转换 Root 根节点
02 function transformRoot(node) {
03   // 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕
04   return () => {
05     // 如果不是根节点,则什么都不做
06     if (node.type !== 'Root') {
07       return
08     }
09     // node 是根节点,根节点的第一个子节点就是模板的根节点,
10     // 当然,这里我们暂时不考虑模板存在多个根节点的情况
11     const vnodeJSAST = node.children[0].jsNode
12     // 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句
13     node.jsNode = {
14       type: 'FunctionDecl',
15       id: { type: 'Identifier', name: 'render' },
16       params: [],
17       body: [
18         {
19           type: 'ReturnStatement',
20           return: vnodeJSAST
21         }
22       ]
23     }
24   }
25 }

经过这一步处理之后,模板 AST 将转换为对应的 JavaScript AST,并且可以通过根节点的 node.jsNode 来访问转换后的JavaScript AST。下一节我们将讨论如何根据转换后得到的JavaScript AST 生成渲染函数代码。

6、代码生成

在上一节中,我们完成了 JavaScript AST 的构造。本节,我们将讨论如何根据 JavaScript AST 生成渲染函数的代码,即代码生成。代码生成本质上是字符串拼接的艺术。我们需要访问JavaScript AST 中的节点,为每一种类型的节点生成相符的JavaScript 代码。

本节,我们将实现 generate 函数来完成代码生成的任务。代码生成也是编译器的最后一步:

01 function compile(template) {
02   // 模板 AST
03   const ast = parse(template)
04   // 将模板 AST 转换为 JavaScript AST
05   transform(ast)
06   // 代码生成
07   const code = generate(ast.jsNode)
08
09   return code
10 }

与 AST 转换一样,代码生成也需要上下文对象。该上下文对象用来维护代码生成过程中程序的运行状态,如下面的代码所示:

01 function generate(node) {
02   const context = {
03     // 存储最终生成的渲染函数代码
04     code: '',
05     // 在生成代码时,通过调用 push 函数完成代码的拼接
06     push(code) {
07       context.code += code
08     }
09   }
10
11   // 调用 genNode 函数完成代码生成的工作,
12   genNode(node, context)
13
14   // 返回渲染函数代码
15   return context.code
16 }

在上面这段 generate 函数的代码中,首先我们定义了上下文对象 context,它包含 context.code 属性,用来存储最终生成的渲染函数代码,还定义了 context.push 函数,用来完成代码拼接,接着调用 genNode 函数完成代码生成的工作,最后将最终生成的渲染函数代码返回。

另外,我们希望最终生成的代码具有较强的可读性,因此我们应该考虑生成代码的格式,例如缩进和换行等。这就需要我们扩展 context 对象,为其增加用来完成换行和缩进的工具函数,如下面的代码所示:

01 function generate(node) {
02   const context = {
03     code: '',
04     push(code) {
05       context.code += code
06     },
07     // 当前缩进的级别,初始值为 0,即没有缩进
08     currentIndent: 0,
09     // 该函数用来换行,即在代码字符串的后面追加 \n 字符,
10     // 另外,换行时应该保留缩进,所以我们还要追加 currentIndent * 2 个空格字符
11     newline() {
12       context.code += '\n' + `  `.repeat(context.currentIndent)
13     },
14     // 用来缩进,即让 currentIndent 自增后,调用换行函数
15     indent() {
16       context.currentIndent++
17       context.newline()
18     },
19     // 取消缩进,即让 currentIndent 自减后,调用换行函数
20     deIndent() {
21       context.currentIndent--
22       context.newline()
23     }
24   }
25
26   genNode(node, context)
27
28   return context.code
29 }

在上面这段代码中,我们增加了 context.currentIndent 属性,它代表缩进的级别,初始值为 0,代表没有缩进,还增加了context.newline() 函数,每次调用该函数时,都会在代码字符串后面追加换行符 \n。由于换行时需要保留缩进,所以我们还要追加 context.currentIndent * 2 个空格字符。这里我们假设缩进为两个空格字符,后续我们可以将其设计为可配置的。同时,我们还增加了 context.indent() 函数用来完成代码缩进,它的原理很简单,即让缩进级别 context.currentIndent 进行自增,再调用 context.newline() 函数。与之对应的context.deIndent() 函数则用来取消缩进,即让缩进级别context.currentIndent 进行自减,再调用 context.newline()函数。

有了这些基础能力之后,我们就可以开始编写 genNode 函数来完成代码生成的工作了。代码生成的原理其实很简单,只需要匹配各种类型的 JavaScript AST 节点,并调用对应的生成函数即可,如下面的代码所示:

01 function genNode(node, context) {
02   switch (node.type) {
03     case 'FunctionDecl':
04       genFunctionDecl(node, context)
05       break
06     case 'ReturnStatement':
07       genReturnStatement(node, context)
08       break
09     case 'CallExpression':
10       genCallExpression(node, context)
11       break
12     case 'StringLiteral':
13       genStringLiteral(node, context)
14       break
15     case 'ArrayExpression':
16       genArrayExpression(node, context)
17       break
18   }
19 }

在 genNode 函数内部,我们使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。

  • 对于 FunctionDecl 节点,使用 genFunctionDecl 函数为该类型节点生成对应的 JavaScript 代码。
  • 对于 ReturnStatement 节点,使用 genReturnStatement 函数为该类型节点生成对应的 JavaScript 代码。
  • 对于 CallExpression 节点,使用 genCallExpression 函数为该类型节点生成对应的 JavaScript 代码。
  • 对于 StringLiteral 节点,使用 genStringLiteral 函数为该类型节点生成对应的 JavaScript 代码。
  • 对于 ArrayExpression 节点,使用 genArrayExpression 函数为该类型节点生成对应的 JavaScript 代码。

由于我们目前只涉及这五种类型的 JavaScript 节点,所以现在的 genNode 函数足够完成上述案例。当然,如果后续需要增加节点类型,只需要在 genNode 函数中添加相应的处理分支即可。

接下来,我们将逐步完善代码生成工作。首先,我们来实现函数声明语句的代码生成,即 genFunctionDecl 函数,如下面的代码所示:

01 function genFunctionDecl(node, context) {
02   // 从 context 对象中取出工具函数
03   const { push, indent, deIndent } = context
04   // node.id 是一个标识符,用来描述函数的名称,即 node.id.name
05   push(`function ${node.id.name} `)
06   push(`(`)
07   // 调用 genNodeList 为函数的参数生成代码
08   genNodeList(node.params, context)
09   push(`) `)
10   push(`{`)
11   // 缩进
12   indent()
13   // 为函数体生成代码,这里递归地调用了 genNode 函数
14   node.body.forEach(n => genNode(n, context))
15   // 取消缩进
16   deIndent()
17   push(`}`)
18 }

genFunctionDecl 函数用来为函数声明类型的节点生成对应的JavaScript 代码。以渲染函数的声明节点为例,它最终生成的代码将会是:

01 function render () {
02   ... 函数体
03 }

另外我们注意到,在 genFunctionDecl 函数内部调用了genNodeList 函数来为函数的参数生成对应的代码。它的实现如下:

01 function genNodeList(nodes, context) {
02   const { push } = context
03   for (let i = 0; i < nodes.length; i++) {
04     const node = nodes[i]
05     genNode(node, context)
06     if (i < nodes.length - 1) {
07       push(', ')
08     }
09   }
10 }

genNodeList 函数接收一个节点数组作为参数,并为每一个节点递归地调用 genNode 函数完成代码生成工作。这里要注意的一点是,每处理完一个节点,需要在生成的代码后面拼接逗号字符(,)。举例来说:

01 // 如果节点数组为
02 const node = [节点 1, 节点 2, 节点 3]
03 // 那么生成的代码将类似于
04 '节点 1,节点 2,节点 3'
05 // 如果在这段代码的前后分别添加圆括号,那么它将可用于函数的参数声明
06 ('节点 1,节点 2,节点 3')
07 // 如果在这段代码的前后分别添加方括号,那么它将是一个数组
08 ['节点 1,节点 2,节点 3']

由上例可知,genNodeList 函数会在节点代码之间补充逗号字符。实际上,genArrayExpression 函数就利用了这个特点来实现对数组表达式的代码生成,如下面的代码所示:

01 function genArrayExpression(node, context) {
02   const { push } = context
03   // 追加方括号
04   push('[')
05   // 调用 genNodeList 为数组元素生成代码
06   genNodeList(node.elements, context)
07   // 补全方括号
08   push(']')
09 }

不过,由于目前渲染函数暂时没有接收任何参数,所以genNodeList 函数不会为其生成任何代码。对于genFunctionDecl 函数,另外需要注意的是,由于函数体本身也是一个节点数组,所以我们需要遍历它并递归地调用genNode 函数生成代码。

对于 ReturnStatement 和 StringLiteral 类型的节点来说,为它们生成代码很简单,如下所示:

01 function genReturnStatement(node, context) {
02   const { push } = context
03   // 追加 return 关键字和空格
04   push(`return `)
05   // 调用 genNode 函数递归地生成返回值代码
06   genNode(node.return, context)
07 }
08
09 function genStringLiteral(node, context) {
10   const { push } = context
11   // 对于字符串字面量,只需要追加与 node.value 对应的字符串即可
12   push(`'${node.value}'`)
13 }

最后,只剩下 genCallExpression 函数了,它的实现如下:

01 function genCallExpression(node, context) {
02   const { push } = context
03   // 取得被调用函数名称和参数列表
04   const { callee, arguments: args } = node
05   // 生成函数调用代码
06   push(`${callee.name}(`)
07   // 调用 genNodeList 生成参数代码
08   genNodeList(args, context)
09   // 补全括号
10   push(`)`)
11 }

可以看到,在 genCallExpression 函数内,我们也用到了genNodeList 函数来为函数调用时的参数生成对应的代码。配合上述生成器函数的实现,我们将得到符合预期的渲染函数代码。运行如下测试用例:

01 const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
02 transform(ast)
03 const code = generate(ast.jsNode)

最终得到的代码字符串如下:

01 function render () {
02   return h('div', [h('p', 'Vue'), h('p', 'Template')])
03 }

7、总结

我们首先讨论了 Vue.js 模板编译器的工作流程。Vue.js 的模板编译器用于把模板编译为渲染函数。它的工作流程大致分为三个步骤。

(1) 分析模板,将其解析为模板 AST。
(2) 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
(3) 根据 JavaScript AST 生成渲染函数代码。

  • 23
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值