浅谈 Babel helpers 和 polyfill

缘起

假设你已经了解并使用过 Babel,如果你在使用过程中遇到过以下问题,那么这篇文章也许能解决你的疑惑:

  • 为什么打包出来的代码中有很多重复的 _extends_classCallCheck 方法?
  • 为什么生成的 Promise 代码会挂载到全局?
  • 为什么使用了 async / await 后提示 regeneratorRuntime is not defined

背景

首先介绍几个我们接下来将涉及的工具:

能够看到这里,想必大家对这些工具都有所了解。

流程

接下来我们看一下,一个 JavaScript 应用在构建的过程中,到底经历了什么。

编译

首先是 Babel 编译,如文档所说,Babel 是一个 JavaScript 编译器。它会把 ESNext 语法解析成语法树,然后转换成各种兼容性更好的语法。

举个例子:

// in
var b = { ...a };

// out
function _extends() { /* ... */ }var b = _extends({}, a);

这里的 _extends 就是一个 helper,用于实现 ... 的展开功能。

打包

然后是 Webpack / Rollup 打包,可以将多个文件打包成一个文件。

这里值得注意的是,@babel/preset-env 可能会把 ES6 编译成 commonjs,导致 ES6 模块的一些特性在 Webpack / Rollup 打包时失效,如静态依赖分析、Tree shaking,所以我们一般会禁用 Babel 的模块转换,而让打包工具来处理:

// .babelrc
{
  "presets": [
    ["@babel/preset-env", { "modules": false }]
  ]
}

如果我们有两个文件都用到了 ...,不使用特殊插件的话,就会在每个文件里都生成一个 _extends 方法:

// 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);
}

这里我们就遇到了第一个问题:打包后的代码有重复的 _extends 方法。

插件

接下来我们看一下 Babel 的插件干了什么。

preset-env

@babel/preset-env 是一个插件集,它会根据指定环境对 JS 特性的支持情况,来按需转换 ESNext 代码。

如果开启了 corejs 配置,它还会自动从 core-js 中引入 polyfill。

比如:

// .babelrc
{
  "presets": [
    ["@babel/preset-env", { "modules": false, "corejs": 3 }]
  ]
}

这时我们使用 Map 的话,如果环境不支持,就会自动引入 Map 的 polyfill:

// in
var b = new Map();

// out
import "core-js/modules/es.map";
var b = new Map();

这就解答了我们的第二个问题,自动引入的 polyfill 是挂载到全局的。

preset-env 里面并没有处理 helpers 的插件,所以它会把 Babel 生成的 helpers 方法原样保留在代码中。

transform-runtime

@babel/plugin-transform-runtime 是一个 Babel 插件,从名字可以看出,它的作用是将 Babel 生成的一些代码转换成 runtime 里的实现。

它的作用包括两个方面:helpers 和 polyfill。所以其实它才是今天的重磅角色。

helpers

先看一下 helpers 的处理。

不指定 corejs 的情况下,transform-runtime 会使用 @babel/runtime 来提供 helpers 的实现。所以上面的代码会变成:

// 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';

如果指定 corejs: 3,配置如下:

// .babelrc
{
  "plugins": [
    ["@babel/plugin-transform-runtime", { "corejs": 3 }]
  ]
}

transform-runtime 就会使用另一份包含了 core-js 的 runtime —— @babel/runtime-corejs3,所以上面的代码会变成:

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

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

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

你可能会奇怪,这有什么区别呢,不就是换了个库吗?区别在于它对 polyfill 的处理。

polyfill

前面已经提到了 @babel/preset-env 对 polyfill 的处理,它会从 core-js 中引入对应的实现,挂载到全局。

但是有时候,我们可能只是打包一个库,不希望污染全局环境,应该怎么做呢?这就是 transform-runtime 的价值所在了。

只有设置了 corejs: 3 的情况下,transform-runtime 才会处理 polyfill,而且它并不是以全局加载的方式引入,所以有另一个名字叫 ponyfill,指的是它不改变任何环境已有的代码,而是引入一个单独的包来提供当前不支持的功能。用官方文档的说法,这是为当前代码创建一个沙箱环境

比如使用 Map 的场景下,代码会这样转换:

// in
var b = new Map();

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

这样有几点好处:

  • 不会污染全局环境,不会影响其他包;
  • 不依赖环境提供的全局变量,不会受到其他包影响;
  • 依赖内部消化,作为库发布时,依赖处理会更简单。

external-helpers

这个插件的意思也比较明显了,就是把所有的辅助方法都打到外面去,通过全局变量的方式访问。

转换后的代码变成了:

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

// b.js
export function b(p) {
  return babelHelpers["extends"]({ id: 'b' }, p);}

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

这样一来,helpers 方法的实现肯定是不会重复了,但是依赖了一个全局变量 babelHelpers,这种情况下需要我们自行引入 babelHelpers 的实现,如使用 babelCore.buildExternalHelpers() 生成一份代码,或者借助别的插件来填充。

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

rollup-plugin-babel

和 Webpack 相比,Rollup 打包的代码冗余更少,可读性更好,所以通常用于库的构建。

rollup-plugin-babel 是一个 Rollup 的插件,它在打包过程中使用 Babel 编译代码,并应用 Babel 的插件。

那么它与直接用 Babel 编译、然后再打包的区别在哪里呢?

  • 如果先编译后打包,会出现重复代码。

    当然我们可以通过使用 transform-runtime 插件加上配置 externals 来解决重复代码的问题,但是那样配置会很复杂。

  • 如果先打包后编译,速度会更慢,因为文件更大了。

所以有了这个插件,一边打包,一边编译。

默认情况下,它会将所有的 helpers 方法集中起来,插入到结果的头部,以确保 helpers 方法只被插入了一次。它的实现原理是直接将所有的 helpers 方法插入到代码最前面,然后通过 Tree shaking 去掉没用到的方法。

打包的时候会得到:

function _extends() { /* ... */ }
/* 还有很多其他的 helpers 方法 */

export function a(p) {
  return _extends({ id: 'a' }, p);
}

export function b(p) {
  return _extends({ id: 'b' }, p);
}

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

然后通过 Tree shaking,只有我们用到了的 _extends 被保留。

当我们用这个插件创建 UMD 包时,默认配置就可以满足我们的需求了,将所有 helpers 方法直接 inline 到生产的代码中,以在浏览器中执行。

但是发布第三方库的时候,这样也有可能造成代码的重复,因为我们可能要在应用中引入多个库,而每个库都 inline 了一段这样的 helpers 代码。

这个时候,就可以结合 transform-runtime 来使用了。只需要指定 runtimeHelpers: true,配置如下:

const babel = require('rollup-plugin-babel');

babel({
  runtimeHelpers: true,
  plugins: [
    ['@babel/plugin-transform-runtime', { corejs: 3 }],
  ],
})

这里还开启了 corejs: 3,还可以一并解决 polyfill 污染全局的问题,很适合构建库的场景。

问题

还有一个遗留问题,为什么在打包成 UMD 时,会出现 regeneratorRuntime is not defined 的报错?

前面已经说到,transform-runtime 有自动引入新特性的能力,所以当代码中出现了 async / await 时,它会先编译成 generator,然后从 @babel/runtime/regenerator 中引入 regeneratorRuntime,这时是不会有依赖问题的。

而在库的编译中,我们可能不希望重复打包 generator 的运行时,所以选择不开启 core-js polyfill,这时生成的代码会期望这个运行时在一个叫 regeneratorRuntime 的全局变量中。其实就和 Promise、Map、Set 一样,regeneratorRuntime 也是这类全局变量中的一员,只不过它没有被浏览器原生支持,需要我们手动引入。

加入 regeneratorRuntime 的 polyfill:

import 'regenerator-runtime/runtime';

也可以直接从 CDN 加载:

<script src="https://cdn.jsdelivr.net/npm/regenerator-runtime/runtime.min.js"></script>

总结

  • @babel/preset-envcore-js 的加持下,可以自动为我们添加项目所需的 polyfill,但是会污染全局环境。
  • @babel/plugin-transform-runtimecore-js 的加持下,可以将 helpers 和 polyfill 转换成包的依赖,既解决了重复代码的问题,又解决了 polyfill 污染全局的问题。
  • 构建库时,可以使用 rollup-plugin-babel 插件:

    • 可以使用内置的 externalHelpers 方案,确保只引入一次 helpers,但是需要自己加载 polyfill,而且在多个包之间还是会存在重复代码;
    • 使用 runtimeHelpers 方案,结合 @babel/plugin-transform-runtime,通过 runtime 来提供实现,更好地复用代码。因此这是更推荐的做法。
  • 如果不想用 Rollup,可以考虑 @babel/plugin-external-helpers 来从全局变量加载 helpers,但是需要自己加载 babelHelpers 这个全局变量。

© 2020