Babel 内部机制研究

5,355 阅读9分钟

Babel

Babel 是一个 JavaScript 编译器。

// Babel 输入: ES2015 箭头函数
[1, 2, 3].map((n) => n + 1);

// Babel 输出: ES5 语法实现的同等功能
[1, 2, 3].map(function(n) {
  return n + 1;
});

Babel通过转换,让我们写新版本的语法,转换到低版本,这样就可以在只支持低版本语法的浏览器里运行了。

Babel真厉害,它居然‘认识’代码、更改代码。那Babel就是操作代码的代码,酷。

学习Babel对我们能力的提升有很大帮助,我们平时都是用代码来操作各种东西,这次我们操作的对象,变成了代码本身。

这个认识和操作代码的过程,学名叫做代码的静态分析。

代码的静态分析

先来看一个问题,编辑器里代码的高亮,如下:

image

编辑器可以把代码不同的成员,标记为不同的颜色。显然编辑器要‘认识’代码,对代码进行分析和处理,才能达到这种效果。这就叫代码的静态分析。

静态分析 VS 动态分析

静态分析是在不需要执行代码的前提下对代码进行分析的处理过程。

动态分析是在代码的运行过程中对代码进行分析和处理。上面的代码高亮属于静态分析,在代码没有运行的情况下,进行分析和处理的。

静态分析的用处

静态分析不光能高亮代码,还有就是代码转换,还可以对我们的源代码进行优化、压缩等操作。

AST (抽象语法树)

在对代码静态分析的过程中,要将源码转换成AST (抽象语法树)。

为什么会有AST

源代码对于Babel来说,就是一个字符串。Babel要对这个字符串进行分析。我们平时对字符串的操作,就是使用字符串方法或是正则,但相对字符串(源码)进行复杂的操作,远远不够。

需要将字符串(源码)转换成树的数据结构,才好操作。这个树结构,就叫AST(抽象语法树)。

这里我想到 程序 = 数据结构 + 算法。我们平时写的业务需求,对数据结构要求不高,简单的对象和列表就可以搞定,但要某些特定的复杂问题,比如现在研究的操作代码,就需先思考:我应该把操作的事物放到什么样的数据结构上,才更容易我写算法/逻辑。

把源码解析成AST

对于源码,此时我们就把它看出一个字符串,对其分析的第一步,肯定是先把源码转换成AST,才好后续操作。

有一个在线AST转换器,我们在这上面可以做实验,写出的代码,它就帮我们翻译成AST:

我什么都不写,AST就有一个根结点了:

// AST
{
  "type": "Program",
  "start": 0,
  "end": 0,
  "body": [],
  "sourceType": "module"
} // 可以看成是一个对象,有一些字段,这代码树的根结点。

然后我写一句代码:

// 源码
const text = 'Hello World';

// AST
{
  "type": "Program",
  "start": 0,
  "end": 27,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 27,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 26,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 10,
            "name": "text"
          },
          "init": {
            "type": "Literal",
            "start": 13,
            "end": 26,
            "value": "Hello World",
            "raw": "'Hello World'"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}

懵逼,一句const text = 'Hello World'; 就生成这么多东西。看懂它、理解AST是学习Babel的第一个门槛。

理解AST

看下图,这就是AST Explorer的界面,左边写代码,右边就帮助我们翻译成AST,AST有两种表达方式,Tree和JSON,上面都是用JSON形式表示AST,后来我发现还是用Tree的形式看更容易些,因为Tree的形式更突出节点的类型:

image

我将AST表达的树画出来,如下:

image

总结AST树的特点:

  1. 节点是有类型的。我们学习树这种数据结构时,节点都是最简单的,这里复杂了,有类型。
  2. 节点与子节点的关系,是通过节点的属性链接的。我们学习的树结构,都是left、right左孩子右孩子的。但是AST树,不同类型的节点,属性不同,Program类型节点的子节点是它的body属性,VariableDeclaration类型的子节点,是它的declarations、kind属性。也就是节点的属性看作是节点的子节点,并且子节点也可能有类型,近而形成一个树。
  3. 父节点是所有子节点的组合,我们可以看到VariableDeclaration代表的const text = 'Hello World'被拆分成了下面两个子节点,子节点又继续拆分。

希望能从上面的分析中,让大家对AST有一个最直观的认识,就是节点有类型的树。

那么节点的类型系统就很必要了解了,这里是Babel的AST类型系统说明。大家可以看看,可以说类型系统是抽象了代码的各种成员,标识符、字面量、声明、表达式。所以拥有这些类型的节点的树结构,可以用来表达我们的代码。

参照类型系统,多实验,我们就会对AST的结构大体掌握和理解了。

额外:V8引擎也用到AST

额外提一下,V8中也用到了AST。V8引擎有四个主要模块:

  1. 转换器Paser:将源代码转换成AST。
  2. 解释器:将AST转换为Bytecode。
  3. 编译器:将Bytecode转换为汇编代码。
  4. 垃圾回收模块:负责管理内存空间回收。

可以看到AST也是V8执行的关键一环。下面下来看看Babel对于AST的利用,及运行步骤。

Babel 的处理步骤

回看Babel的处理过程

  1. 解析(parse)。将源代码变成AST。
  2. 转换(transform)。操作AST,这也是我们可以操作的部分,去改变代码。
  3. 生成(generate)。将更改后的AST,再变回代码。

解析器 babylon

第一步:解析,Babel中的解析器是babylon。我们来体验一下:

// 安装
npm install --save babylon
// 实验代码
import * as babylon from "babylon";

const code = `const text = 'Hello World';`;

const ast = babylon.parse(code);

console.log('ast', ast);

code变量是我们的源代码,ast变量是AST,我们看一下打印结果:

image

和我们预期的一样,得到AST了。这里我注意到还有start、end、loc这样位置信息的字段,应该可以对生成Source Map有用的字段。

转换器 babel-traverse

第二步:转换。得到ast了,该操作它了,Babel中的babel-traverse用来干这个事。

// 安装
npm install --save babel-traverse
// 实验代码
import * as babylon from "babylon";
import traverse from "babel-traverse";

const code = `const text = 'Hello World';`;
const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    console.log('path', path);
  }
})

console.log('ast', ast);

babel-traverse库暴露了traverse方法,第一个参数是ast,第二个参数是一个对象,我们写了一个enter方法,方法的参数是个path,咋不是个node呢?我们看一下输出:

image

path被打印了5次,ast上确实也是有5个节点,是对应的。traverse方法是一个遍历方法,path封装了每一个节点,并且还提供容器container,作用域scope这样的字段。提供个更多关于节点的相关的信息,让我们更好的操作节点。

我们来做一个变量重命名操作:

import * as babylon from "babylon";
import traverse from "babel-traverse";

const code = `const text = 'Hello World';`;
const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    if (path.node.type === "Identifier"
      && path.node.name === 'text') {
      path.node.name = 'alteredText';
    }
  }
})

console.log('ast', ast);

看结果:

image

确实我们的ast被更改了,用这个ast生成的code就会是const alteredText = 'Hello World';

babel-traverse的Lodash : babel-types

在利用babel-traverse操作AST时,也可以利用工具库帮助我们写出更加简洁有效的代码,就可以使用babel-types。

npm install --save babel-types
import * as babylon from "babylon";
import traverse from "babel-traverse";
import * as t from "babel-types";

const code = `const text = 'Hello World';`;
const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    // 使用babel-types
    if (t.isIdentifier(path.node, { name: "text" })) {
      path.node.name = 'alteredText';
    }
  }
})

console.log('ast', ast);

使用babel-types实现和上例中一样的功能,代码量更少了。

生成器 babel-generator

第三步:生成。得到操作后的ast,该生成新代码了。Babel中的babel-generator用来干这个事。

npm install --save babel-generator
// 加入babel-generator
import * as babylon from "babylon";
import traverse from "babel-traverse";
import * as t from "babel-types";
import generate from "babel-generator";

const code = `const text = 'Hello World';`;
const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    if (t.isIdentifier(path.node, { name: "text" })) {
      path.node.name = 'alteredText';
    }
  }
})

const genCode = generate(ast, {}, code);

console.log('genCode', genCode);

来看打印结果:

image

nice! 在code字段里,我们看到里新生成的代码。

当然,上面提到的这四个库,还有更多细节,有兴趣的可以再研究研究。

这些库的集合,就是我们的Babel。

插件

我们研究过了Babel的内部,现在跳出来,我们需要在外部操作AST,而不是进Babel内部去改traverse。

从外部操作AST,就需要插件了。我们来研究一个插件babel-plugin-transform-member-expression-literals

使用次插件后:

// 转换前
obj.const = "isKeyword";

// 转换后
obj["const"] = "isKeyword";

我们再来看看这个插件的源码

{
    name: "transform-member-expression-literals",
    visitor: {
      MemberExpression: {
        exit({ node }) {
          const prop = node.property;
          if (
            !node.computed &&
            t.isIdentifier(prop) &&
            !t.isValidES3Identifier(prop.name)
          ) {
            // foo.default -> foo["default"]
            node.property = t.stringLiteral(prop.name);
            node.computed = true;
          }
        },
      },
    },
  };
}

这里我尝试将它放到我们的实验代码里,如下:

import * as babylon from "babylon";
import traverse from "babel-traverse";
import * as t from "babel-types";
import generate from "babel-generator";

const code = `const obj = {};obj.const = "isKeyword";`;
const ast = babylon.parse(code);
const plugin = {
  MemberExpression: {
    exit({ node }) {
      const prop = node.property;
      console.log('node', node);
      if (
        !node.computed &&
        t.isIdentifier(prop)
        // !t.isValidES3Identifier(prop.name) 这里注释掉,我们的t里没这个方法
      ) {
        // foo.default -> foo["default"]
        node.property = t.stringLiteral(prop.name);
        node.computed = true;
      }
    },
  },
};

traverse(ast, plugin)

const genCode = generate(ast, {}, code);

console.log('genCode', genCode); 

输出的代码是"const obj = {};obj["const"] = "isKeyword";"。符合预期,也就是说,Babel的插件,会传给内部的traverse方法。并且是一种符合访问者模式的,让我们可以针对节点类型(如这里的visitor.MemberExpression)的操作。这里用的是exit,而不是enter了,解释一下,traverse是对树的深度遍历,向下遍历这棵树我们进入(entry)每个节点,向上遍历回去时我们退出(exit)每个节点。就是对于AST,traverse遍历了两遍,我们可以选择在进入还是退出的时候,操纵节点。

结束语

我的参考:

  1. Babel用户手册
  2. Babel中文文档

今天的研究就到这里,理解Babel内部机制和基本的插件工作方式。