从零搭建webpack前端类库脚手架[3]-强悍的babel

从零搭建webpack前端类库脚手架[3]-强悍的babel

上一节我们提到了ES6语法转换插件 babel-loader, 然而babel-loader只是webpack调用 babel的一个桥梁。 实际上,babel是一个具有强大语言转换功能的独立程序。它的主要功能是把ES6或者更新的语言语法转换为浏览器可识别的ES5语法。

了解ES6

ES6甚至包括后来出现的ES7都是下一代的JavaScript的语法版本名称。目前chrome已经支持了大部分的ES6语法,而其他一些浏览器还是大多数支持ES5为主。如果要在纯前端兼容低端浏览器,则需要 es6-shim 之类的前端js库解决polyfill的问题,用babel-standalone.js 解决新的es语法转换的问题(如箭头函数)。

在这里可以看到各个平台(包括手机和PC端)对ES6,ES7等标准的各个特性的支持情况:http://kangax.github.io/compat-table/es6/#chrome61
这个网址非常全,其平台涵盖了所有浏览器、server端平台(包括node),以及各种polyfill库对ES语法的支持情况(其实babel-preset-env 这个智能预设就是利用这个对照表(compat-table)进行自动化的插件加载的)。

如果你希望了解 Nodejs 平台的各个版本对ES特性的支持情况,可以戳这里:
http://node.green/

至于ES6的语法学习,请参考中文的阮一峰的:http://es6.ruanyifeng.com/#docs/reflect
或者我写的 es6语法精要

babel

为了提前使用更新的JavaScript语法,大佬们就发明了babel。通过babel,可以把我们写的ES6语法的代码,转换为ES5语法。这样我们就可以写ES6最终却可以在ES5的浏览器上跑了,岂不快哉。

Babel 把用最新标准编写的 JavaScript 代码向下编译成可以在今天随处可用的版本。 这一过程叫做“源码到源码”编译, 也被称为转换编译(transpiling,是一个自造合成词,即转换+编译。以下也简称为转译)

不过 Babel 的用途并不止于此,它支持语法扩展,能支持像 React 所用的 JSX 语法,同时还支持用于静态类型检查的流式语法(Flow Syntax)。

更重要的是,Babel 的一切都是简单的插件,谁都可以创建自己的插件,利用 Babel 的全部威力去做任何事情。在架构上,Babel 自身被分解成了数个核心模块(@babel/core, @babel/cli),任何人都可以利用它们来创建下一代的 JavaScript 工具。

我对shim、polyfill和transform的理解

上面我们讲了,babel是一种语言转换的技术。那么其实要想在浏览器里运行更新的语言语法,需要解决2个问题。

  • 一个是新的语法(如箭头函数、async函数)怎么解析
  • 一个是以前没有的类或方法该怎么hack(伪造),如Promise。

对于这种新增的类、方法,我们很容易想到可以在JavaScript运行之前去hack一个自己实现的类,如自己造一个Promise. 这种方法在业界叫做shim或者polyfill技术。比如,如果你想让你的ES6代码支持低端浏览器,这里是一个shim库: https://github.com/paulmillr/es6-shim

shim、polyfill所谓的垫片技术,是通过提前加载一个脚本,给宿主环境添加一些额外的功能。从而让宿主拥有更多的能力。例如可以基于JavaScript的原型能力,给Array.prototype增加额外的方法,就可以一定程度上让宿主环境拥有ES6的能力。

然而,有些功能,是通过shim/polyfill技术难以实现的,比如箭头函数 =>,因为JavaScript自身无法提供这样的能力进行扩展。所以就必然需要进行 语言转换transform/transpiling,即将代码中的 => 箭头预先转换为ES5的 function 函数。

babel为浏览器提供了一个运行时的转换器babel-standlone. 这个版本内置了大量的babel插件,所以可以直接在浏览器中运行并编译特定标签内的ES6+代码(而不需要安装额外的预设或插件), 开发者只需要把ES6脚本放在页面的script标签之中,但是要注明type=”text/babel”. 这种standalone版本由于需要实时编译,因此主要用在那些非商业产品中,比如说在线的try-out网站,jsfiddler这样的网站,或者一些APP上内嵌一个V8引擎让你REPL执行ES6语法的场景。

独立版本的babel使用方法类似下面这样:

standalone

在商业产品的生产环境下,一般是通过babel对代码进行预编译,这样最终运行在浏览器中的代码就是ES5了,就不存在性能问题了

babel编译代码的几种方式

直接执行es6代码

在后端node项目或单元测试中,可能你会需要直接执行ES6编写的代码(一般也只用在mocha等测试场景,生产环节还是建议预编译后再执行)。 而基于 @babel/cli,你便可以实现的直接执行node代码,因为@babel/cli自带了一个babel-node的命令行程序,可以直接执行node.js脚本。

首先安装babel-node命令行工具

1
npm i @babel/node @babel/core --save-dev

现在babel7之后,babel内置的模块和插件都放在了一个babel的命名空间下, 而且为了版本控制一般建议局部安装。

然后在 node_modules/.bin 下会有 babel-node 命令可以用。我们通过两种方式来直接执行es6.js 脚本:

1
2
3
node_modules/.bin/babel-node es6.js
# 或使用 npx 命令(npm@5.2.0之后支持)
npx babel-node es6.js

也可以在 package.json 中写成 npm scripts

1
2
3
4
5
{
"scripts": {
"start": "babel-node es6.js"
}
}

注意,在执行 babel-node 时,你需要配置自己的 .babelrc 文件开启babel相关的转换插件。否则默认情况下babel会什么都不做。

题外话: 为什么 webpack 在没有使用babel的时候,却可以转译 esmodule 的模块化语法呢?其实是因为 webpack 从版本2之后 确实内置了对 esmodule 的默认转译支持,不需要依赖babel。但仅限 esmodule 的模块课语法而已,所以在webpack中要使用 ES6+ 新特性,还是要安装并配置babel.

现在我们来配置一下babel的ES语法转换,最简单的办法是使用官方的 env 预设(这已经是babel7默认的建议预设)。先安装这个预设 babel-preset-env:

1
npm i @babel/preset-env --save-dev // babel7

然后在 .babelrc 或 babel.config.js(babel7的配置文件) 里配置babel:

1
2
3
4
5
// babel.config.js
const presets = [
["@babel/preset-env"]
];
module.exports = { presets }

其实跟 babel.config.js 跟 babelrc 的原理是一样的,只是JavaScript文件更灵活,所以babel7建议使用 babel.config.js 或 .babelrc.js

另外,babel-node 默认是加载了 poyfill 的,所以各种新的 API 都能用。 (关于babel polyfill 的知识点,我们后文再讲)

那么,babel-node 适用于什么场景呢?由于它是实时进行 ES 转换执行,因此会比较消耗内存,不适用于 Node.js 的生产环境。所以一般用来在开发阶段使用,或临时测试一些 es6 编写的脚本来用。

babel-register

Node中另一种直接执行ES6代码的方式是使用 babel-register,该库引入后会重写你的require加载器,让你的Node代码中require模块时自动进行ES6转码。例如在你的 index.js 中使用 babel-register:

1
npm install @babel/core @babel/register --save-dev
1
2
3
4
// index.js
require('@babel/register')
...
require('./abc.js') // abc.js可以用ES6语法编写,require时会自动使用babel编译

另外,需要注意的是,babel register只会对require命令加载的文件转码,而不会对当前文件转码。所以最好你要设计一个什么都不做的入口(如 index.js) 然后让这个 index.js 只做一件事情:就是加载其他模块。另外,由于它是实时转码,所以同样只适合在开发环境使用。

babel register 一般用在 mocha 等测试框架加载es6的测试脚本时使用。

babel命令

一般外网要上线的 Node 或 JavaScript 代码,都不会用 babel-node 或 babel-standalone 直接去运行时运行的。因此,上线前必须提前编译为目标平台可支持的语法的代码。

如果你已经安装了 @babel/cli, 那么就有了babel命令可以用。babel命令就是对源码进行转译的命令。使用方法如下:

1
2
3
4
5
6
7
8
9
10
# 编译 example.js 输出到 compiled.js
babel example.js -o compiled.js

# 或 整个目录转码
# --out-dir 或 -d 参数指定输出目录
$ babel src --out-dir lib
# 或者缩写参数
$ babel src -d lib
# -s 参数生成source map文件
$ babel src -d lib -s

看一个例子:

1
2
3
// index.js
// Babel Input: ES2015 arrow function
[1, 2, 3].map((n) => n + 1);

然后安装babel相关模块,并执行编译:

1
2
npm i @babel/cli @babel/core --save-dev // 安装babel
npx babel ./index.js -d dist // 编译index.js 生成到dist目录下

然后对babel进行配置,我们像上文中一样配置好 babelrc 的 preset-env 预设;然后执行编译得到结果:

1
2
3
4
5
6
"use strict";

// Babel Input: ES2015 arrow function
[1, 2, 3].map(function (n) {
return n + 1;
}); // Babel Output: ES5 equivalent

箭头函数已经被编译成了普通函数。

API调用babel实现编译

如果想在代码中调用babel API进行转码。则依赖的是@babel/core。(理论上,@babel/cli, @babel/node 底层都是依赖 babel-core而已啦)。我们先来安装babel-core

1
npm install @babel/core --save-dev // babel7

安装后可以调用babel这个模块的函数进行编译:

1
2
3
4
5
6
7
8
9
var babel = require("@babel/core");
// import { transform } from "@babel/core"; // 引入方法2
// import * as babel from "@babel/core"; // 引入方法3

babel.transform("code();", options, function(err, result) {
result.code;
result.map;
result.ast;
});

其中 options 就是babel的配置。一般情况下,我们并不会用API的方式调用babel。这里就不多做讲述了。总之 本质上跟我们通过其他方式调用babel都是一样的,配置内容也是一样的,只是API方式调用时我们的babel配置是通过函数传给babel。具体转码API的使用,可以参考官方文档: https://babeljs.io/docs/core-packages/.

通过babel-loader调用babel

其实我们在大部分情况下,做前端项目是在用 babel-loader 来使用 babel。一般前端项目都有一套自己的构建、打包过程的。而这种情况下,我们要用ES6,就可以顺便把babel加入到这个构建过程当中(岂不是更加灵活咯)。而babel也为webpack这类的工具提供了对应的loader。(有了babel-loader,webpack就能在打包过程中加入babel对es6源码进行编译处理了)

其实babel除了能支持webpack,也能支持JavaScript社区所有的主流构建工具,可以访问这里寻找各种构建工具的集成帮助:

babel-loader的使用方法实际上跟你使用命令CLI或者API的方式都是一模一样的。只是这个调用者变成了webpack,webpack执行时其实类似于你通过babel API来转译你的源码。所以他们之间的关系是:

webpack 依赖 babel-loader; babel-loader 依赖 babel core

babel-loader 是无法独立存在运行的。在babel-loader的package.json里你会发现有个 peerDependencies,其中就定义了一个依赖项叫做webpack。(peerDependencies依赖一般表示了一个模块所依赖的宿主环境, 一般各种插件都会这样表明依赖)

看下使用babel-loader时,webpack是如何配置的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
]
}
]
}
}

由于babel会自动寻找目录下的 .babelrc,所以上面代码中babel-loader中的options配置可以不写,而放到独立的babelrc配置文件当中。

babel配置详解

终于要学习复杂的 babel 配置了。

Babel的配置文件是.babelrc或者babel.config.js(babel7推荐的),存放在项目的根目录下,(rc结尾的文件通常代表运行时自动加载的文件、配置)。使用Babel的第一步,就是配置这个文件。
你可以通过配置 插件(plugins)预设(presets,也就是一组插件) 来指示 Babel 去做什么事情。

其基本格式如下:

1
2
3
4
{
"presets": [],
"plugins": []
}

除了放到 .babelrc 中,该配置还可放到package.json中也可以生效, 如:

babelrc

插件配置

babel6以后,babel自身只能完成基本的核心功能。并不去做转换任何语法特性的事情。如果要做某个特性的转换,则必须安装并配置插件。

比如 transform-es2015-classes 这个插件就可以让babel转译你代码中的class定义的语法。再比如如果想用箭头函数,得装上插件 @babel/plugin-transform-arrow-functions

插件安装后,只需要在 babelrc 中协商 plugin 配置:

1
2
3
{
"plugins": ["@babel/plugin-transform-es2015-arrow-functions"]
}

如果要编译react jsx 语法,则可以安装react的插件:

1
npm install --save-dev @babel/preset-react

babel官方内置插件都在babel的官方仓库package目录下(babel-cli代码也在这): https://github.com/babel/babel/tree/master/packages. 虽然插件包都放在 babel 仓库中,但其实每个包都是个独立的 npm 模块,使用时别忘记安装哦

预设配置

难道使用babel要自己去配置这么多插件吗?写起来非常麻烦,而且开发者很难知道需要使用什么特性。所以有了preset预设。 一个预设就包含了很多插件咯。preset预设是一系列plugin插件的集合。

比如在 babel6 之前,使用 preset-es2015 的预设就可以转换 es2015 的所有语法特性(如class定义)。其实就因为 es2015的预设中已经包含了 transform-es2015-classes 这个插件。官方的预设还是在babel的这个仓库里.

babel package

babel7 已经废弃了以前的 es2015 es2016 es2017 等预设,现在内置的预设如下:

  • env
  • flow
  • react
  • stage-0
  • stage-1
  • stage-2
  • stage-3

还有其他一些非官方的预设,可以在npm上进行搜索

其中,es2015, es2016, es2017分别代表不同ES标准, env表示根据你设定的环境进行转译的智能预设。react、flow是另一个领域的,暂且不表。另外还有 stage-0, stage-1 等预设代表最新标准的提案四个阶段.

由于 es2015 等预设已经过时,我们就按照最新的 babel7 来讲解。如果要使用某个预设,就先安装它。例如 npm i @babel/preset-env --save-dev。然后.babelrc中加入如下配置, 把包名的最后那个名字加进去即可:

1
2
3
4
5
6
{
"presets": [
"@babel/preset-env"
],
"plugins": []
}

着重介绍preset-env

有一个预设叫做 babel-preset-env, 他是一个高级的预设,能编译 ES2015+ 到 ES5,但它是根据你提供的目标浏览器版本和运行时环境来决定使用哪些插件和polyfills。 这个预设是 babel7 里面唯一推荐使用的预设, babel7建议废弃掉其他所有的预设。preset-env的目标是 make your bundles smaller and your life easier

对于preset-env预设来说,如果不做任何配置:

1
2
3
{
"presets": ["@babel/preset-env"]
}

那么preset-env就相当于以前的 babel-preset-latest 这个预设。它包含所有的 babel-preset-es2015, babel-preset-es2016, and babel-preset-es2017 预设。

如果你了解你的目标用户所使用的平台(比如大部分用户都使用了较新的浏览器),那么你大可不必转译所有的特性。你只需要告诉babel让他转译你目标平台现在不支持的语法即可。这也是 babel 推荐的一种使用方法。

所以,正确的姿势是:你需要配置一个数组写法, 且第二个元素是个对象用来配置preset-env的options:

1
2
3
4
5
6
7
8
9
{
"presets": [
["env", {
"targets": {
"browsers": ["last 2 versions", "safari >= 7"]
}
}]
]
}

其中 targets字段可以用来指明目标平台和版本等信息。如果是面向node环境,可以指明node环境版本:

1
2
3
"targets": {
"node": "6.10"
}

可以看到preset-env的options中,最重要的就是这个targets配置。targets中有2个选项,一个叫 node, 一个叫 browsers。node这个key后面可以写一个字符串类型的版本号或者”current”, 如果想直接面向其babel运行环境的node版本,则可以改写为这样: "node": "current",此时babel会直接取 process.versions.node 中的版本号。browsers这个字段后面是一个Array类型的字符串数组或者是一个字符串。比如可以是一个字符串:

1
2
3
"targets": {
"browsers": "> 5%"
}

也可以是个字符串数组:

1
2
3
"targets": {
"browsers": ["last 2 versions", "ie >= 7"]
}

targets.browsers浏览器版本配置采用了browerslist写法,因此具体写法就去参考这个文档吧。browserlist的源除了可以配置在package.json中,还可以单独配置在一个叫做.browserslistrc文件中,甚至可以配置在BROWSERSLIST的环境变量中。( 实际上这个配置可能不仅被项目里的babel使用,比如还会给postcss使用,因此建议放在.browserlistrc中)。

preset-env还有其他一些配置,如:

  • modules 设置为true可以让babel把你的模块编译为 “amd”, ””umd”或者”commonjs”. 在配合webpack使用的时候,一般由webpack打包,因此一般将babel的这个配置设置为false
  • include, exclude, 可以让babel加载或者去除指定名称的插件。适用于我们要自定义改动preset-env的情况。
  • useBuiltIns. 这个配置用来给preset-env这个智能预设添加polyfill的。因为babel只转换语法,不转换API(下文会讲),所以代码中很多API需要根据你设置的targets环境进行polyfill处理,而在preset-env中能根据配置的环境进行智能添加polyfill的过程,就需要useBuiltIns的支持。 这也是在开发web应用(非类库时)使用preset-env时的polyfill最佳实践,下文会讲。

另开一片文章:

为你的代码配置一份优雅的 babelrc. 理解babel的polyfill配置

配置 babelrc的正确姿势。

复杂语法转换和@babel/polyfill

babel在语言转换方面,只转换各种ES新语法以及jsx等,但不转换ES6提供的新的API功能,例如Promise、Array的新增的原型、静态方法等。那么像 Object.assign String.prototype.includes 这些方法怎么办呢?这时就需要polyfill垫片。

我们可以再次分析下,对于ES6转换为ES5这件事情来说。有几种需要做不同实现的转换类型呢?

大概是这样的:

  1. 一种是仅仅是语法糖的区别,比如箭头函数能直接转为ES5的function
  2. 一种是API方法或类的。比如Array.from是ES6新加的API,ES5没有这方法。babel要想提供只能提前给实现这个方法。
  3. 一种是既是新语法,但ES5也没有能直接对应的语法. babel要想实现这个,就既要做语法变换,又要提供一些复杂的函数。比如 class类声明以及async这些,你不能简单的转换成一个 ES5 的映射,你需要一些辅助函数配合。

babel是怎么处理这些情况的呢?

  1. 对于第一种,babel是通过上文讲到的插件直接进行代码翻译即可,很容易理解,也很简单; 最好的方法是用上文讲到的babel+presetEnv预设的默认配置所完成的。如果需要删减插件,可以通过 targets 配置指定平台.

  2. 对于第二种情况,为了解决这个问题,babel使用一种叫做 Polyfill(代码填充,也可译作兼容性补丁) 的技术。 简单地说,polyfill 即是在当前运行环境中用来复制(意指模拟性的复制,而不是拷贝)尚不存在的原生 api 的代码。 能让你提前使用还不可用的 APIs,Array.from 就是一个例子。Babel 用了优秀的 core-js 用作 polyfill。

  3. 对于第三种情况,babel采用的方法是:编译你代码的过程中如果发现了这种语法,就会把你的语法包装成另一种ES5实现的语法,但是由于实现比较复杂,所以除了对语法进行转换之外,还需要辅助函数的配合,因此你会发现有运行时函数插入到代码的最上方。

我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 原型方法
[1, 2, 3].map((n) => n + 1);

// 新类型
var a = new Promise(function (resolve, reject) {
resolve('123')
})
a.then(d => console.log(d))

// 新的class语法
class Foo {
method() {}
}

// 新的async语法
async function testAsyncFn() {
var a = await Promise.resolve('ok')
return a
}
testAsyncFn().then(data=>{console.log(data)})

这段代码中包含了上面我提到的3种情形: 新箭头语法、原型/静态方法/新类型、新的复杂语法class/async。 我们使用 preset-env的默认设置对它进行编译(preset-env预设的默认设置意味着对最新的所有ES特性都进行转换,且不包含polyfill)。 转换结果如下:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
"use strict";

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }

function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }

function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }

// 原型方法
[1, 2, 3].map(function (n) {
return n + 1;
}); // 新类型

var a = new Promise(function (resolve, reject) {
resolve('123');
});
a.then(function (d) {
return console.log(d);
}); // 新的class语法

var Foo =
/*#__PURE__*/
function () {
function Foo() {
_classCallCheck(this, Foo);
}

_createClass(Foo, [{
key: "method",
value: function method() {}
}]);

return Foo;
}(); // 新的async语法


function testAsyncFn() {
return _testAsyncFn.apply(this, arguments);
}

function _testAsyncFn() {
_testAsyncFn = _asyncToGenerator(
/*#__PURE__*/
regeneratorRuntime.mark(function _callee() {
var a;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return Promise.resolve('ok');

case 2:
a = _context.sent;
return _context.abrupt("return", a);

case 4:
case "end":
return _context.stop();
}
}
}, _callee, this);
}));
return _testAsyncFn.apply(this, arguments);
}

testAsyncFn().then(function (data) {
console.log(data);
});

分析转换结果,我们可以看到:

  1. 对于普通的ES语法如箭头函数,babel会通过你preset-env指定的插件完成语法转换。把箭头函数转为ES5的函数写法
  2. 对于ES5中没有的原型方法和静态方法,babel自身在进行语法转换时,并不关注这一点。这个需要交给polyfill垫片js库来完成。
  3. 对于复杂的语法如class/async,babel的preset-env里面包含了对这类语法的转换。但是这类语法由于其比较复杂,所以会产生辅助函数,而且这些辅助函数的实现代码会注入到转译后的文件里。 如class的实现需要_createClass和classCallback函数,这两个函数就注入到了编译结果代码里。

由此可见,由于默认的preset-env配置是转换所有的ES6语法,所以我们的箭头函数、async、class都被启用了相应的插件进行转换,并且转换成功了。 现在伤脑筋的问题有两个:

  1. 原型方法、静态方法等都无法转换,包括但不限于Array.from Object.assign Array.prototype.includes. 那么,我们上文也说了,这种活应该交给polyfill(比如在页面里引入一个shim.js),那么babel有没有提供相应的polyfill办法呢? 答案是有的,它就是 @babel/polyfill.
  2. 复杂的ES语法,经过转换后会生成一坨函数实现代码在文件里。如果只有一个文件/模块还好,如果有 a.js, b.js, c.js 等多个模块文件,babel编译后每个js文件里都有一堆重复的 _createClass函数实现;如果再未来用webpack对他们打包上线,则会导致打包里面每个模块里也包含重复的_createClass函数实现(因为这个函数在每个js文件里相当于是个私有函数)
  3. 在 Class Async 这种语法被转换后。转换结果代码中会出现以来 Promise、regeneratorRuntime 这几个全局API的情况。而全局可能是没有这两个API的,需要polyfill。

怎么办呢? 其中1和3都属于Polyfill的问题,第2个问题属于helper冗余的问题。

下面我们来分别分析这俩伤脑筋的问题如何解决。

如何解决语言API无法被转换的问题(即polyfill问题)

babel自身只转换语法,不负责hack语法的API。这个一般用polyfill代码实现。其实用一个polyfill垫片库最简单的方式就是全量引入了。如果你是希望在执行代码的页面里进行垫片,则在页面中引入babel-polyfill的页面版本即可:

1
使用 @babel/polyfill/dist/polyfill.js

如果希望在预编译阶段引入到业务代码中,你可以 require 到业务代码的开头;未来打包到bundle.js的时候就能加载polyfill的代码了。步骤如下

  • 首先用 npm 安装它:
1
npm install @babel/polyfill // babel7 版本的安装方式
  • 然后只需要在入口文件最顶部导入 polyfill 就可以了:import @babel/polyfill. 如果是webpack可以作为entry数组的第一项。具体官方文档

示例代码:

1
2
3
4
5
6
7
8
9
10
11
// polyfill.js
import '@babel/polyfill'
console.log([1, 2, 3].includes(2))
console.log(Object.assign({}, {a: 1}))
console.log(Array.from([1,2,3]))

// babel.config.js
const presets = [
["@babel/env"]
];
module.exports = { presets };

用这个 preset-env 的默认配置进行 npx babel ./polyfill.js -d dist 编译,得到:

1
2
3
4
5
6
7
8
9
"use strict";

require("@babel/polyfill");

console.log([1, 2, 3].includes(2));
console.log(Object.assign({}, {
a: 1
}));
console.log(Array.from([1, 2, 3]));

可以发现,babel编译的过程,除了对js模块代码进行了上文讲述的必要的语法转译外,并没有做任何事情。对于此案例,仅仅就是把esmodule语法转译为commonjs语法(因为你源码中写了import这样的es模块引用的代码)。 但实际上,我们这段代码在经过webpack等工具打包放入页面后,是可以polyfill的,因为打包后 require('@babel/polyfill') 这一句会把@babel/polyfill的代码打包进来。

所以,可以看出来,垫片这个事情跟babel的转译其实无关。是因为我们在页面或代码开头引入了一些@babel/polyfill的代码,所以才让我们的业务代码可以使用一些新的API特性。其中 regeneratorRuntime 得以让 class 转译后的代码可以运行;其中 core-js/promise 得以让 async 转译后的generator函数得以运行。

仔细研究@babel/polyfill的话就会发现,这个包其实就是依靠 core-jsregenerator-runtime 实现了所有的shim/polyfill。所以在@babel/polyfill这个npm包里面,只有一个index.js文件,里面直接引用了这两个npm库而已。

babel polyfill shim

会看到@babel/polyfill引用了core-js/shim.js, 其实shim.js这个文件就是把core-js包里的所有polyfill的API暴漏出来而已。

虽然polyfill的使用很简单,甚至跟babel都没有多少关系。可是现在问题来了:

  1. 如果你的代码是要支持chrome的某个较新版本即可,由于chrome已经支持了大部分的ES6能力,可能你只需polyfill该版本chrome尚不支持的少量API即可;结果却引入了一个庞然大物@babel/polyfill。能不能根据目标平台的支持情况来精简polyfill呢?
  2. 你的业务代码中可能仅仅使用了一个Object.assign和Promise,结果却要引入一个庞然大物 @babel/polyfill。能不能根据代码中用到的API来精简polyfill引入呢?
  3. 尽管polyfill的目的就是能全局hack API,但是有些时候比如你开发的是一个类库。你可能仅仅希望局部去hack一下你用到的这个API就好了,不要污染外部环境。能不能只在局部hack我的Array.from呢?

优化是无止境的,让我们看看怎么解决上面问题呢?

1.根据目标平台的支持情况引入需要的polyfill

恭喜,这个能力已经被 preset-env 这个预设所支持了。只要你打开preset-env预设的这个特性,那么preset-env就能自动根据你配置的env targets,按照目标平台的支持情况引入对应平台所需的polyfill模块。

这里最关键的就是 useBuiltIns 这个字段的配置。他有3个值:

  • 默认是false,表示不引入任何polyfill
  • entry。这个值表示你的polyfill在代码入口处引入了(如 require('@babel/polyfill')), 此时babel会根据 targets 配置的目标环境情况,把你引入的polyfill 拆成当前平台所需要的具体polyfill
  • usage。 这个是最智能的polyfill设置,他不需要你在文件入口处引入polyfill。他可以自动检测你代码中用到的API,如果某API在targets目标平台还不支持,它就会自动在该模块开头引入 core-js 或 regenerator 的polyfill模块

来个例子:

1
2
3
4
5
6
7
8
9
10
11
12
// babel.config.js 配置
const presets = [
["@babel/env", {
targets: {
node: '0.10.42',
// node: 'current'
},
useBuiltIns: 'entry' // 这里是关键。
}]
];

module.exports = { presets };

源码如下,文件入口需要手工引入polyfill:

1
2
3
4
5
6
7
import '@babel/polyfill'
console.log([1, 2, 3].includes(2))
console.log(Object.assign({}, {a: 1}))
console.log(Array.from([1,2,3]))
console.log(new Promise())
console.log(Object.defineProperties())
console.log([1,2,3].flat())

由于目标平台是node的0.10版本,这个版本是不支持Object.assign, Array.from 这些API的。因此编译结果中就引入了该平台所需要的polyfill模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"use strict";

require("core-js/modules/es6.promise");

require("core-js/modules/es6.array.from");

require("core-js/modules/es6.object.assign");

require("core-js/modules/es7.array.includes");

require("core-js/modules/es6.string.includes");

console.log([1, 2, 3].includes(2));
console.log(Object.assign({}, {
a: 1
}));
console.log(Array.from([1, 2, 3]));
console.log(new Promise());
console.log(Object.defineProperties());
console.log([1, 2, 3].flat());

2. 根据代码中用到的API来加载polyfill

上面的 entry 配置是把目标平台上所有不支持的 API 全部polyfill。而 usage 则可以让 babel 只引入那些开发者代码中用到的 polyfill(当然,他也会判断 target环境,如果目标环境已经支持了则不进行polyfill)。

注意:这个特性在 7.0 中还处于 实验阶段。不过对于减小包体积的优化来说,这种配置肯定是最佳推荐。

3.局部hack

通过 preset-env 的 useBuiltIns 配置的方式,我们解决了polyfill问题。但这种方式实际上适合 Web应用 的场景,而不适合类库开发的场景。因为Web应用开发,我们不担心污染宿主全局环境,但类库开发是给其他人用的,作为类库不应该主动去污染别人的环境。

那么,在类库场景下,我们就应该把 useBuiltIns 设置为 false(不进行全局polyfill)。然后通过 babel-runtime 这个包来局部注入polyfill,babel-runtime更像是分散的 polyfill 模块,我们可以在自己的模块里单独引入,比如 var innerPromise = require(‘babel-runtime/core-js/promise’) ,它们不会在全局环境添加未实现的方法. 这样你在使用Promise的时候就要这样了:

1
2
var innerPromise = require(‘@babel/runtime/core-js/promise’)
var a = new innerPromise(...)

其实 @babel/runtime/core-js/** 就是对 core-js 这个库进行了改造,以防止全局污染,而是采用 return 出来一个 polyfill 对象或函数的方式来进行polyfill使用。

可是,自己模仿上面的方式去发现并改写业务代码里的API调用未免有点麻烦了. 这里就有个babel插件来帮忙做这个事情了: @babel/plugin-transform-runtime 插件。 首先安装它:

1
2
npm install --save-dev @babel/plugin-transform-runtime // babel7的安装方式
npm install --save @babel/runtime // 这个要作为运行依赖

然后我们配置下transform-runtime插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// babel.config.js
const presets = [
["@babel/env", {
targets: {
node: '0.10.42',
// node: 'current'
},
useBuiltIns: false // 使用transform-runtime来polyfill,就不用这个全局polyfill了
}]
];

const plugins = [
["@babel/plugin-transform-runtime", {
"corejs": 2, // 在代码模块中注入 局部的core-js polyfill。2表示使用 @babel/runtime-corejs2 这个polyfill包
"helpers": true, // true则表示把模块中babel转换后的helper函数改成 require @babel/runtime 下的helper
"regenerator": true, // true则表示进行 regenerator的polyfill
"useESModules": false // 是否转换代码中的esmodule(如果代码后续还要webpack来打包,则不需要处理)
}]
]

module.exports = { presets, plugins };

我们执行编译看下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs2/helpers/interopRequireDefault");

var _defineProperties = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/object/define-properties"));

var _promise = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/promise"));

var _from = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/array/from"));

var _assign = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/object/assign"));

console.log([1, 2, 3].includes(2));
console.log((0, _assign.default)({}, {
a: 1
}));
console.log((0, _from.default)([1, 2, 3]));
console.log(new _promise.default());
console.log((0, _defineProperties.default)());
console.log([1, 2, 3].flat());

所有可以被 tranform-runtime 插件进行语法感知的ES6特性,都被 transform-runtime 编译成了对 corejs2的函数调用,而且是按照实际的使用情况按需引用和改写的。 而 [1,2,3].includes 这种实例原型方法就无法被感知到并进行转换了。

而这种实例写法,通常需要类库开发者在文档中明确告知使用者你需要依赖哪个特性的polyfill。毕竟大部分的代码都已经被transform-plugin 进行 polyfill了,剩下的为数不多的实例方法,的确可以在类库文档中说明下就好了。

如何解决复杂语法转换后模块间重复冗余helper的问题

实际上babel-runtime里不止包含了所有ES6的API(即core-js),也包含了ES6语法转换时需要的那些辅助函数helpers, 也包含了generator依赖的regenerator-runtime这个polyfill。仔细观察babel-runtime的包依赖也可以证实这一点. 所以 transform-runtime 的方案也不止用来局部hack polyfill,也会用在上文中提到的另外一个疑难问题: “复杂语法编译后多文件重复” 的问题。

不知你有没有注意本节前面那个 transform-runtime的例子,他的编译结果中其实已经出现了 require(@babel/runtime-corejs2/helpers) 的引用。

我们再来详细解释下 async 语法的编译过程。实际上,如果在代码中使用了ES7的async,babel会使用了定制化的 regenerator 来让 generators(生成器)和 async functions(异步函数)正常工作。 但这个regenerator函数会插入到编译后代码的最上方。如果源码中使用了ES6的class,也会出现类似的 _createClass 等函数的实现代码放在代码模块文件的上方。这些代码又叫做 helper

此时,如果有多个js模块文件,每个文件内都会有自己的一份helper(这会导致打包后每个js factory工厂函数模块里都有重复冗余的helper代码)

要解决这个问题,我们其实可以想到办法:

如果是用自己写代码的思路来看,根据DRY原则,如果每个js文件里都使用同一个函数如_createClass, 那么我们最好把他们放到一个单独的文件/模块里,然后需要的时候require它。 这样写的话,最终webpack等工具打包的时候会以模块为粒度打包,大家都依赖的这个模块只会存在一份,不会存在重复。

所以上文讲到的 _classCallback 这些辅助函数其实可以改为 var _classCallCheck2 = require('babel-runtime/helpers/classCallCheck'); 这样从babel-runtime包种引用。

是的,跟局部polyfill的表现一样,我们可以让代码中的复杂ES6语法如class、async的helper函数,替换为引入对应的babel-runtime辅助函数。要做到这个,只需要借助 transform-runtime 插件来自动化处理这一切。

步骤:插件安装方式跟上文一样

1
2
npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime @babel/runtime-corejs2 // runtime是运行时依赖

然后修改 babel.config.js 的配置为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// babel.config.js
const presets = [
["@babel/env", {
targets: {
node: '0.10.42',
// node: 'current'
},
useBuiltIns: false
}]
];
const plugins = [
["@babel/plugin-transform-runtime", {
"corejs": 2,
"helpers": true, // 就是这个配置啦
"regenerator": true,
"useESModules": false
}]
]
module.exports = { presets, plugins };

这样再运行babel编译时,这个插件会把这种generator或者class的运行时的定义移到单独的文件里。 我们看下编译示例:

1
2
3
4
5
6
7
8
// 源码
console.log(Object.assign({}, {a: 1}))
console.log(new Promise())

// 新的class语法
class Foo {
method() {}
}

编译结果如下:

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
31
// 编译结果
"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs2/helpers/interopRequireDefault");

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs2/helpers/classCallCheck"));

var _createClass2 = _interopRequireDefault(require("@babel/runtime-corejs2/helpers/createClass"));

var _promise = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/promise"));

var _assign = _interopRequireDefault(require("@babel/runtime-corejs2/core-js/object/assign"));

console.log((0, _assign.default)({}, {
a: 1
}));
console.log(new _promise.default()); // 新的class语法

var Foo =
/*#__PURE__*/
function () {
function Foo() {
(0, _classCallCheck2.default)(this, Foo);
}

(0, _createClass2.default)(Foo, [{
key: "method",
value: function method() {}
}]);
return Foo;
}();

但是 假如我们这是一个Web应用,我们发现上面的编译结果是有问题的。由于transform-runtime的存在,导致我们本该全局polyfill的静态方法变成了局部polyfill。 这个原因就是transform-runtime导致的。不过幸好,tranform-runtime是可配置的,我们可以配置他是否局部hack polyfill, 是否局部hack helpers, 是否局部修正regenertor。

最佳实践和取舍

首先,上面讲了那么多polyfill和语法转换的使用和优化方式。我们可以看到要想正确配置babel需要看我们的需要和场景。而且,作为babel的使用者,我们需要理解几个概念:一个是helper, 一个是垫片polyfill,一个是regenerator-runtime。helper/external-helper是为了帮助你构造ES6的ES5 class实现以及generator等实现的辅助函数,而垫片是为了实现ES6的ES5版本的API,如Array.from;垫片函数是对某个API的具体实现;regenerator-runtime也可以认为是一个polyfill,只是它特定地用来实现ES6里面的generaotor语法。

如此,我们就能明白@babel/polyfill只为实现API垫片为目的,可以全局污染来垫片,也可以在局部使用某个垫片函数(transform-runtime配合corejs配置的做法)。而babel在转换代码时,内置了external-helper转换的功能(也就是在目标文件中出现的那些createClass辅助函数),babel-runtime的包中也有这些辅助函数helpers,因此才可以配合helper配置去把代码中的helper函数替换为对babel-runtime/helpers的引用,方便webpack打包时减少冗余代码。

我们基于以上所学,对babel的最佳实践做如下总结:

类库项目

对于类库项目来说,你可以使用最新的语法特性,然后用babel+presetEnv进行语法编译后释出一个ES5的dist.js。但你代码中使用的API你不能直接全局给他polyfill掉,哪怕你按目标平台进行polyfill也不好,因为这会污染全局环境。你在未知你的调用者环境的情况下,你不能污染全局。所以,类库中最好的polyfill方式是局部polyfill(利用transform-runtime或手工引入@babel/runtime-core-js的module)。在babel官方polyfill文档里有提到这个小细节

If you are looking for something that won’t modify globals to be used in a tool/library, checkout the transform-runtime plugin. This means you won’t be able to use the instance methods mentioned above like Array.prototype.includes.

Depending on what ES2015 methods you actually use, you may not need to use @babel/polyfill or the runtime plugin. You may want to only load the specific polyfills you are using (like Object.assign) or just document that the environment the library is being loaded in should include certain polyfills.

但是 transform-runtime 这个方案 无法解决实例的原型方法的polyfill问题 这个缺点你必须要注意。 而如果你很明显地知道这个类库调用了哪些较新的API(你的客户环境可能会不支持的API),那么你就不要使用 @babel/polyfill 或 babel-runtime方案了,你可以直接手工走core-js来加载它,或者你在你的类库文档里告诉你的开发者说你这个类库需要依赖什么polyfill。

所以类库项目我们推荐这样的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const presets = [
["@babel/env", {
// 对类库项目来说,targets不做配置。(相当于全部语法都转为es5)
useBuiltIns: false // 对类库项目来说,这里不要打开
}]
];
const plugins = [
["@babel/plugin-transform-runtime", {
"corejs": 2, // 使用 core-js2进行局部polyfill
"helpers": true, // 替换每个模块里的helper定义
"regenerator": true, // 使用 regenerator-runtime进行 generator的polyfill
"useESModules": false
}]
]
module.exports = { presets, plugins }

Web应用项目

这种项目由于不怕全局polyfill污染,因此一般采用全局polyfill的方式。不过为了提高页面性能,一般也通过 preset-env 配合 useBuiltIns:usage 配置的方式实现按需加载polyfill。注意,现在版本的preset-env如果开启了useBuildIns,你就不要自己在代码的开头出引用@babel/polyfill了。

至于复杂语法转换带来的辅助函数问题,就靠 transform-runtime来解决了。注意不要开启 core-js选项,从而避免全局和局部polyfill混用(实际上在混用时,transform-runtime优先替换为局部polyfill,从而无法让useBuiltIns:usage 感知到要加载某特性的polyfill;不过对于 transform-runtime做不到的实例方法,useBuiltIns就起作用了)

因此,对于web项目,我们的推荐配置是:

不要打开 transform-runtime 的局部 polyfill,只打开它的 helper 替换(只让他帮忙来解决 preset-env 的helper冗余问题)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const presets = [
["@babel/env", {
// 对web项目来说,一般要设置targets
targets: '> 0.5%',
useBuiltIns: 'usage' // 对类库项目来说,这里不要打开
}]
];
const plugins = [
["@babel/plugin-transform-runtime", {
"corejs": false, // 不用polyfill
"helpers": true, // 替换每个模块里的helper定义 (由babel产生的)
"regenerator": false, // 不用polyfill
"useESModules": false
}]
]
module.exports = { presets, plugins }

babel与mocha和lint结合使用

用ES6写代码之后,测试有时也希望使用ES6来编写。而且eslint进行代码检查时也要利用babel进行转换。关于结合mocha的使用将在后面的文章讲解。eslint的使用请参看博文[实践]-使用ESLINT检查代码规范

总之,测试这些环节执行ES6的测试用例代码时就不需走编译步骤了。由于不在乎性能,因此可以直接走实时编译执行的模式。

1
mocha --compilers js:babel-core/register --require @babel/polyfill

(新版 register应该不是这么用了,而且默认内置polyfill了)

babel不止于ES

现在流行框架,都在使用babel进行框架特有的语法转换。例如除了react,还有Vue2.0的jsx

我们也可以写自己的babel插件,详情可参考手册: https://github.com/thejameskyle/babel-handboo
官方脚手架:https://github.com/babel/generator-babel-plugin

下一节,就用这些知识点真正搭建一个类库开发项目了。

Refer

babel-handbook中文
xxx
babel-preset-env
https://babeljs.io/docs/plugins/transform-runtime/
https://github.com/brunoyang/blog/issues/20
你真的会用 Babel 吗?
21 分钟精通前端 Polyfill 方案
https://leanpub.com/setting-up-es6/read#ch_babel-helpers-standard-library
babel笔记
测试external-helper
creeperyang的博客