深入理解 JavaScript 代码编译过程

本文将从 AST 开始,结合 Babel 由浅入深的去探究编译器是如何编译 JS 代码的。

AST

在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。

之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

使用场景

  • JS 反编译,语法解析
  • Babel 编译 ES6 语法
  • 代码高亮
  • 关键字匹配
  • 作用域判断
  • 代码压缩

AST 长什么样子?

看一个例子:

1
function square(n) {
2
  return n * n;
3
}

AST Explorer 可以让你对 AST 节点有一个更好的感性认识。

AST Explorer

这段代码被解析成的 AST 树结构可以表示为:

ast

通过解析结果,细心的你会发现,AST 是一个多层次嵌套的树结构,每一层都映射着源代码的相关物理信息:

  • 类型 type,如 type: BlockStatement
  • 名称 name,如 name: "square"
  • 位置 start,如 start: 10end: 16
  • 运算符 operator,如 operator: "*"

下文介绍节点类型时,会继续介绍。

我们再加一行代码,看会怎样:

1
function square(n) {
2
  return n * n;
3
}
4
(n) => n * n;

如下树结构:

AST

JSON 格式可能会更直观些:

1
{
2
  "type": "Program",
3
  "start": 0,
4
  "end": 54,
5
  "body": [
6
    {
7
      "type": "FunctionDeclaration",
8
      "start": 1,
9
      "end": 39,
10
      "id": {
11
        "type": "Identifier",
12
        "start": 10,
13
        "end": 16,
14
        "name": "square"
15
      },
16
      "expression": false,
17
      "generator": false,
18
      "async": false,
19
      "params": [
20
        {
21
          "type": "Identifier",
22
          "start": 17,
23
          "end": 18,
24
          "name": "n"
25
        }
26
      ],
27
      "body": {
28
        "type": "BlockStatement",
29
        "start": 20,
30
        "end": 39,
31
        "body": [
32
          {
33
            "type": "ReturnStatement",
34
            "start": 24,
35
            "end": 37,
36
            "argument": {
37
              "type": "BinaryExpression",
38
              "start": 31,
39
              "end": 36,
40
              "left": {
41
                "type": "Identifier",
42
                "start": 31,
43
                "end": 32,
44
                "name": "n"
45
              },
46
              "operator": "*",
47
              "right": {
48
                "type": "Identifier",
49
                "start": 35,
50
                "end": 36,
51
                "name": "n"
52
              }
53
            }
54
          }
55
        ]
56
      }
57
    },
58
    {
59
      "type": "ExpressionStatement",
60
      "start": 41,
61
      "end": 54,
62
      "expression": {
63
        "type": "ArrowFunctionExpression",
64
        "start": 41,
65
        "end": 53,
66
        "id": null,
67
        "expression": true,
68
        "generator": false,
69
        "async": false,
70
        "params": [
71
          {
72
            "type": "Identifier",
73
            "start": 42,
74
            "end": 43,
75
            "name": "n"
76
          }
77
        ],
78
        "body": {
79
          "type": "BinaryExpression",
80
          "start": 48,
81
          "end": 53,
82
          "left": {
83
            "type": "Identifier",
84
            "start": 48,
85
            "end": 49,
86
            "name": "n"
87
          },
88
          "operator": "*",
89
          "right": {
90
            "type": "Identifier",
91
            "start": 52,
92
            "end": 53,
93
            "name": "n"
94
          }
95
        }
96
      }
97
    }
98
  ],
99
  "sourceType": "module"
100
}

你会发现在之前的基础上,最外层 body 字段中新增了一个 expressSatement 属性,用来描述 (n) => n * n; 相关语法信息等。

接着还会发现 AST 每一层都拥有相同的结构:

1
{
2
  type: "FunctionDeclaration",
3
  id: {...},
4
  params: [...],
5
  body: {...}
6
}
1
{
2
  type: "Identifier",
3
  name: ...
4
}
1
{
2
  type: "BinaryExpression",
3
  operator: ...,
4
  left: {...},
5
  right: {...}
6
}

注意:出于简化的目的移除了某些属性

节点类型

这样的每一层结构也被叫做 节点(Node)。 一个 AST 可以由单一的节点或是成百上千个节点构成。 它们组合在一起可以描述用于静态分析的程序语法。

每一个节点都有如下所示的接口(Interface):

1
interface Node {
2
  type: string;
3
}

字符串形式的 type 字段表示节点的类型,如:

  • FunctionDeclaration 函数声明。
  • BlockStatement 块语句,即用 {} 括号括起来的一系列语句。
  • ReturnStatement 一个 return 声明。
  • Identifier" 标识符,标识符可以是表达式或解构模式。
  • BinaryExpression 二进制运算符表达式(二项式对象)。
  • ExpressionStatement 表达式语句,即由单个表达式组成的语句。
  • ArrowFunctionExpression 箭头函数表达式。

每一种类型的节点定义了一些附加属性用来进一步描述该节点类型。

详细描述请看AST节点对象文档

1
(parameter) node: Identifier | SimpleLiteral | RegExpLiteral | Program | FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | SwitchCase | CatchClause | VariableDeclarator | ExpressionStatement | BlockStatement | EmptyStatement | DebuggerStatement | WithStatement | ReturnStatement | LabeledStatement | BreakStatement | ContinueStatement | IfStatement | SwitchStatement | ThrowStatement | TryStatement | WhileStatement | DoWhileStatement | ForStatement | ForInStatement | ForOfStatement | VariableDeclaration | ClassDeclaration | ThisExpression | ArrayExpression | ObjectExpression | YieldExpression | UnaryExpression | UpdateExpression | BinaryExpression | AssignmentExpression | LogicalExpression | MemberExpression | ConditionalExpression | SimpleCallExpression | NewExpression | SequenceExpression | TemplateLiteral | TaggedTemplateExpression | ClassExpression | MetaProperty | AwaitExpression | Property | AssignmentProperty | Super | TemplateElement | SpreadElement | ObjectPattern | ArrayPattern | RestElement | AssignmentPattern | ClassBody | MethodDefinition | ImportDeclaration | ExportNamedDeclaration | ExportDefaultDeclaration | ExportAllDeclaration | ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier | ExportSpecifier

到这里,其实我们已经慢慢明白了:

抽象语法树其实就是将源码反格式化,将一类标签转化成通用标识符,从而解构出的一个类似于树形结构的语法树。

深入原理

我们用代码来模拟一下 JS 的编译过程:(代码转换成 AST 并生成新代码的过程)。

1
// 解析(JS) --> 转换(AST) --> 生成(JS)
2
const esprima = require('esprima'); //解析js的语法的包
3
const estraverse = require('estraverse'); //遍历树的包
4
const escodegen = require('escodegen'); //生成新的代码
5
6
let code = `function square() {}`;
7
// 1. 解析
8
let tree = esprima.parseScript(code);
9
console.log(tree);
10
11
// 2. 转换/遍历树(深度遍历)
12
estraverse.traverse(tree, {
13
  enter(node) {
14
    console.log('enter: ' + node.type);
15
    if (node.type === 'Identifier') {
16
        node.name = 'multiply';
17
      }
18
    }
19
  }
20
});
21
// 3. 生成
22
23
let newCode = escodegen.generate(tree);
24
console.log(newCode);

上段代码中,我们首先使用 esprima(解析器)将源代码解析成 AST 抽象语法树:

1
...
2
let tree = esprima.parseScript(code);
3
console.log(tree);
4
...

打印结果如下图:

AST

正如同上文所介绍的那样:

ast

之后,使用 estraverse 遍历整个 AST,通过节点类型转换/修改(修改了源代码的函数签名)成新 AST

从打印结果可知,这个遍历过程是一个深度优先遍历的过程。

AST

最后,将转换后的 AST 借助代码生成器 escodegen,生成新的代码:

最终结果如下:
code

新生成的代码是格式化后的代码,这里有一个线上的例子。demo

深入原理之后,你可能会想到 ES6ES5 的转换,可能想到了 Babel,想到了 JS 混淆,想到了更多背后的东西。

抽象语法树的强大之处,本质上通过编译,我们可以去改变任何输出结果。

那么,接下来,就让我们一起来认识下 Babel 吧。

Babel

初识

Babel 是一个通用的多用途 JavaScript 编译器。通过 Babel 你可以使用(并创建)下一代的 JavaScript,以及下一代的 JavaScript 工具。

Babel 把用最新标准编写的 JavaScript 代码向下编译成可以在今天随处可用的版本。 这一过程叫做“源码到源码”编译, 也被称为转换编译(transpiling 是一个自造合成词,即转换+编译。以下也简称为转译)。

例如,Babel 能够将新的 ES2015 箭头函数语法:

1
const square = n => n * n;

转译为:

1
const square = function square(n) {
2
  return n * n;
3
};

不过 Babel 的用途并不止于此,它支持语法扩展,能支持像 React 所用的 JSX 语法,同时还支持用于静态类型检查的流式语法(Flow Syntax)。

Babel 的一切都是简单的插件,谁都可以创建自己的插件,利用 Babel 的全部威力去做任何事情。

Babel 是如何工作的?

通过上文可知,BabelJavaScript 编译器,那么它在工作时也要执行 解析(JS) --> 转换(AST) --> 生成(JS)的过程。

只不过是 Babel 将这一过程进行了封装,每一个封装都定义了一种规则。

下面让我们借助 Babel 插件来实现这一过程。

1
const babel = require('babel-core'); //babel 核心解析库,负责解析
2
const types = require('babel-types'); //babel 类型转化库,负责转换
3
4
let code = `let sum = (a, b) => { return a + b }`;
5
6
// 制定规则(如何转换)
7
let ArrowPlugins = {
8
    //访问者模式
9
    visitor: {
10
    //捕获匹配的API
11
        ArrowFunctionExpression(path) {
12
            let { node } = path;
13
            let body = node.body;
14
            let params = node.params;
15
            let r = types.functionExpression(null, params, body, false, false);
16
            path.replaceWith(r);
17
        }
18
    }
19
}
20
// 根据规则进行转换并生成
21
let date = babel.transform(code, {
22
  plugins: [
23
    ArrowPlugins
24
  ]
25
})
26
console.log(date.code);

看下输出结果:

1
let sum = function (a, b) {
2
  return a + b;
3
};

好像这正是我们想要的。

深入 Babel

当然,上文我们简单演示了 Babel 是如何来编译代码的,但是并非就这么简单。

Babel 使用一个基于 ESTree 并修改过的 AST,它的内核说明文档可以在这里找到。

正如上文所说,Babel 编译会经过三个阶段,分别是: 解析(parse转换(transform生成(generate)

解析

解析步骤接收代码并输出 AST。 这个步骤分为两个阶段:

  • 词法分析(Lexical Analysis)
  • 语法分析(Syntactic Analysis)
1. 词法解析

编译的第一个阶段,它的主要任务就是从左到右扫描字符串形式的代码,将其转换为 令牌(tokens) 流,用于语法分析。

可以把令牌(tokens)看作是一个扁平的语法片段数组:

1
n * n;

例如:

1
[
2
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
3
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
4
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
5
  ...
6
]

每一个 type 有一组属性来描述该令牌:

1
{
2
  type: {
3
    label: 'name',
4
    keyword: undefined,
5
    beforeExpr: false,
6
    startsExpr: true,
7
    rightAssociative: false,
8
    isLoop: false,
9
    isAssign: false,
10
    prefix: false,
11
    postfix: false,
12
    binop: null,
13
    updateContext: null
14
  },
15
  ...
16
}

AST 节点一样它们也有 startendloc 属性。

2. 语法解析

语法分析阶段会把一个 令牌流 转换成 AST 的形式。

这个阶段会使用令牌中的信息把它们转换成一个 AST 的表述结构,这样更易于后续的操作。

语法分析

转换

转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。

这是 Babel 或是其他编译器中最复杂的过程 同时也是插件将要介入工作的部分。

Babel 转换时,还为每个节点额外生成了一些属性,用于描述该节点在原始代码中的位置。

1
{
2
  type: ...,
3
  start: 0,
4
  end: 38,
5
  loc: {
6
    start: {
7
      line: 1,
8
      column: 0
9
    },
10
    end: {
11
      line: 3,
12
      column: 1
13
    }
14
  },
15
  ...
16
}

用伪代码可以表示为:

1
// 规则配置
2
function traverser(ast, visitor) {
3
    let methods = visitor[node.type];
4
    if (methods && methods.enter) {
5
      methods.enter(node, parent);
6
    }
7
    switch (node.type) {
8
        case 'Program':
9
            // do something ...
10
        case 'ArrowPlugins':
11
            // do something ...
12
        ...
13
    }
14
}
15
16
function transformer(ast) {
17
    let newAst = {
18
        type: 'Program',
19
        body: [],
20
    };
21
    ast._context = newAst.body;
22
    // 根据节点类型取得对应转换规则
23
    traverser(ast, {
24
        ArrowPlugins: {
25
            enter(node, parent) {
26
                parent._context.push(...);
27
            },
28
        },
29
        // 其他节点类型
30
        ...
31
    })
32
    // 返回新的 AST
33
    return newAst;
34
}

生成

代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。

代码生成其实很简单:深度优先遍历整个AST,然后构建可以表示转换后代码的字符串。

用伪代码可以表示为:

1
function codeGenerator(newAst) {
2
    // 遍历入口点从节点类型开始
3
    switch (newAst.type) {
4
        case 'ExpressionStatement':
5
            return codeGenerator(newAst.expression)  // 一个深度遍历的过程
6
        ...
7
    }
8
}

介绍到这里,回头再看这段代码:

1
const babel = require('babel-core'); //babel 核心解析库,负责解析
2
const types = require('babel-types'); //babel 类型转化库,负责转换
3
4
let code = `let sum = (a, b) => { return a + b }`;
5
6
// 制定规则(如何转换)
7
let ArrowPlugins = {
8
    //访问者模式
9
    visitor: {
10
    //捕获匹配的 API
11
        ArrowFunctionExpression(path) {
12
            let { node } = path;
13
            let body = node.body;
14
            let params = node.params;
15
            let r = types.functionExpression(null, params, body, false, false);
16
            path.replaceWith(r);
17
        }
18
    }
19
}
20
// 根据规则进行转换并生成
21
let date = babel.transform(code, {
22
  plugins: [
23
    ArrowPlugins
24
  ]
25
})
26
console.log(date.code);

是不是明白了很多。

关于遍历

想要转换 AST 你需要进行递归的树形遍历。

比方说我们有一个 FunctionDeclaration 类型。它有几个属性:idparams,和 body,每一个都有一些内嵌节点。

1
{
2
  type: "FunctionDeclaration",
3
  id: {
4
    type: "Identifier",
5
    name: "square"
6
  },
7
  params: [{
8
    type: "Identifier",
9
    name: "n"
10
  }],
11
  body: {
12
    type: "BlockStatement",
13
    body: [{
14
      type: "ReturnStatement",
15
      argument: {
16
        type: "BinaryExpression",
17
        operator: "*",
18
        left: {
19
          type: "Identifier",
20
          name: "n"
21
        },
22
        right: {
23
          type: "Identifier",
24
          name: "n"
25
        }
26
      }
27
    }]
28
  }
29
}

参照上边的结构我们说一下它遍历的过程:

  1. 我们从 FunctionDeclaration 节点开始,依次访问每一个属性(idparams,和 body)及它们的子节点。

  2. 接着我们来到 id,它是一个 IdentifierIdentifier 没有任何子节点属性,所以我们继续下一个属性。

  3. 之后是 params,由于它是一个数组节点所以我们访问其中的每一个,它们都是 Identifier 类型的单一节点,然后我们继续。

  4. 此时我们来到了 body,这是一个 BlockStatement 并且也有一个 body 节点,而且也是一个数组节点,我们继续访问其中的每一个。

  5. 这里唯一的一个属性是 ReturnStatement 节点,它有一个 argument,我们访问 argument 就找到了 BinaryExpression。.

  6. BinaryExpression 有一个 operator,一个 left,和一个 rightOperator 不是一个节点,它只是一个值因此我们不用继续向内遍历,我们只需要访问 leftright

这个过程可以这么表示:

  • 进入 FunctionDeclaration
    • 进入 Identifier (id)
      • 走到尽头
    • 退出 Identifier (id)
    • 进入 Identifier (params[0])
      • 走到尽头
    • 退出 Identifier (params[0])
    • 进入 BlockStatement (body)
      • 进入 ReturnStatement (body)
        • 进入 BinaryExpression (argument)
          • 进入 Identifier (left)
            • 走到尽头
          • 退出 Identifier (left)
          • 进入 Identifier (right)
            • 走到尽头
          • 退出 Identifier (right)
        • 退出 BinaryExpression (argument)
      • 退出 ReturnStatement (body)
    • 退出 BlockStatement (body)
  • 退出 FunctionDeclaration

Babel 的转换步骤全都是这样的遍历过程。

关于 Babel 就介绍到这里。

具体语法树

看到抽象语法树 AST,我们脑海中会出现这样一个疑问:有没有具体语法树呢?

和抽象语法树相对的是具体语法树(通常称作解析树)。

解析树通常是由解析器在源代码转换和编译过程中构建的。建成后,将通过后续处理(例如上下文分析)将其他信息添加到 AST

参考

ESTree
Babel
AST 抽象语法树

未经允许,不得转载。