Babel和它的小伙伴们

之前写过一篇关于 Babel 插件的文章,感觉过于关注细节,反而不利于理解。这一篇希望可以从代码的转换流程上来讲一讲 Babel 和它的小伙伴们是如何工作的。

背景

随着 JavaScript 的快速发展,现在(2020)的 JavaScript 和几年前(2015)已经完全不一样了,新语法层出不穷,语法的发展速度甚至超越了浏览器和 Node.js 的发展速度,因此 Babel 作为一个 JavaScript 编译器,成为了前端必备大神器,用于将新语法转换成浏览器或者 Node.js 支持的语法,从而用于生产环境。

代码是怎样流转的

Babel 转换源代码的大致流程如下:

parser
plugins
Source code
AST
Transformed code

插件大致可以分为以下几种:

  • Syntax plugins

    • 通常命名为 @babel/plugin-syntax-xxx,用于开启 parser 的相关插件,支持新语法的解析。
  • Transform plugins

    • 通常命名为 @babel/plugin-proposal-xxx 或者 @babel/plugin-transform-xxx,用于转换某个新语法到兼容性更好的语法。
  • Presets

    • 通常命名为 @babel/preset-xxx,用于打包加载一组插件,让配置更友好。

第一步:语法解析

拿到一段源代码,Babel 干的第一件事情就是解析语法树,这个时候主要是 @babel/parser 的工作。

默认情况下 parser 仅支持已经进入 ECMAScript 规范的语法,如果需要支持一些更新的语法,则需要开启对应的 syntax plugins。

举个例子,class properties 目前处于 stage-3,默认是不支持的,所以以下语法解析的时候会报错:

class Cat {
  age = 2;}

报错如下:

  1 | class Cat {
> 2 |   age = 2;
    |       ^
  3 | }

这时需要我们开启语法插件 classProperties@babel/parser 才能识别 class properties 语法。

我们可以选择添加 @babel/plugin-syntax-class-properties 的 syntax plugin,让它来帮我们完成需要的 parser 配置,这样可以更少地暴露 parser 的实现细节。

但是,实际中我们通常不需要额外地去开启这些插件,具体原因请看下文。

划重点:语法解析过程中,针对尚未进入规范的语法,需要开启对应的插件(Syntax plugins),parser 才能识别。

第二步:语法转换

这一步是整个编译过程的核心,主要是 @babel/core 在起作用。

在这个步骤中,我们可以对语法树进行各种魔幻的操作,比如修改替换引用的依赖(babel-plugin-import)、将新的语法转换成浏览器支持的旧语法(optional chaining)等等。

再看前面的例子,为了让包含 class properties 的代码可以在浏览器中执行,我们需要对它进行转换,比如替换成在 constructor 中创建一个实例属性。

这时我们就需要添加一个转换插件:@babel/plugin-proposal-class-properties

值得一提的是,Babel 在升级到 7.0 的时候,新增了一个命名规则:

凡是尚未成为规范的语法,其转换插件命名都使用 @babel/plugin-proposal-xxx 的形式,用于表示这个转换还处在提议阶段。直到它稳定下来进入规范,才改名为 @babel/plugin-transform-xxx然而目前来看,他们并没有对新加入规范的特性插件进行改名,大概是牵连甚广,不好操作吧。

因此,class properties 的转换插件名为 @babel/plugin-proposal-class-properties,而转换 arrow functions 的插件名为 @babel/plugin-transform-arrow-functions,但实际上他们是同一类的插件。

上面还提到,语法解析的 syntax plugins 通常不需要额外添加,原因是对应的转换插件同样也依赖了解析,所以如果引入了转换插件,就同时开启了 parser 插件。所以为了支持 class properties,我们只需要添加 @babel/plugin-proposal-class-properties,无需再添加 @babel/plugin-syntax-class-properties

划重点:转换语法需要添加对应的转换插件,且转换插件会依赖相关的语法插件,使我们不需要单独添加语法插件。

后置转换

Babel 插件的运行是有顺序的,所以有些插件可以选择在其他插件之后运行,对其他插件的输出继续做转换处理,比如压缩代码,比如对 class properties 中的 arrow functions 做二次转换。

这里重点讲一下 transform-runtimeexternal-helpers

transform-runtime

@babel/plugin-transform-runtime 是一个常用而又复杂的插件,使用不当可能会出现重复代码导致代码体积增大,或者依赖缺失导致应用异常。

场景一:复用 helpers

这是它的主要作用之一,复用 Babel 插入的一些辅助方法(helpers),减少重复代码。

举个例子,打包前:

// a.js
export function a(p) {
  return { id: 'a', ...p };
}

// b.js
export function b(p) {
  return { id: 'b', ...p };
}

// index.js
export * from './a';
export * from './b';

打包后得到:

function _extends() { /* ... */ }
function a(p) {
  return _extends({ id: 'a' }, p);
}

function _extends$1() { /* ... */ }
function b(p) {
  return _extends$1({ id: 'b' }, p);
}

Babel 在转换 ESNext 语法的时候,经常会生成一些辅助方法,如 _extends 用于扩展对象属性,_classCallCheck 用于检查 Class 的调用。如果每次遇到一个这样的场景,都生成一个这样的方法,势必会有很多不必要的重复方法,transform-runtime 的作用就是将这些方法全部转换成来自 @babel/runtime 的引用,从而让他们被复用。

开启插件后得到,源码变成这样,就不会有重复的实现了:

// a.js
import _extends from '@babel/runtime/helpers/extends';
export function a(p) {
  return _extends({ id: 'a' }, p);
}

// b.js
import _extends from '@babel/runtime/helpers/extends';
export function b(p) {
  return _extends({ id: 'b' }, p);
}

// index.js
export * from './a';
export * from './b';

由于转换后的代码依赖了 @babel/runtime,因此使用 transform-runtime 的时候,我们必须把 @babel/runtime 添加到 dependencies 中。

场景二:提供沙箱运行环境

这里是指针对一些 ESNext 的全局变量,通过沙箱内部提供实现,达到不污染全局,不与其他库类冲突的效果。

说白了,就是把 Map、Set、Promise 等新增的全局对象替换成来自 core-js 的依赖。这样即使页面提供了一个有问题的全局实现,也不会影响到我们的代码。

这种转换默认是不开启的,需要设置 { corejs: 3 } 并安装相关依赖才会启用。

// in
var b = new Map();

// out
import _Map from '@babel/runtime-corejs3/core-js-stable/map';
var b = new _Map();

一般来说,这种用法是不推荐的,因为它会增加代码量,而且在很多情况下可能是不必要的。比如运行环境为较新的浏览器时,我们根本不需要多打包一份 Map 的实现。

更推荐的用法是,在页面级全局引入一次 polyfill,然后各个库类代码共享。即:

// 入口 polyfill
import "core-js/modules/es.map";

// 直接使用
var b = new Map();

场景三:提供 regenerator 环境

这里可以解答一个常见问题:

为什么使用了 async / await 后提示 regeneratorRuntime is not defined

当代码中使用了 async / await 或者 generator 后,转换后的代码会依赖一个全局变量,叫 regeneratorRuntime。如果配置不当且没有合适的 polyfill,就会出现 regeneratorRuntime is not defined 的提示。

与场景二不一样的地方是,regeneratorRuntime 是一个特殊的全局变量,浏览器本身并不会支持它,所以我们总是会需要引入一次实现才能正常运行。

举个例子,generator 编译如下:

// in
function* foo() {}

// out
"use strict";

var _marked = [foo].map(regeneratorRuntime.mark);
function foo() {
  return regeneratorRuntime.wrap(    function foo$(_context) {
      while (1) {
        switch ((_context.prev = _context.next)) {
          case 0:
          case "end":
            return _context.stop();
        }
      }
    },
    _marked[0],
    this
  );
}

这时 transform-runtime 就可以发挥作用了,它可以将这个全局变量转换成来自 @babel/runtime 的引用,大概是这个意思:

import regeneratorRuntime from '@babel/runtime/regenerator';

所以,如果你遇到了 regeneratorRuntime is not defined 的错误,说明你没有开启 transform-runtime 插件,而且没有引入 regeneratorRuntime 的全局 polyfill。

这时可以根据实际情况二选一:

  • 对于 library,可以开启 transform-runtime,将 regeneratorRuntime 作为一个依赖内部消化。
  • 对于 application,可以打开 @babel/preset-envuseBuiltIns,或者手动引入 polyfill:

    import "regenerator-runtime/runtime";

external-helpers

external-helpers 是一个与 transform-runtime 相对的插件,不那么常用。

它的作用是把所有的辅助方法都打到外面去,通过全局变量的方式访问。

// in
function a(p) {
  return { id: 'a', ...p };
}

// out
function a(p) {
  return babelHelpers["extends"]({ id: 'a' }, p);}

helpers 方法都引自一个全局变量 babelHelpers,这种情况下需要我们自行引入 babelHelpers 的实现,如使用 babelCore.buildExternalHelpers() 生成一份代码,或者借助别的插件来填充。

相比之下,这种方式比 transform-runtime 更为麻烦,使用场景较少。

总结

Babel 先通过 parser 解析代码到 AST,然后通过各种转换插件对 AST 进行转换,最终输出我们想要的代码。

  • 解析过程中 @babel/parser 在各种 syntax plugins(@babel/plugin-syntax-xxx)的支持下识别新语法;
  • 转换过程中 @babel/core 在各种 transform plugins(@babel/plugin-proposal-xxx@babel/plugin-transform-xxx)的支持下对语法进行转换;
  • 去掉重复代码,补充必须的 polyfill,按需压缩代码,最后得到编译后的产物。

问题

最后留下一个小问题,既然大部分情况下都不需要额外引入 syntax plugins,那有没有什么情况下是需要单独引入的呢?


© 2020