之前写过一篇关于 Babel 插件的文章,感觉过于关注细节,反而不利于理解。这一篇希望可以从代码的转换流程上来讲一讲 Babel 和它的小伙伴们是如何工作的。
背景
随着 JavaScript 的快速发展,现在(2020)的 JavaScript 和几年前(2015)已经完全不一样了,新语法层出不穷,语法的发展速度甚至超越了浏览器和 Node.js 的发展速度,因此 Babel 作为一个 JavaScript 编译器,成为了前端必备大神器,用于将新语法转换成浏览器或者 Node.js 支持的语法,从而用于生产环境。
代码是怎样流转的
Babel 转换源代码的大致流程如下:
插件大致可以分为以下几种:
-
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-runtime
和 external-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-env
的useBuiltIns
,或者手动引入 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,那有没有什么情况下是需要单独引入的呢?