精读《Vuejs设计与实现》第 15 章(编译器核心技术)

编译技术是一门庞大的学科,我们无法用几个章节对其做完善的讲解。

但作为前端工程师,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(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. 在"状态1"下,读取第一个字符 <,转移至"状态2"(标签开始)。
  3. 在"状态2"下,读取字符 p,由于其为字母,转移至"状态3"(标签名称)。
  4. 在"状态3"下,读取字符 > ,回到"状态1",记录标签名称 p
  5. 在"状态1"下,读取字符 V,转移至"状态4"(文本)。
  6. 在"状态4"下,读取字符直到遇到 < ,再次转至"状态2",记录文本内容 Vue
  7. 在"状态2"下,读取字符 /,进入"状态5"(结束标签)。
  8. 在"状态5"下,读取字符 p,进入"状态6"(结束标签名称)。
  9. 在"状态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 语句来匹配不同类型的节点,并调用与之对应的生成器函数。

  1. 对于 FunctionDecl 节点,使用 genFunctionDecl 函数为该类型节点生成对应的 JavaScript 代码。
  2. 对于 ReturnStatement 节点,使用 genReturnStatement 函数为该类型节点生成对应的 JavaScript 代码。
  3. 对于 CallExpression 节点,使用 genCallExpression 函数为该类型节点生成对应的 JavaScript 代码。
  4. 对于 StringLiteral 节点,使用 genStringLiteral 函数为该类型节点生成对应的 JavaScript 代码。
  5. 对于 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 模板编译器,它用于将模板编译为渲染函数,工作流程分为三个步骤:

  1. 分析模板,将其解析为模板 AST。
  2. 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
  3. 根据 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 节点编写对应的代码生成函数。为了让生成的代码具有更强的可读性,我们还讨论了如何对生成的代码进行缩进和换行。我们将用于缩进和换行的代码封装为工具函数,并且定义到代码生成过程中的上下文对象中。

相关推荐
J总裁的小芒果5 分钟前
Vue3 el-table 默认选中 传入的数组
前端·javascript·elementui·typescript
Lei_zhen967 分钟前
记录一次electron-builder报错ENOENT: no such file or directory, rename xxxx的问题
前端·javascript·electron
辣条小哥哥9 分钟前
electron主进程和渲染进程之间的通信
javascript·electron·ecmascript
咖喱鱼蛋10 分钟前
Electron一些概念理解
前端·javascript·electron
yqcoder11 分钟前
Vue3 + Vite + Electron + TS 项目构建
前端·javascript·vue.js
鑫宝Code28 分钟前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架
Mr_Xuhhh2 小时前
重生之我在学环境变量
linux·运维·服务器·前端·chrome·算法
永乐春秋3 小时前
WEB攻防-通用漏洞&文件上传&js验证&mime&user.ini&语言特性
前端
鸽鸽程序猿3 小时前
【前端】CSS
前端·css
ggdpzhk3 小时前
VUE:基于MVVN的前端js框架
前端·javascript·vue.js