基础

Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。

抽象语法树(ASTs)

这个处理过程中的每一步都涉及到创建或是操作抽象语法树,亦称 AST。

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

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

AST Explorer 可以让你对 AST 节点有一个更好的感性认识。 这里是上述代码的一个示例链接。

这个程序可以被表示成如下的一棵树:

  1. - FunctionDeclaration:
  2. - id:
  3. - Identifier:
  4. - name: square
  5. - params [1]
  6. - Identifier
  7. - name: n
  8. - body:
  9. - BlockStatement
  10. - body [1]
  11. - ReturnStatement
  12. - argument
  13. - BinaryExpression
  14. - operator: *
  15. - left
  16. - Identifier
  17. - name: n
  18. - right
  19. - Identifier
  20. - name: n

或是如下所示的 JavaScript Object(对象):

  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. }

你会留意到 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""Identifier",或 "BinaryExpression")。 每一种类型的节点定义了一些附加属性用来进一步描述该节点类型。

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. }

每一个节点都会有 startendloc 这几个属性。

Babel 的处理步骤

Babel 的三个主要处理步骤分别是: 解析(parse)转换(transform)生成(generate)。.

解析

解析步骤接收代码并输出 AST。 这个步骤分为两个阶段:词法分析(Lexical Analysis) 语法分析(Syntactic Analysis)。.

词法分析

词法分析阶段把字符串形式的代码转换为 令牌(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 属性。.

语法分析

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

转换

转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。 这是 Babel 或是其他编译器中最复杂的过程 同时也是插件将要介入工作的部分,这将是本手册的主要内容, 因此让我们慢慢来。

生成

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

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

遍历

想要转换 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. }

于是我们从 FunctionDeclaration 开始并且我们知道它的内部属性(即:idparamsbody),所以我们依次访问每一个属性及它们的子节点。

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

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

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

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

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

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

Visitors(访问者)

当我们谈及“进入”一个节点,实际上是说我们在访问它们, 之所以使用这样的术语是因为有一个访问者模式(visitor)的概念。.

访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。 这么说有些抽象所以让我们来看一个例子。

  1. const MyVisitor = {
  2. Identifier() {
  3. console.log("Called!");
  4. }
  5. };
  6. // 你也可以先创建一个访问者对象,并在稍后给它添加方法。
  7. let visitor = {};
  8. visitor.MemberExpression = function() {};
  9. visitor.FunctionDeclaration = function() {}

注意Identifier() { ... }Identifier: { enter() { ... } } 的简写形式。.

这是一个简单的访问者,把它用于遍历中时,每当在树中遇见一个 Identifier 的时候会调用 Identifier() 方法。

所以在下面的代码中 Identifier() 方法会被调用四次(包括 square 在内,总共有四个 Identifier)。).

  1. function square(n) {
  2. return n * n;
  3. }
  1. path.traverse(MyVisitor);
  2. Called!
  3. Called!
  4. Called!
  5. Called!

这些调用都发生在进入节点时,不过有时候我们也可以在退出时调用访问者方法。.

假设我们有一个树状结构:

  1. - FunctionDeclaration
  2. - Identifier (id)
  3. - Identifier (params[0])
  4. - BlockStatement (body)
  5. - ReturnStatement (body)
  6. - BinaryExpression (argument)
  7. - Identifier (left)
  8. - Identifier (right)

当我们向下遍历这颗树的每一个分支时我们最终会走到尽头,于是我们需要往上遍历回去从而获取到下一个节点。 向下遍历这棵树我们进入每个节点,向上遍历回去时我们退出每个节点。

让我们以上面那棵树为例子走一遍这个过程。

  • 进入 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

所以当创建访问者时你实际上有两次机会来访问一个节点。

  1. const MyVisitor = {
  2. Identifier: {
  3. enter() {
  4. console.log("Entered!");
  5. },
  6. exit() {
  7. console.log("Exited!");
  8. }
  9. }
  10. };

如有必要,你还可以把方法名用|分割成Idenfifier |MemberExpression形式的字符串,把同一个函数应用到多种访问节点。.

flow-comments 插件中的例子如下:

  1. const MyVisitor = {
  2. "ExportNamedDeclaration|Flow"(path) {}
  3. };

你也可以在访问者中使用别名(如babel-types定义).

例如,

Function is an alias for FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, ObjectMethod and ClassMethod.

  1. const MyVisitor = {
  2. Function(path) {}
  3. };

Paths(路径)

AST 通常会有许多节点,那么节点直接如何相互关联呢? 我们可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系,或者也可以用Paths(路径)来简化这件事情。.

Path 是表示两个节点之间连接的对象。

例如,如果有下面这样一个节点及其子节点︰

  1. {
  2. type: "FunctionDeclaration",
  3. id: {
  4. type: "Identifier",
  5. name: "square"
  6. },
  7. ...
  8. }

将子节点 Identifier 表示为一个路径(Path)的话,看起来是这样的:

  1. {
  2. "parent": {
  3. "type": "FunctionDeclaration",
  4. "id": {...},
  5. ....
  6. },
  7. "node": {
  8. "type": "Identifier",
  9. "name": "square"
  10. }
  11. }

同时它还包含关于该路径的其他元数据:

  1. {
  2. "parent": {...},
  3. "node": {...},
  4. "hub": {...},
  5. "contexts": [],
  6. "data": {},
  7. "shouldSkip": false,
  8. "shouldStop": false,
  9. "removed": false,
  10. "state": null,
  11. "opts": null,
  12. "skipKeys": null,
  13. "parentPath": null,
  14. "context": null,
  15. "container": null,
  16. "listKey": null,
  17. "inList": false,
  18. "parentKey": null,
  19. "key": null,
  20. "scope": null,
  21. "type": null,
  22. "typeAnnotation": null
  23. }

当然路径对象还包含添加、更新、移动和删除节点有关的其他很多方法,稍后我们再来看这些方法。

在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。 当你调用一个修改树的方法后,路径信息也会被更新。 Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。

Paths in Visitors(存在于访问者中的路径)

当你有一个 Identifier() 成员方法的访问者时,你实际上是在访问路径而非节点。 通过这种方式,你操作的就是节点的响应式表示(译注:即路径)而非节点本身。

  1. const MyVisitor = {
  2. Identifier(path) {
  3. console.log("Visiting: " + path.node.name);
  4. }
  5. };
  1. a + b + c;
  1. path.traverse(MyVisitor);
  2. Visiting: a
  3. Visiting: b
  4. Visiting: c

State(状态)

状态是抽象语法树AST转换的敌人,状态管理会不断牵扯你的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。

考虑下列代码:

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

让我们写一个把 n 重命名为 x 的访问者的快速实现.

  1. let paramName;
  2. const MyVisitor = {
  3. FunctionDeclaration(path) {
  4. const param = path.node.params[0];
  5. paramName = param.name;
  6. param.name = "x";
  7. },
  8. Identifier(path) {
  9. if (path.node.name === paramName) {
  10. path.node.name = "x";
  11. }
  12. }
  13. };

对上面的例子代码这段访问者代码也许能工作,但它很容易被打破:

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

更好的处理方式是使用递归,下面让我们来像克里斯托佛·诺兰的电影盗梦空间那样来把一个访问者放进另外一个访问者里面。

  1. const updateParamNameVisitor = {
  2. Identifier(path) {
  3. if (path.node.name === this.paramName) {
  4. path.node.name = "x";
  5. }
  6. }
  7. };
  8. const MyVisitor = {
  9. FunctionDeclaration(path) {
  10. const param = path.node.params[0];
  11. const paramName = param.name;
  12. param.name = "x";
  13. path.traverse(updateParamNameVisitor, { paramName });
  14. }
  15. };
  16. path.traverse(MyVisitor);

当然,这只是一个刻意编写的例子,不过它演示了如何从访问者中消除全局状态。

Scopes(作用域)

接下来让我们介绍作用域(scope))的概念。 JavaScript 支持词法作用域#Lexical_scoping_vs._dynamic_scoping),在树状嵌套结构中代码块创建出新的作用域。

  1. // global scope
  2. function scopeOne() {
  3. // scope 1
  4. function scopeTwo() {
  5. // scope 2
  6. }
  7. }

在 JavaScript 中,每当你创建了一个引用,不管是通过变量(variable)、函数(function)、类型(class)、参数(params)、模块导入(import)还是标签(label)等,它都属于当前作用域。

  1. var global = "I am in the global scope";
  2. function scopeOne() {
  3. var one = "I am in the scope created by `scopeOne()`";
  4. function scopeTwo() {
  5. var two = "I am in the scope created by `scopeTwo()`";
  6. }
  7. }

更深的内部作用域代码可以使用外层作用域中的引用。

  1. function scopeOne() {
  2. var one = "I am in the scope created by `scopeOne()`";
  3. function scopeTwo() {
  4. one = "I am updating the reference in `scopeOne` inside `scopeTwo`";
  5. }
  6. }

内层作用域也可以创建和外层作用域同名的引用。

  1. function scopeOne() {
  2. var one = "I am in the scope created by `scopeOne()`";
  3. function scopeTwo() {
  4. var one = "I am creating a new `one` but leaving reference in `scopeOne()` alone.";
  5. }
  6. }

当编写一个转换时,必须小心作用域。我们得确保在改变代码的各个部分时不会破坏已经存在的代码。

我们在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突。 或者我们仅仅想找出使用一个变量的所有引用, 我们只想在给定的作用域(Scope)中找出这些引用。

作用域可以被表示为如下形式:

  1. {
  2. path: path,
  3. block: path.node,
  4. parentBlock: path.parent,
  5. parent: parentScope,
  6. bindings: [...]
  7. }

当你创建一个新的作用域时,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内收集所有的引用(“绑定”)。

一旦引用收集完毕,你就可以在作用域(Scopes)上使用各种方法,稍后我们会了解这些方法。

Bindings(绑定)

所有引用属于特定的作用域,引用和作用域的这种关系被称作:绑定(binding)。.

  1. function scopeOnce() {
  2. var ref = "This is a binding";
  3. ref; // This is a reference to a binding
  4. function scopeTwo() {
  5. ref; // This is a reference to a binding from a lower scope
  6. }
  7. }

单个绑定看起来像这样︰

  1. Text for Translation
  2. {
  3. identifier: node,
  4. scope: scope,
  5. path: path,
  6. kind: 'var',
  7. referenced: true,
  8. references: 3,
  9. referencePaths: [path, path, path],
  10. constant: false,
  11. constantViolations: [path]
  12. }

有了这些信息你就可以查找一个绑定的所有引用,并且知道这是什么类型的绑定(参数,定义等等),查找它所属的作用域,或者拷贝它的标识符。 你甚至可以知道它是不是常量,如果不是,那么是哪个路径修改了它。

在很多情况下,知道一个绑定是否是常量非常有用,最有用的一种情形就是代码压缩时。

  1. function scopeOne() {
  2. var ref1 = "This is a constant binding";
  3. becauseNothingEverChangesTheValueOf(ref1);
  4. function scopeTwo() {
  5. var ref2 = "This is *not* a constant binding";
  6. ref2 = "Because this changes the value";
  7. }
  8. }