编译器核心技术概览

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

模板 DSL 的编译器

编译器其实只是一段程序,它用来将 “一种语言 A” 翻译成 “另外一种语言 B”。其中,语言 A 通常叫做源代码(source code) ,语言 B 通常叫做目标代码(object code 或 target code)。编译器将源代码翻译为目标代码的过程叫做编译(compile)。完整的编译过程通常包括词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤。

compiler01.png

可以看到,整个编译过程分为编译前端和编译后端。编译前端包括词法分析、语法分析和语义分析,它常用与目标平台无关,仅负责分析源代码。编译后端则通常与目标平台有关,编译后端设计中间代码生成和优化以及目标代码生成。但是编译后端并不一定会包含中间代码生成和优化这两个环节,这取决于具体的场景和实现。中间代码生成和优化这两个环节有时也叫 “中端”。

上图展示了 “教科书” 式的编译模型,但 Vue.js 的模板作为 DSL,其编译流程会有所不同。对于 Vue.js 模板编译器来说,源代码就是组件的模板,而目标代码是能够在浏览器平台上运行的 JavaScript 代码,或其他拥有 JavaScript 运行时的平台代码。

源代码:

<div>
  <h1 :id="dynamicId">
    Vue Template
  </h1>
</div>

目标代码:

function render() {
  return h('div', [
    h('h1', { id: dynamicId }, 'Vue Template')
  ])
}
js

Vue.js 模板编译器的目标代码其实就是渲染函数。详细来说,Vue.js 模板编译器首先会对模板进行词法分析和语法分析,得到模板 AST。接着,将模板 AST 转换(transform)成 JavaScript AST。最后,根据 JavaScript AST 生成 JavaScript 代码。

下图是 Vue.js 模板编译器的工作流程。

compiler02.png

AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。所谓模板 AST,其实就是用来描述模板的抽象语法树。举个例子,假设我们有如下模板。

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

这段代码会被编译为如下所示的 AST:

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,不带有前缀 v0
              exp: {
                // 表达式节点
                type: 'Expression',
                content: 'ok'
              }
            }
          ]
        }
      ]
    }
  ]
]
js

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

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

我们可以通过封装 parse 函数来完成对模板的词法分析和语法分析,得到模板 AST。

compiler03.png

我们可以用下面的代码来表达模板解析的过程:

const template = `
  <div>
    <h1 v-if="ok">Vue Template</h1>
  </div>
`

const templateAST = parse(template)
js

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

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

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

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

我们可以封装 transform 函数来完成模板 AST 到 JavaScript AST 的转换工作。

compiler04.png

同样,我们也可以用下面的代码来表示:

const templateAST = parse(template)
const jsAST = transform(templateAST)
js

我们会在后面详细介绍 JavaScript AST 的结构。

有了 JavaScript AST 后,我们就可以根据它生成渲染函数了,这一步可以通过封装 generate 函数来完成。

compiler05.png

我们也可以用下面的代码来表达代码生成的过程:

const templateAST = parse(template)
const jsAST = transform(templateAST)
const code = generate(jsAST)
js

上面这段代码中,generate 函数会将渲染函数的代码以字符串的形式返回,并存储在常量中。下图是将 Vue.js 模板编译为渲染函数的完整流程。

compiler06.png

parser 的实现原理

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

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

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

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

<p>Vue</p>

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

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

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

compiler07.png

我们用自然语言来描述图中给出的状态迁移过程。

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

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

另外,图中中给出的状态机并不严谨。实际上,解析 HTML 并构造 Token 的过程是有规范可循的。在 WHATWG 发布的关于浏览器解析 HTML 的规范中,详细阐述了状态迁移。下图截取了该规范中定义的在 “初始状态” 下状态机的状态迁移过程。

https://html.spec.whatwg.org/multipage/parsing.html#data-state

WHATWG(Web Hypertext Application Technology Working GroupWeb 超文本应用程序技术工作组)是一个负责维护与开发 Web 标准的社区,他们的工作成果包括 DOM、Fetch API,和 HTML。一些来自 Apple、Mozilla,和 Opera 的员工在 2004 年建立了 WHATWG。

compiler08.png

可以看到,在 “初始状态” (Data State)下,当遇到字符 < 时,状态机会迁移到 tag open state,即 “标签开始状态“ 。如果遇到字符 < 以外的字符,规范中也有对应的说明,应该让状态机迁移到怎样的状态。不过 Vue.js 的模板作为一个 DSL,并非必须遵守该规范。但 Vue.js 的模板毕竟是类 HTML 的实现,因此,也需要尽可能按照规范来做,不会有什么坏处。更重要的一点是,规范中已经定义了非常详细的状态迁移过程,这对于我们编写解析器非常有帮助。

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

// 定义状态机的状态
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 tokenzie(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 数组中
          // tip:cahrs 数组中缓存的字符就是标签名称
          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,并添加到 Token 数组中
          // tip: cahrs 数组中缓存的字符就是文本内容
          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
          // tip: cahrs 数组中缓存的字符就是标签名称
          tokens.push({
            type: 'tagEnd',
            name: chars.join('')
          })
          // 3. 清空已消费的 chars 数组
          chars.length = 0
          // 4. 消费当前字符 >
          str = str.slice(1)
        }
        break;
    }
  }

  // 返回 tokens
  return tokens
}
js

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

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

const tokens = tokenzie(`<p>Vue</p>`)

// [
//   { type: 'tag', name: 'p' },
//   { type: 'text', content: 'Vue' },
//   { type: 'tagEnd', name: 'p' }
// ]
js

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

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

构造 AST

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

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

<div>
  <p>Vue</p>
  <p>Template</p>
</div>
html

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

const ast = {
  type: 'Root',
  children: [
    {
      type: 'Element',
      tag: 'div',
      children: [
        {
          type: 'Element',
          tag: 'p',
          children: [
            {
              type: 'Text',
              content: 'Vue'
            }
          ]
        }
      ]
    },
    {
      type: 'Element',
      tag: 'div',
      children: [
        {
          type: 'Element',
          tag: 'p',
          children: [
            {
              type: 'Text',
              content: 'Template'
            }
          ]
        }
      ]
    }
  ]
}
js

可以看到,AST 在结构上与模板是 “同构” 的,它们都具有树形结构。

compiler09.png

了解了 AST 的结构,接下来我们的任务是,使用程序根据模板解析后生成的 Token 构造出这样一颗 AST。首先,我们使用上一节的 tokenize 函数将开头给出的模板进行标记化。解析这段模板得到的 tokens 如下所示。

const tokens = tokenzie(`<div><p>Vue</p><p>Template</p></div>`)
js

执行上段代码,我们将得到如下 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 结束标签节点
]
js

根据 Token 列表构建 AST 的过程,其实就是对 Token 列表进行扫描的过程。从第一个 Token 开始,顺序地扫描整个 Token 列表,直到列表中的所有 Token 处理完毕。在这个过程中,我们需要维护一个栈 elementStack ,这个栈将用于维护元素间的父子关系。每遇到一个开始标签节点,我们就构造一个 Element 类型的 AST 节点,并将其亚茹栈中。类似地,每当遇到一个结束标签节点,我们就将当前栈顶的节点弹出。这样,栈顶的节点将始终充当父节点的角色。扫描过程中遇到的所有节点,都会作为当前栈顶节点的子节点,并添加到栈顶节点的 children 属性下。

下图给出在扫描 Token 列表之前,Token 列表、父级元素栈和 AST 三者的状态。

compiler10.png

在图中,左侧的事 Token 列表,我们将会按照从上到下的顺序扫描 Token 列表,中间和右侧分别展示了栈 elementStack 的状态和 AST 的状态。可以看到,它们最初都只有 Root 根节点。

接着,我们对 Token 列表进行扫描。首先,扫描到第一个 Token,即 “开始标签(div)”。

compiler11.png

由于当前扫描到的 Token 是一个开始标签节点,因此我们创建一个类型为 Element 的 AST 节点 Element(div) ,然后将该节点作为当前栈顶节点的子节点。由于当前栈顶节是 Root 根节点,所以我们将新建的 Element(div) 节点作为 Root 根节点的子节点添加到 AST 中,最后将新建的 Element(div) 节点压入 elementStack 栈。

接着,我们扫描下一个 Token。

扫描到的第二个 Token 也是一个开始标签节点,因此我们创建一个类型为 Element 的 AST 节点 Element(p),然后将该节点作为当前栈顶节点的子节点。由于当前栈顶节点为 Element(div) 节点,所以我们将新建的 Element(div) 节点的子节点添加到 AST 中,最后将新建的 Element(p) 节点压入 elementStack 栈。

compiler12.png

接着,我们扫描下一个 Token。扫描到的第三个 Token 是一个文本节点,于是我们创建一个类型为 Text 的 AST 节点 Text(vue) ,于是我们创建一个类型为 Text 的 AST 节点 Text(Vue),然后将该节点作为当前栈顶节点的子节点。当前栈顶节点为 Element(p) 节点,所以我们将新建的 Text(p) 节点作为 Element(p) 节点的子节点添加到 AST 中。

compiler13.png

接着,扫描下一个 Token。此时扫描的 Token 是一个结束标签,所以我们需要将栈顶的 Element(p) 节点从 elementStack 栈中弹出。

compiler14.png

接着,扫描下一个 Token。此时扫描到的是一个开始标签,我们为它新建一个 AST 节点 Element(p) ,并将其作为当前栈顶节点 Element(div) 的子节点。最后,将 Element(p) 压入 elementStack 栈中,使其成为新的栈顶节点。

compiler15.png

接着,开始扫描下一个 Token。此时扫描到的 Token 是一个文本节点,所以只需要为其创建一个相应的 AST 节点 Text(Template) 即可,然乎将其作为当前栈顶节点 Element(p) 的子节点添加到 AST 中。

compiler16.png

接着,扫描下一个 Token。此时扫描到的 Token 是一个结束标签,于是我们将当前的栈顶节点 Element(p)elementStack 栈中弹出。

compiler17.png

接着,扫描下一个 Token。此时,扫描到最后一个 Token,它是一个 div 结束标签,所以我们需要再次将当前栈顶节点 Element(div)elementStack 栈中弹出。至此,所有 Token 都被扫描完毕,AST 构建完成。

compiler18.png

下图是最终状态。在所有 Token 扫描完毕后,一颗 AST 就构建完成了。

compiler19.png

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

// parse 函数接收模板作为参数
function parse(str) {
  // 首先对模板进行标记化,得到 tokens
  const tokens = tokenzie(str)
  //  创建 Root 根节点
  const root = {
    type: 'Root',
    children: []
  }
  // 创建 elementStack 栈
  const elementStack = [root]

  // 开启 while 循环扫描 tokens
  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
}
js

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

const ast = parse('<div><p>Vue</p><p>Template</p></div>')
js

运行这句代码,我们会得到与本节开头给出的 AST 一致的结果。不过当前的实现仍然存在很多问题。这些问题我们会在后面详细讲解。

AST 转换与插件化结构

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

compiler20.png

其中 transform 函数就是用来完成 AST 转换工作的。

节点的访问

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

const { parse } = require('../vue/compiler')

function dump(node, indent = 0) {
  // 节点类型
  const type = node.type
  // 节点的描述,如果是根节点,则没有描述
  // 如果是 Element 类型的节点,则使用 node.tag 作为节点的描述
  // 如果是 Text 类型的节点,则使用 ndoe.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))
  } 
}

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

dump(ast)
js

我们还是使用上一节的例子,使用 dump 函数输出结果。

Root: 
--Element: div
----Element: p
------Text: Vue
----Element: p
------Text: Template
js

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

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

function traverseNode(ast) {
  // 当前节点,ast 本身就是 Root 节点
  const currentNode = ast

  console.log(currentNode)

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = currentNode.children
  if (children) {
    children.forEach(item => {
      traverseNode(item)
    })
  }
}
js

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

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) {
    children.forEach(item => {
      traverseNode(item)
    })
  }
}
js

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

function transform(ast) {
  // 调用 traverseNode 完成转换
  traverseNode(ast)
  // 打印 AST 信息
  dump(ast)
}
js

运行上段代码,我们可以得到如下输出:

Root: 
--Element: div
----Element: h1
------Text: Vue
----Element: h1
------Text: Template
js

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

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

function traverseNode(ast) {
  // 当前节点,ast 本身就是 Root 节点
  const currentNode = ast

  // 当节点进行操作
  if (currentNode.type === 'Element' && currentNode.tag === 'p') {
    // 将所有 p 标签转换为 h1 标签
    currentNode.tag = 'h1'
  }
  if (currentNode.type === 'Text') {
    currentNode.content = currentNode.content.repeat(2)
  }

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = currentNode.children
  if (children) {
    children.forEach(item => {
      traverseNode(item)
    })
  }
}
js

最终,我们可以得到如下输出:

Root: 
--Element: div
----Element: h1
------Text: VueVue
----Element: h1
------Text: TemplateTemplate
js

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

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

function traverseNode(ast, context) {
  // 当前节点,ast 本身就是 Root 节点
  const currentNode = ast

  // context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
  const transforms = context.nodeTransforms || []
  transforms.forEach(cur => {
    // 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
    cur(currentNode, context)
  })

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = currentNode.children
  if (children) {
    children.forEach(cur => {
      traverseNode(cur, context)
    })
  }
}
js

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

有了修改后的 traverseNode 函数,我们就可以这样使用它。

function transformElement(node) {
  if (node.type === 'Element' && node.tag === 'p') {
    // 将所有 p 标签转换为 h1 标签
    node.tag = 'h1'
  }
}
function transdormText(node) {
  if (node.type === 'Text') {
    node.content = node.content.repeat(2)
  }
}

function transform(ast) {
  // 在 transform 函数内创建 context 对象
  const context = {
    // 注册 nodeTransforms 数组
    nodeTransforms: [
      transformElement,
      transdormText
    ]
  }

  // 调用 traverseNode 完成转换
  traverseNode(ast, context)
  // 打印 AST 信息
  dump(ast)
}
js

运行上述代码,可以实现和改造前同样的打印效果:

Root: 
--Element: div
----Element: h1
------Text: VueVue
----Element: h1
------Text: TemplateTemplate
js

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

转换上下文与节点操作

在上下文中,我们将转换函数注册到 context.nodeTransform 数组中。那么,为什么要使用 context 对象呢?直接定义一个数组不可以吗?你可能或多或少听说过关于 Context(上下文)的内容,我们可以把 Context 看做程序在某个范围内的 “全局变量” 。实际上,上下文并不是一个具象的东西,它依赖于具体的使用场景。

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

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

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

function transform(ast) {
  // 在 transform 函数内创建 context 对象
  const context = {
    // 增加 currentNode,存储当前正在转换的节点
    currentNode: null,
    // 增加 childIndex,存储当前节点在父节点的 children 中的位置索引
    childIndex: 0,
    // 增加 parent,存储当前转换节点的父节点
    parent: null,
    // 注册 nodeTransforms 数组
    nodeTransforms: [
      transformElement,
      transdormText
    ]
  }

  // 调用 traverseNode 完成转换
  traverseNode(ast, context)
  // 打印 AST 信息
  dump(ast)
}
js

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

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

紧接着,我们需要在合适的地方设置转换上下文对象中的数据。

function traverseNode(ast, context) {
  // 当前节点,ast 本身就是 Root 节点
  context.currentNode = ast

  // context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
  const transforms = context.nodeTransforms || []
  transforms.forEach(cur => {
    // 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
    cur(context.currentNode, context)
  })

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = context.currentNode.children
  if (children) {
    children.forEach((cur, i) => {
      // 设置父节点
      context.parent = context.currentNode
      // 设置位置索引
      context.childIndex = i
      // 递归调用
      traverseNode(cur, context)
    })
  }
}
js

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

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

function transform(ast) {
  // 在 transform 函数内创建 context 对象
  const context = {
    // 增加 currentNode,存储当前正在转换的节点
    currentNode: null,
    // 增加 childIndex,存储当前节点在父节点的 children 中的位置索引
    childIndex: 0,
    // 增加 parent,存储当前转换节点的父节点
    parent: null,
    // 用于替换节点的函数,接收新节点作为参数
    replaceNode(node) {
      // 为了替换节点,我们需要修改 AST
      // 找到当前节点在父节点的 children 中的位置:context.childIndex
      // 然后使用新节点替换即可
      context.parent.children[context.childIndex] = node
      // 由于当前新节点已经被新节点替换掉,因此我们需要将 currentNode 更新为新节点
      context.currentNode = node
    },
    // 注册 nodeTransforms 数组
    nodeTransforms: [
      transformElement,
      transdormText
    ]
  }

  // 调用 traverseNode 完成转换
  traverseNode(ast, context)
  // 打印 AST 信息
  dump(ast)
}
js

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

接下来,我们就可以在转换函数中使用 replaceNode 函数对 AST 中的节点进行替换了。

function transdormText(node, context) {
  if (node.type === 'Text') {
    // 如果当前转换的节点是文本节点,调用 replaceNode 函数将其替换为元素节点
    context.replaceNode({
      type: 'Element',
      tag: 'span'
    })
  }
}
js

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

const ast = parse('<div><p>Vue</p><p>Template</p></div>')
transform(ast)
js

运行上段代码,转换前后的结果分别是。

// 转换前
Root: 
--Element: div
----Element: h1
------Text: VueVue
----Element: h1
------Text: TemplateTemplate

// 转换后
Root: 
--Element: div
----Element: h1
------Element: span
----Element: h1
------Element: span
js

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

处理替换节点,有时我们还希望移除当前访问的节点。我们可以通过实现 context.removeNode 函数来达到目的。

function transform(ast) {
  // 在 transform 函数内创建 context 对象
  const context = {
    // 增加 currentNode,存储当前正在转换的节点
    currentNode: null,
    // 增加 childIndex,存储当前节点在父节点的 children 中的位置索引
    childIndex: 0,
    // 增加 parent,存储当前转换节点的父节点
    parent: null,
    // 用于替换节点的函数,接收新节点作为参数
    replaceNode(node) {
      // 为了替换节点,我们需要修改 AST
      // 找到当前节点在父节点的 children 中的位置:context.childIndex
      // 然后使用新节点替换即可
      context.parent.children[context.childIndex] = node
      // 由于当前新节点已经被新节点替换掉,因此我们需要将 currentNode 更新为新节点
      context.currentNode = node
    },
    // 删除当前节点
    removeNode() {
      if (context.parent) {
        // 调用数组的 splice 方法,根据当前节点的索引删除当前节点
        context.parent.children.splice(context.childIndex, 1)
        // 将 context.currentNode 置空
        context.currentNode = null
      }
    },
    // 注册 nodeTransforms 数组
    nodeTransforms: [
      transformElement,
      transdormText
    ]
  }

  // 调用 traverseNode 完成转换
  traverseNode(ast, context)
  // 打印 AST 信息
  dump(ast)
}
js

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

function traverseNode(ast, context) {
  // 当前节点,ast 本身就是 Root 节点
  context.currentNode = ast

  // context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
  const transforms = context.nodeTransforms || []
  for (let i = 0; i < transforms.length; i++) {
    // 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
    transforms[i](context.currentNode, context)
    // 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
    // 都应该检查当前节点是否以经被移除,如果被移除,直接返回即可
    if (!context.currentNode) return
  }

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = context.currentNode.children
  if (children) {
    children.forEach((cur, i) => {
      // 设置父节点
      context.parent = context.currentNode
      // 设置位置索引
      context.childIndex = i
      // 递归调用
      traverseNode(cur, context)
    })
  }
}
js

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

有了 context.removeNode 函数之后,我们即可实现用于移除文本节点的转换函数。

function transdormText(node, context) {
  if (node.type === 'Text') {
    // 如果是文本节点,直接调用 context.rmeoveNode 函数将其移除即可
    context.removeNode()
  }
}
js

继续下面的用例:

const ast = parse('<div><p>Vue</p><p>Template</p></div>')
transform(ast)
js

转换前后输出结果是:

// 转换前
Root: 
--Element: div
----Element: h1
------Text: VueVue
----Element: h1
------Text: TemplateTemplate

// 转换后
Root: 
--Element: div
----Element: h1
----Element: h1
js

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

进入与退出

在转换 AST 节点的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待所有子节点全部转换完毕后再执行。然而,我们目前设计的转换工作流并不支持这一能力。上文中介绍的转换工作流,是一种从根节点开始、顺序执行的工作流。

compiler21.png

从图中可以看出,Root 根节点第一个被处理,节点层次越深,对它的处理将越靠后,这种顺序处理的工作流存在的问题是,当一个节点被处理时,意味着它的父节点已经被处理完毕了,并且我们无法再回头重新处理父节点。

更加理想的转换工作流应该如下图所示。

compiler22.png

对节点的访问分为两个阶段,即进入阶段和退出阶段。当转换函数处理进入阶段时,它会先进入父节点,再进入子节点。当转换函数出去退出阶段时,会先退出子节点,再退出父节点。这样,只要我们再退出节点阶段对当前访问的节点进行处理,就一定能保证其子节点全部处理完毕。

为了实现图中所示的转换工作流,我们需要重新设计转换函数的能力。

function traverseNode(ast, context) {
  // 当前节点,ast 本身就是 Root 节点
  context.currentNode = ast
  // 增加退出阶段的回调函数数组
  const exitFns = []

  // context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
  const transforms = context.nodeTransforms || []
  for (let i = 0; i < transforms.length; i++) {
    // 转换函数可以返回另外一个函数,该函数作为退出阶段的回调函数
    const onExit = transforms[i](context.currentNode, context)

    if (onExit) {
      // 将退出阶段的回调函数添加到 exitFns 数组中
      exitFns.push(onExit)
    }
    
    // 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
    // 都应该检查当前节点是否以经被移除,如果被移除,直接返回即可
    if (!context.currentNode) return
  }

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = context.currentNode.children
  if (children) {
    children.forEach((cur, i) => {
      // 设置父节点
      context.parent = context.currentNode
      // 设置位置索引
      context.childIndex = i
      // 递归调用
      traverseNode(cur, context)
    })
  }

  // 节点处理的最后阶段执行缓存到 exitFns 中的回调函数
  // tip:这里我们要反序执行
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}
js

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

function transformElement(node) {
  // 进入节点

  // 返回一个会在退出节点执行的回调函数
  return () => {
    // 在这里编写退出节点的逻辑,当这里的代码运行时,当前转换的子节点一定处理完毕了
  }
}
js

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

function transform(ast) {
  const context = {
		// ...
    nodeTransforms: [
      transformA,
      transdormB
    ]
  }

  traverseNode(ast, context)
  dump(ast)
}
js

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

-- transformA 进入阶段执行
---- transformB 进入阶段执行
---- transformB 退出阶段执行
-- transformA 退出阶段执行
js

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

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

-- transformB 进入阶段执行
---- transformA 进入阶段执行
---- transformA 退出阶段执行
-- transformB 退出阶段执行
js

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

将模板 AST 转为 JavaScript AST

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

为什么要将模板 AST 转化为 JavaScript AST?我们需要将模板编译为渲染函数。而渲染函数是由 JavaScript 代码来描述的,因此,我们需要将模板 AST 转换为用于描述渲染函数的 JavaScript AST。

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

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

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

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

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

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

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

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

const FunctionDeclNode = {
  type: 'FunctionDecl', // 标识该节点是函数声明
  // 函数名称是一个标识符,标识符本身也是一个节点
  id: {
    type: 'Identifier',
    name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
  },
  parmas: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
  // 渲染函数的函数体只有一个语句,即 return 语句
  body: [
    {
      type: 'ReturnStatement',
      return: null // 暂时留空,后续会继续讲解
    }
  ]
}
js

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

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

const CallExp = {
  type: 'CallExpression',
  // 被调用函数的名称,它是一个标识符
  callee: {
    type: 'Identifier',
    name: 'h'
  },
  // 参数
  arguments: []
}
js

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

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

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

function render() {
  // h 函数的第一个参数是一个字符串字面量
  // h 函数的第二个参数是一个数组
  return h('div', [])
}
js

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

const Str = {
  type: 'StringLiteral',
  value: 'div'
}
js

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

const Arr = {
  type: 'ArrayExpression',
  // 数组中的元素
  elements: []
}
js

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

const FunctionDeclNode = {
  type: 'FunctionDecl', // 标识该节点是函数声明
  // 函数名称是一个标识符,标识符本身也是一个节点
  id: {
    type: 'Identifier',
    name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
  },
  parmas: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
  // 渲染函数的函数体只有一个语句,即 return 语句
  body: [
    {
      type: 'ReturnStatement',
      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' },
                  ]
                }
            ]
          }
        ]
      }
    }
  ]
}
js

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

// 用来创建 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
  }
}
js

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

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

// 转换文本节点
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
  }
}
js

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

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

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

// 转换 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
        }
      ]
    }
  }
}
js

经过这一步处理之后,模板 AST 将转换为对应 JavaScript,并且可以通过根节点的 node.jsNode 来访问转换后的 JavaScript AST

const { parse } = require('../vue/compiler/parse')
const { transform } = require('../vue/compiler/transform')

function compiler(template) {
  // 模板 AST
  const ast = parse(template)
  // 将模板 AST 转换为 javaScript AST
  transform(ast)
}

compiler('<div><p>Vue</p><p>Template</p></div>')
js

代码生成

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

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

const { parse } = require('../vue/compiler/parse')
const { transform } = require('../vue/compiler/transform')

function compiler(template) {
  // 模板 AST
  const ast = parse(template)
  // 将模板 AST 转换为 javaScript AST
  transform(ast)
  // 代码生成
  const code = genertae(ast.jsNode)
  return code
}

compiler('<div><p>Vue</p><p>Template</p></div>')
js

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

function genertae(node) {
  const context = {
    // 存储最终生成的渲染函数代码
    code: '',
    // 生成代码时,通过调用 push 函数完成代码拼接
    push(code) {
      context.code += code
    }
  }

  // 调用 genNode 函数完成代码生成工作
  genNode(node, context)

  // 返回渲染函数代码
  return context.code
}
js

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

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

function genertae(node) {
  const context = {
    // 存储最终生成的渲染函数代码
    code: '',
    // 生成代码时,通过调用 push 函数完成代码拼接
    push(code) {
      context.code += code
    },
    // 当前缩进级别,初始值为 0,即没有缩进
    currentIndent: 0,
    // 该函数用来换行,即在代码字符串的后买你追加 \n 字符
    // 另外,换行时应该保留缩进,所以我们还要追加 currentIdent * 2 个空格字符
    newLine() {
      context.code += '\n' + `  `.repeat(context.currentIndent)
    },
    // 用来缩进,即让 currentIdent 自增后,调用换行函数
    indent() {
      context.currentIndent++
      context.newLine()
    },
    // 取消缩进,即让 currentIdent 自减后,调用换行函数
    deIndent() {
      context.currentIndent--
      context.newLine()
    }
  }

  // 调用 genNode 函数完成代码生成工作
  genNode(node, context)

  // 返回渲染函数代码
  return context.code
}
js

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

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

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;
  }
}
js

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

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

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

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

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(`}`)
}
js

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

function render() {
  // ... 函数体
}
js

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

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(', ')
    }
  }
}
js

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

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

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

function genArrayExpression(node, context) {
  const { push } = context
  // 追加方括号
  push('[')
  // 调用 genNodeList 为数组元素生成代码
  genNodeList(node.elements, context)
  // 补全方括号
  push(']')
}
js

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

对于 ReturnStatementStringLiteral 类型的节点来说,为它们生成代码很简单。

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}'`)
}
js

最后,只剩下 genCallExpression 函数。

function genCallExpression(node, context) {
  const { push } = context
  // 取得被调用函数名称和参数列表
  const { callee, arguments: args } = node
  // 生成函数调用代码
  push(`${callee.name}(`)
  // 调用 genNodeList 生成参数代码
  genNodeList(args, context)
  // 补全括号
  push(`)`)
}
js

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

function compiler(template) {
  const ast = parse(template)
  transform(ast)
  const code = genertae(ast.jsNode)
  return code
}

compiler('<div><p>Vue</p><p>Template</p></div>')
js

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

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

总结

本篇文章中,我们首先讨论了 Vue.js 模板编译器的工作流程。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 节点编写对应的代码生成函数。为了让生成的代码具有更强的可读性,我们还讨论了如何对生成的代码进行缩进和换行。我们将用于缩进和换行的代码封装为工具函数,并且定义到代码生成过程中的上下文对象中。

模板编译器完整代码