我们知道,ES2015+ 在低版本浏览器中存在严重的兼容问题,为此前人们尝试了各种方式:

Shim/Sham

在远古时代的代码中,我们可能看到过这样的处理:

1
2
<script src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/0.34.2/es5-shim.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/0.34.2/es5-sham.min.js"></script>

shim 仅依靠旧版浏览器中现有的 API,实现了一些新版本 ES 的特性,而 sham 则是仅仅为了保证新版本特性能在浏览器中不抛出错误(因为有些新特性在低版本浏览器中根本无法实现)。

Babel

为了彻底解决上面的问题,Babel 诞生了。Babel 这个名字指的是巴比伦文明里面的通天塔,在那里,人们只说同一种语言。而在前端工程中,我们可以依靠 Babel 将 ES6+ 的语法转换为旧浏览器可以识别的版本。

在浏览器端,Babel 提供了一个运行时的转换器 babel-standlone ,它内置了大量的插件,所以可以直接在浏览器中运行并编译特定标签(type=”text/babel” 的 script 标签)内的代码。

而在 Webpack 中,我们则可以使用 babel-loader 来完成语法的转换。

Polyfill

但是 Babel 并不是万能的,它默认只转换 JavaScript 语法,而不转换新的 API,比如 Promise、Set、Map 等全局对象, Array.from、Object.assign 等全局静态函数和 Array.prototype.includes 等 实例方法也不会被转码。为了解决这个问题,我们需要使用 polyfill。

polyfill 可以认为是 shim 的一种,和常规的 shim 不同的是,它致力于抹平不同浏览器之间的差异。

polyfill.io

1
<script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>

我们可以在页面中引入这样一个 polyfill.io 提供的服务来实现最简单的 polyfill。服务器会根据用户浏览器的 UA 来判断对新特性的支持程度而返回不同的 polyfill 文件,如果用户的浏览器足够强大,那么这个服务将不返回任何内容,做到了一定意义上的按需加载。

但是它的缺点也很明显,这种方式并不能按照代码所用到的新特性按需进行 polyfill,而是会 polyfill 用户浏览器不支持的所有特性。

babel-plugins

babel 为我们提供了几乎所有新特性的插件,比如 @babel/transform-es2015-classes@babel/transform-es2015-arrow-functions 等,使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
// .babelrc
{
"presets": [
[
"@babel/env"
]
],
"plugins": [
"@babel/plugin-transform-object-assign",
"@babel/transform-es2015-arrow-functions"
]
}

这样,babel 就可以帮我们转换指定的特性了,可以看一下下面的栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const foo = (a, b) => {
return Object.assign(a, b);
};

// 打包后
function _extends() {
_extends =
Object.assign ||
function(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (
Object.prototype.hasOwnProperty.call(
source,
key
)
) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}

var foo = function foo(a, b) {
return _extends(a, b);
};

可以看到,@babel/plugin-transform-object-assign 使用 ES5 自己实现了一份 Object.assign 并插入到了我们的代码之前。

但是这样做有两个问题,一是在不同的模块中如果都使用了 Object.assign,它可能在每个使用到的模块中都被实现一次并将这些 helpers 代码添加到模块顶部,会大幅增加构建包的体积;另一个问题则是我们需要手动为我们用到的特性添加所有的插件。

在这个背景下, @babel/plugin-transform-runtime 应运而生。在 Babel 的插件配置中,我们只需要引入一个 @babel/plugin-transform-runtime ,它会自动检测我们代码中需要转换的部分并重写,例如 Object.assign 会被重写为 _extends 并引入,而重复的模块也会被抽离成一份。配置如下:

1
2
3
4
5
6
7
8
9
10
11
// .babelrc
{
"presets": [
[
"@babel/env"
]
],
"plugins": [
"@babel/plugin-transform-runtime"
]
}

和单独引入各个插件一样,transform-runtime 同样存在模块级别的限制,因此它也依然无法转换 Array.prototype.includes 这样的方法。

babel-polyfill

对于这种全局方法,Babel 提供了 babel-polyfill,我们可以这样引入:

1
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.js"></script>

显然,这样会全量引入所有的补丁,不论你的项目是否需要。

也可以这样使用 npm 包的形式加载:

1
2
3
4
5
6
7
8
9
10
11
12
import '@babel/polyfill';

const test = [1, 2, 3].includes(1);

// 打包后
$export($export.P, 'Array', {
includes: function includes(el /* , fromIndex = 0 */) {
return $includes(this, el, arguments.length > 1 ? arguments[1] : undefined);
}
});

const test = [1, 2, 3].includes(1);

和使用 script 标签引入类似,全量引入后,这个仅有2行的原始文件,打包后的大小变成了 246K:

1
2
3
4
5
6
7
8
Build completed in 1.286s

Hash: 3103b6ec6e9cb29c304b
Version: webpack 4.29.6
Time: 1464ms
Built at: 2019-03-01 16:05:13
Asset Size Chunks Chunk Names
app.js 246 KiB app [emitted] [big] app

为了实现按需引入,我们需要依靠 @babel/env,而在 Babel7 中,提供了 useBuiltIns: usage 这样一个新配置。结合配置如下:

1
2
3
4
5
6
7
8
9
"presets": [
[
"@babel/env",
{
"useBuiltIns": "usage",
"targets": "> 1%, last 3 versions, not ie <= 7"
}
]
]

useBuiltIns 配置为 entry 时, 会根据指定的 targets 中的 browserlist 将polyfills拆分引入,仅引入有浏览器不支持的polyfill。而配置为 usage ,babel 则会检测代码中新特性等的使用情况,仅仅加载代码中用到的 polyfills,同时,不需要再手动 import '@babel/polyfill'。browserlist 的配置可以在这里查看: https://browserl.ist/

重新打包,app.js 恢复了正常大小:

1
2
3
4
5
6
7
8
Build completed in 0.769s

Hash: 21afcbb413dad4242589
Version: webpack 4.29.6
Time: 946ms
Built at: 2019-03-01 16:13:23
Asset Size Chunks Chunk Names
app.js 22.8 KiB app [emitted] app

总结

对于常规项目来说,我们可以手动指定要兼容到的 targets,进行如下配置来保证尽可能的安全并且按需加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"presets": [
[
"@babel/env",
{
"modules": false, // 使用 es modules
"useBuiltIns": "usage",
"targets": "> 1%, last 3 versions, not ie <= 7"
}
]
],
"plugins": [
"@babel/plugin-transform-runtime"
]
}

而如果在开发的是一个库,为了避免全局的污染,则只能使用 transform-runtime 的方案,这种方案不能解决 Array.prototype.includes 等实例方法的转换,如果库中有应用,需要在文档中特意提醒开发者注意。

展望

在HTML5中,有一个新 API module,我们可以使用这个特性来检测浏览器对 ES6的支持程度,例如,支持 <script type="module"> 的浏览器也支持 async、await、Class、Promise 等新特性。

因此,我们可以打包出2份 JavaScript 文件同时引入:

1
2
<script type="module" src="app.js"></script>
<script nomodule src="app-legacy.js"></script>

大部分现代浏览器都会自动识别 script 标签中的 type="module"nomodule 属性,加载 app.js 忽略 app-legacy.js。

我们只需在 app-legacy.js 中添加 polyfill 即可,这样,在兼容低版本浏览器的同时,我们也将在现代浏览器中体验到巨大的性能提升。

参考

https://www.babeljs.cn/docs/usage/polyfill/
https://github.com/sorrycc/blog/issues/80
https://segmentfault.com/a/1190000010106158