Babel 的编译流程小结

317 阅读5分钟

Babel流程以及AST结构

Babel 是什么?

简单理解就是让新的 JS 语法也可以跑在旧的浏览器上。官方文档的定义是:Babel is a JavaScript compiler,那 Babel 和我们通常所说的编译型语言的编译器一样么?Babel 又是如何工作的呢?

编译器和解释器

编译器是一种计算机程序,将某种编程语言(源代码)转换成另一种编程语言(目标代码)。通常,编译型语言最终转换成的目标代码是低阶的计算机能直接运行的程序,也就是可执行文件。

一个现代编译器的主要工作流程如下:

编译流程

解释器是一种计算机程序,能够把解释型语言解释执行。解释器就像一位“中间人”。解释器边解释边执行,因此依赖于解释器的程序运行速度比较缓慢。解释器的好处是它不需要重新编译 整个程序,从而减轻了每次程序更新后编译的负担。相对的编译器一次性将所有源代码 编译成二进制文件,执行时无需依赖编译器或其他额外的程序

解释器执行程序的方法有:

  1. 直接执行高级编程语言(如Shell内建的编译器)
  2. 转换高级编程语言到更有效率的字节码,并执行字节码
  3. 用解释器包含的编译器对高级语言进行编译,并指示中央处理器执行编译后的程序(例如:JIT

JavaScript 是解释性语言,是由宿主环境 (V8 或者 JavaScriptCore)将其转成字节码执行的。而 Babel 的工作是将包含有高级语言特性的 JavaScript 代码,比如 ES9,转换为标准的 ES5 代码。

所以 Babel 是一个 transcompiler

Babel 工作流程

babel 的解析流程分为三步:parse,transform,generate

babel处理流程

Babel Parse

这个阶段的目的是将代码字符串转换成机器能读懂的 AST,这个过程分为词法分析和语法分析

词法解析和语法解析

类似 let a = 1 这种语句都是先按照一定的规则将其拆分成一个个词( token、 记号,后文中将称为 token),类似下面这种

"tokens": [
    {
      "type": {
        "label""name",
        ...
      },
      "value""let",
      "start"0,
      "end"3,
      "loc": ...,
    },
    {
      "type": {
        "label""name",
        ...,
      },
      "value""a",
      "start"4,
      "end"5,
      "loc": ...,
    },
    {
      "type": {
        "label""=",
        ...,
      },
      "value""=",
      "start"6,
      "end"7,
      "loc": ...,
    },
    {
      "type": {
        "label""num",
        ...,
      },
      "value"1,
      "start"8,
      "end"9,
      "loc": ...,
    },
    {
      "type": {
        "label""eof",
        ...,
      },
      "start"9,
      "end"9,
      "loc": ...,
    }
  ]

这个过程是由扫描器(Scanner)完成的。

完成词法分析后,接下来就是将这些 token 组装成 AST,这个过程也叫语法分析。后续的 transform 和 generate 都是对这个 AST 进行操作,可以说 babel 的整个编译流程都是围绕 AST 来的。

这里可以查看 AST 节点的全部定义。当我们有了 AST 结构后,就可以把对代码的操作转成对 AST 的操作。而 transform 就是干这个事情的

Babel transform

babel 会递归遍历整个 AST 树,Babel 使用了访问者模式将 AST 节点和节点处理函数分离,遍历过程中不同的 AST 节点会调用对应的节点函数来处理,比如你定义了如下的访问者对象

const MyVisitor = {
  Identifier: {
    enter(path, state) {
      console.log("Entered!");
    },
    exit(path, state) {
      console.log("Exited!");
    }
  }
};

那么在处理 Identifier 节点时,会在进入和离开时分别调用 enter 和 exit 方法

enter 和 exit 会接受两个入参,path 和 state。

Paths

AST 通常会有许多节点,那么节点之间如何相互关联呢?用 Paths(路径)来简化这件事情,Path 是表示两个节点之间连接的对象。大概的结构如下

{
  "parent": {...},
  "node": {...},
  "hub": {...},
  "contexts": [],
  "data": {},
  "shouldSkip"false,
  "shouldStop"false,
  "removed"false,
  "state"null,
  "opts"null,
  "skipKeys"null,
  "parentPath"null,
  "context"null,
  "container"null,
  "listKey"null,
  "inList"false,
  "parentKey"null,
  "key"null,
  "scope"null,
  "type"null,
  "typeAnnotation"null
}

path 还包含了一些方法, 例如:

get(key) 获取某个属性的 path

traverse(visitor, state) 遍历当前节点的子节点,传入 visitor 和 state(state 是不同节点间传递数据的方式)

State

是遍历过程中 AST 节点之间传递数据的方式。可以在遍历的过程中在 state 中存一些状态信息,用于后续的 AST 处理。

在处理完 AST 之后,接下来就是进入 Generate 阶段生成目标代码和 SourceMap

Babel Generate

generate 阶段会递归打印 ast,每个 nodeType 会对应一个打印方法。比如 DoWhiteStatement 的 nodeType

export function DoWhileStatement(this: Printer, node: t.DoWhileStatement) {
  this.word("do");
  this.space();
  this.print(node.body, node);
  this.space();
  this.word("while");
  this.space();
  this.token("(");
  this.print(node.test, node);
  this.token(")");
  this.semicolon();
}

通过这样的方式递归打印整个 AST,就可以生成目标代码。

SourceMap

打印的过程中可以选择是否生产 sourcemap,sourcemap 就是为了解决目标代码如何自动关联到源代码。一般使用 sourcemap 的目的有两个:

  1. 调试代码时定位到源码 一般浏览器支持在文件末尾加上类似如下的一样注释

    //# sourceMappingURL=http://example.com/path/to/your/sourcemap.map
    

    调试工具会自动解析 sourcemap,关联到源码。这样就可以定位到报错的地方了

  2. 线上报错定位到源码

    sourcemap 一般不会上传到生产环境,而是单独上传到错误收集平台,比如 sentry 就提供了一个插件支持在打包完成后把 sourcemap 自动上传到 sentry 的后台,然后本地 sourcemap 删掉。

那 babel 又是如何把上面三个流程串联在一起,平时使用到的 plugin 又是如何插进这个流程里的呢?这里就要谈下 babel core 的工作流程

Babel Core

babel core 的主要工作就是调用 parse、transform、generate。一般可能是这样

function plugin() {
 return {
  visitor: {
   CallExpression(path) {
    ......
   }
  }
 }
}
const filename = "example.js";
const source = fs.readFileSync(filename, "utf8");

const { code, map } = transformSync(source, {
 parserOpts: {
    plugins: ['jsx']
  },
 plugins: [
  [plugin, {}]
 ],
 presets: []
});

这样通过 options 参数就能将 plugin 和 presets 串联起来了

这里需要注意的是 plugins 和 presets 顺序,总结如下:

  1. 先执行完所有Plugin,再执行Preset。
  2. 多个 plugin,按照声明次序执行
  3. 多个 preset,按照声明次序逆序执行

调用的代码类似如下

// plugins
options.plugins && options.plugins.forEach(([plugin, options]) => {
   ....
});

// presets
options.presets && options.presets.reverse().forEach(([preset, options]) => {
 ....
})

最终通过上面三步流程,babel 完成了代码的转译,而通过分析 ast,处理 ast,可以做很多事情,比如 Rax 就是通过这种方式实现了类 React 的 DSL 语法从而实现编译型小程序多端框架。

参考资料

zh.wikipedia.org/wiki/編譯器

javascript.ruanyifeng.com/advanced/in…

zhuanlan.zhihu.com/p/99395691

en.wikipedia.org/wiki/Babel_…

en.wikipedia.org/wiki/Source…

juejin.cn/post/697866…

zh.wikipedia.org/zh-cn/即時編譯

pandolia.net/tinyc/ch7_l…

github.com/jamiebuilds…