# 背景知识

我们知道编程语言主要分为「编译型语言」和「解释型语言」,编译型语言是在代码运行前编译器将编程语言转换成机器语言,运行时不需要重新翻译,直接使用编译的结果就行了。而解释型语言也是需要将编程语言转换成机器语言,但是是在运行时转换的。

通常我们都将 JavaScript 归类为「解释型语言」,以至于很多人都误以为前端代码是不需要编译的,但其实 JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,只不过与传统的编译语言不同,它不是提前编译的。并且随着现代浏览器和前端领域的蓬勃发展,编译器在前端领域的应用越来越广泛,就日常工作而言,包括但不限于以下几个方面:

  • v8 引擎、typescript 编译器(tsc)
  • webpack loader 编译器(acorn[19]),babel、SWC 等编译工具。
  • angular、Vue 、react等框架的.vue模板编译器、jsx

# 前端主流编译工具

  • Babel

2014 年,Babel[11] 发布首版³,重心放在对 JavaScript 转译,使得尚在提案阶段的语言特性能兼容。

  • SWC

2019 年,基于 Rust 实现的 SWC[21] 发布首版,对标 Babel,显著提升了性能;

  • Esbuild

2020 年,使用 go 实现的 esbuild[22] 发布首版,相比 SWC 更聚焦于 TypeScript 和 JavaScript 的转译,性能更快;

  1. 它基于 golang,就是比 node.js 快。
  2. 高度并行的处理算法。
  3. 节制的功能设计。
  4. 重写核心工具链。

# 概述

我们先来回顾下编译原理的基本知识,从宏观上来说,编译本质上是一种转换技术,从一门编程语言转换成另一门编程语言,或者从高级语言转换成低级语言,或者从高级语言到高级语言,所谓的高级语言和低级语言主要是指下面的区分:

  • 高级语言:有很多用于描述逻辑的语言特性,比如分支、循环、函数、面向对象等,接近人的思维,可以让开发者快速的通过它来表达各种逻辑。比如 c++、javascript。

  • 低级语言:与硬件和执行细节有关,会操作寄存器、内存,具体做内存与寄存器之间的复制,需要开发者理解熟悉计算机的工作原理,熟悉具体的执行细节。比如汇编语言、机器语言。

上面的约定的编译规则,就是指各种编程语言的语法规则,不同的编译器会产出不同的“编译结果”,例如 C/C++ 语言经过编译得到二进制的机器码,然后交给操作系统,例如当我们运行 tsc 命令就会将 TS 代码编译为 js 代码,再比如执行 babel 命令会将 es6+ 的代码编译为指定目标(es5)的 js 代码

一般来说,整个编译过程主要分为两个阶段:编译 前端和编译后端,大致分为下面的几个过程:

从上图可以看到,编译前端主要就是帮助计算机阅读源代码并理解源代码的结构、含义、作用等,将源代码由一串无意义的字符流解析为一个个的有特定含义的构件。通常情况下,编译前端会产生一种用于给编译后端消费的中间产物,比如我们常见的抽象语法树 AST,而编译后端则是在前端解析的结果和基础上,进一步优化和转换并生成最终的目标代码。

# 上下文无关文法

面提到编译器会根据「约定的编译规则」进行编译,这里「约定的编译规则」就是指「上下文无关文法(CFG)」。CFG 用于在理论上的形式化定义一门语言的语法,或者说,用于系统地描述程序设计语言的构造(比如表达式和语句)

实际上,几乎所有程序设计语言都是通过上下文无关文法来定义的,与正则表达式比较像,但是比正则表达式功能更强大,它能表达非常复杂的文法,比如C语言语法用正则表达式来表示不可能做到,但是可以用CFG的一组规则来表达。

要理解上下文无关文法,需要先理解下面几个概念:

  • 终结符:可以理解为基础符号,词法符号,是不可替代的,是固定存在的,不能通过文法规则生成的。
  • 非终结符:句法变量,是可以替代的
  • 产生式规则:语法是由终结符集、非终结符集和产生式规则共同组成。产生式规则定义了符号之间如何转换替代。规则的左侧是规则头,即非终结符,是可以被替代的符号;右侧是产生式,是具体的内容。

# 编译流程

# 词法分析

  • 首先,通过对源代码的字符串从左到右进行扫描,以空白字符(空格、换行、制表符等)为分隔符,拆分为一个个无类型的 token

  • 其次,再根据词法规则,利用 「有限状态机」 对第一步拆分的 Token 进行字符串模式匹配,以识别每一个 Token 的类型(v8 token.h)

一般而言,token 是一个有类型和值的数据结构,而 token 流简单理解可以是 token 数组。以下面一行代码为例:

const name = 'xujianglong';

// 根据 js 的语法规则, 大致会生成如下的 token 流
[
  { type: "CONST", value: "const" }, 
  { type: "IDENTIFIER", value: "name" },
  { type: "ASSIGN", value: "=" },
  { type: "STRING", value: "xujianglong" },
  { type: "SEMICOLON", value: ";" },
]

1
2
3
4
5
6
7
8
9
10
11

那么有限状态机是个什么概念呢?它是怎么把字符串代码转化为 token 的呢?

首先我们想一下,词法描述的是最小的单词格式,比如上面例子的那一行代码为例,利用空白字符拆分成这几个token:['const','name','=','xujianglong',';'],但是怎么去识别每种 token 的类型呢,最简单粗暴的方式我们可以写个 if else语句或者写个正则,但是这样貌似不太优雅且不容易维护,而使用状态机是许多编程语言都使用的方式。

有限状态机(英语:finite-state machine,缩写:FSM)又称有限状态自动机(英语:finite-state automation,缩写:FSA),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型

如图所示,用户从其他状态机进入 S1 状态机,如果用户输入1,则继续进入 S1 状态机,如果用户输入了0,则进入下一个状态机 S2。在 S2 状态机中,如果用户继续输入1,则继续进入S2状态机,如果用户输入了0,则回到 S1 状态机。这是一个循环的过程。 听起来有点抽象,对比到代码分词中来说,我们可以把每个单词的处理过程当成一种状态,将整体的输入(源代码)按照每个字符依次去读取,根据每次读取到的字符来更改当前的状态,每个 token 识别完了就可以抛出来。 我们举个简单的四则运算的例子:10 + 20 - 30

首先我们定义了三种状态机,分别是 NUMBER 代表数值,ADD 代表加号,SUB 代表减号:

  1. 当分析到 "1" 时,因为本次输入我们需要改变状态机内部状态为 NUMBER,继续迭代下一个字符 “0”,此时因为 "1" 和 "0" 是一个整体可以不被分开的。

  2. 当分析到 "+" 时,状态机中输入为 “+”, 显然 “+” 是一个运算符号,它并不能和上一次的 “10” 拼接在一起。所以此时状态改变,我们将上一次的 currentToken 也就是 "10" 推入 tokens 中,同时改变状态机状态为 ADD。

  3. 依次类推,最终会输出如下 tokens 数组:

[
  { type: "NUMBER", value: "10" }, 
  { type: "ADD", value: "+" },
  { type: "NUMBER", value: "20" },
  { type: "SUB", value: "-" },
  { type: "NUMBER", value: "30" },
]
1
2
3
4
5
6
7

# 语法分析

在这个阶段,语法分析器(parser)会将词法分析中得到的 token 数组转化为 抽象语法树 AST 。比如前面定义变量的那行代码可以在这个在线工具 AST explorer 中查看生成的 AST:

{
  "type": "Program",
  "start": 0,
  "end": 28,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 1,
      "end": 28,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 7,
          "end": 27,
          "id": {
            "type": "Identifier",
            "start": 7,
            "end": 11,
            "name": "name"
          },
          "init": {
            "type": "Literal",
            "start": 14,
            "end": 27,
            "value": "xujianglong",
            "raw": "'xujianglong'"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

对于 JavaScript 语言来说,AST 也有一套约定的规范:GitHub - estree/estree: The ESTree Spec,社区称之为 estree,借助这个规范,整个前端社区的一些工具便可以产出一套统一的数据格式而无需关心下游,下游的消费类工具统一使用这个统一的格式进行处理而无需关心上游,这样就做到了上下游的解耦。以 webpack 为例,其底层是 acorn 工具,acorn 会把 js 源码转化为上述的标准 estree,而 webpack 作为下游便可以消费该 estree,比如遍历,提取和分析 require/import 依赖,转换代码并输出。 生成 AST 的过程需要遵循语法规则(用上下文无关文法表示),在上面代码中,我们用到了「VariableDeclaration」,其语法规则可以表示为:

VariableDeclaration :: Kind Identifier Init?;
Kind :: Const | Let | Var;
Init :: '=' Expression | Identifier | Literal;
Expression :: BinaryExpression | ConditionalExpression | ...;
Literal :: StringLiteral | ...;
1
2
3
4
5

有了语法规则之后,我们就需要思考编译器是如何将 token 流,在语法规则的约束下,转换成 AST 的呢?生成 AST 也主要是两大方向:

  1. 一是将文法规则的约束硬编码到编译器的代码逻辑中,这种是特定语言的编译器使用的常见方案,这种方案往往是人工编写 parse 代码,对输入源码的各种错误和异常可以更细致地报告和处理。比如前面提到的 arorn,以及 tsc,babel,以及熟悉的 vue,angular 的模板编译器等,都主要是这种方法。

  2. 二是使用自动生成工具将文法规则直接转换成语法 parse 代码。这种更常用于非特定的编程语言,比如一些业务中自定义的简单但易变的语法,或仅仅只是字符串文本的复杂处理规则。

我们这里以第一种方式最基础的 「递归下降算法」(递归下降的编译技术,是业界最常使用的手写编译器实现编译前端的技术之一,感兴趣可以专门去深入研究下)为例,简单描述示例代码生成 AST 的过程:

尝试匹配 VariableDeclaration

匹配到 Const

匹配到 Identifier

尝试匹配 Init,递归下降

匹配到 '='

尝试匹配 Expression,递归下降

匹配失败,回溯

匹配到 Literal,回溯

VariableDeclaration 匹配成功,构造相应类型节点插入 AST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 语义分析

并不是所有的编译器都有语义分析,比如 Babel 就没有。不过对于其它大部分编程语言(包括 TypeScript)的编译器来说,是有语义分析这一步骤的,特别是静态类型语言,类型检查就属于语义分析的其中一个步骤

语义分析阶段,编译器开始对 AST 进行一次或多次的遍历,检查程序的语义规则。主要包括声明检查和类型检查,如上一个赋值语句中,就需要检查:

  • 语句中的变量 name 是否被声明过
  • const 类型变量是否被改变
  • 加号运算的两个操作数的类型是否匹配
  • 函数的参数数量和类型是否与其声明的参数数量及类型匹配

语义检查的步骤和人对源代码的阅读和理解的步骤差不多,一般都是在遍历 AST 的过程中,遇到变量声明和函数声明时,则将变量名--类型、函数名--返回类型--参数数量及类型等信息保存到符号表里,当遇到使用变量和函数的地方,则根据名称在符号表中查找和检查,查找该名称是否被声明过,该名称的类型是否被正确的使用等等。

语义检查时,也会对语法树进行一些优化,比如将只含常量的表达式先计算出来,如:

a = 1 + 2 * 9;

会被优化成:

a = 19;

1
2
3
4
5
6

语义分析完成后,源代码的结构解析就已经完成了,所有编译期错误都已被排除,所有使用到的变量名和函数名都绑定到其声明位置(地址)了,至此编译器可以说是真正理解了源代码,可以开始进行代码生成和代码优化了。

# 中间代码生成与优化

一般的编译器并不直接生成目标代码,而是先生成某种中间代码,然后再生成目标代码。之所以先生成中间代码,主要有以下几个原因:

  1. 为了降低编译器开发的难度,将高级语言翻译成中间代码、将此中间代码再翻译成目标代码的难度都比直接将高级语言翻译成目标代码的难度要低。

  2. 为了增加编译器的模块化、可移植性和可扩展性,一般来说,中间代码既独立于任何高级语言,也独立于任何目标机器架构,这就为开发出适应性广泛的编译器提供了媒介

  3. 为了代码优化,一般来说,计算机直接生成的代码比人手写的汇编要庞大、重复很多,计算机科学家们对一些具有固定格式的中间代码的进行大量的研究工作,提出了很多广泛应用的、效率非常高的优化算法,可以对中间代码进行优化,比直接对目标代码进行优化的效果要好很多。

JavaScript 的编译器 v8 引擎早期是没有中间代码生成的,直接从 AST 生成本地可执行的代码,但由于缺少了转换为字节码这一中间过程,也就减少了优化代码的机会。为了提高性能, v8 开始采用了引入字节码的架构,先把 AST 编译为字节码,再通过 JIT 工具转换成本地代码。 v8 引擎的编译过程这里不做过多介绍,后续可作为单独的分享。

# 生成目标代码

有了中间代码后,目标代码的生成是相对容易的,因为中间代码在设计的时候就考虑到要能轻松生成目标代码。不同的高级语言的编译器生成的目标代码不一样,如:

  • C、C++、Go,目标代码都是汇编语言(可能目标代码不一样),在经过汇编器最终得到机器码;

  • Java经过javac编译后,生成字节码,只经过了编译的词法分析、语法分析、语义分析和中间代码生成,运行时再由解释器逐条将字节码解释为机器码来执行;

  • JavaScript,在运行时经过整个编译流程,不过也是先生成字节码,然后解析器解析字节码执行;

  • 至于 webpack、babel 这些前端工具则是最终编译成相应的 js 代码。