无处不在的Babel

背景

海子在《面朝大海,春暖花开》中写到:“从明天起,做一个幸福的人”,Babel在介绍自己时说到:“从今天起,做一个幸福的人,使用下一代JavaScript”。

JavaScript学名叫ECMAScript,主要版本有2000年发布的ES3,2010年发布的ES5,以及2015年发布的ES2015。其中,ES2015相对于ES5简直是翻天覆地的变化,大量新特性极好的解决了开发人员面临的问题。然而市场份额占比极高的Internet Explorer 9发布于2011年,对ECMAScript的支持只到第5版。难道为了兼容低版本浏览器用户而就此放弃?不!在新达达,我们拥抱社区,跟随潮流,始终走在技术前沿。Babel作为一个广泛使用的转码器,可以将ES2015代码转译成ES5代码,从而在现有环境中执行。有了它,使用下一代JavaScript成为可能。

如今,从手机应用到桌面网站,再到后端服务,Babel的身影无处不在。但通往幸福的路上,我们遇到不少困难。随着项目复杂度增加,文件打包体积变大,程序运行性能变差。

困难

以IE9为例,它对ECMAScript的支持只到第5版,为了能使用ES2015,我们需要将ES2015转译成ES5,这样才能被IE9正确执行。在这个过程中,有些特性能用ES5直接实现,比如箭头函数:

1
2
3
4
5
6
7
8
9
// ES2015
const square = n => n * n

// ES5
"use strict";

var square = function square(n) {
return n * n;
};

但有些特性则不行,比如创建类:

1
2
3
4
5
6
7
8
9
10
11
// ES2015
class Person {}

// ES5
"use strict";

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

var Person = function Person() {
_classCallCheck(this, Person);
};

类似_classCallCheck的函数叫helper,每当遇到创建类这种无法直接实现的特性时,Babel都会在该文件内嵌入helper,这导致整个项目包含大量重复代码。对于这个问题,大多数人推荐的做法是引入babel-polyfill,对已有执行环境进行扩展,但这样做一会导致文件打包体积变大,二会导致特性检测失败。另一种做法是使用transform-runtime插件,改变Babel默认行为,将文件内嵌入helper改为引入外部模块,这样整个项目只存在一份helper的代码,文件打包体积变大的问题迎刃而解。由于是引入外部模块而非扩展执行环境,特性检测失败的问题也就不复存在。

一切看起来都很美好,但新的问题出现了。在使用Babel转译代码时,通常只对项目源码进行处理,第三方模块保持不变。当第三方模块依赖一个ES2015特性时,由于不被处理,自然无法执行。这时需要引入额外的模块,比如GitHub出品的whatwg-fetch

1
2
import 'es6-promise';
import 'whatwg-fetch';

babel-presets-es2015本身包含Promise,现在为了保证whatwg-fetch正常执行而重复引入,有没有什么办法能够避免?

除此之外,还有一个问题比较隐蔽。在使用ES2015语法引入模块时,通常会这样写:

1
import { find } from 'lodash'

看似没有问题,实际上问题很大。上面的代码经Babel转译后:

1
2
3
4
5
6
7
// ES2015
import { find } from 'lodash'

// ES5
'use strict';

var _lodash = require('lodash');

原本只是想引入1kb的find函数,最终却变成引入540kb的lodash,完全无法接受。类似的问题还存在其它地方,怎样解决?

基础

所有这些问题的解决都离不开对Babel的深入理解,就从Babel的安装、配置开始,逐步深入到pluginspresets的区别,源码转译顺序,ECMAScript ModulesCommonJS的区别等问题上。

安装

Babel的安装主要针对浏览器和Node.js两种环境,构建方式主要是命令行和构建工具两种,这里只关注浏览器环境下基于Webpack的构建方式。新建项目,并执行以下命令:

1
$ npm i -D webpack babel-loader babel-core

其中babel-loader的作用是让Webpack能够通过Babel转译JavaScript,而babel-core才是真正负责JavaScript转译的工具。

安装完后,对Webpack进行如下配置,就能自动转译main.js及其引入的其它模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
entry: './main.js',
output: {
filename: 'bundle.js'
},
module: {
loaders: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
}
}

配置

有了上述配置,Babel就能自动转译,但如何转译特定的语言特性仍需配置。通常会将Babel的配置保存为单独的.babelrc文件,该文件的内容为JSON格式,主要包含presetsplugins两部分。

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

Plugins vs. Presets

简单来说,plugins是包含规则的函数,在转译时用来处理输入内容。通常以babel-plugin-作为前缀,因此在书写时可以省略:

1
$ npm i -D babel-plugin-transform-es2015-arrow-functions
1
2
3
4
5
6
{
"presets": [],
"plugins": [
"transform-es2015-arrow-functions"
]
}

presets则是plugins的合集,省却一个个安装的繁琐过程,比如babel-preset-es2015包含所有ES2015的语言特性:

1
$ npm i -D babel-preset-es2015
1
2
3
4
5
6
7
8
{
"presets": [
"es2015"
],

"plugins": [
"transform-es2015-arrow-functions"
]
}

转译顺序

presetsplugins同时存在时,按照什么顺序进行转译?Babel官方文档提及如下规则:

  • plugins先于presets执行
  • plugins的执行顺序是从头到尾
  • presets的执行顺序和plugins相反

假定配置如下:

1
2
3
4
5
6
7
8
{
"presets": [
"es2015"
],

"plugins": [
"transform-async-to-generator"
]
}

源码如下:

1
2
3
4
5
const beautify = data => JSON.stringify(data, null, 2)

async function fetchUserDetail(userID) {
return await fetch(`/user/${userID}`)
}

第一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let fetchUserDetail = (() => {
var _ref = _asyncToGenerator(function* (userID) {
return yield fetch(`/user/${ userID }`);
});

return function fetchUserDetail(_x) {
return _ref.apply(this, arguments);
};
})();

function _asyncToGenerator(fn) {
...
}

const beautify = data => JSON.stringify(data, null, 2);

第二步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let fetchUserDetail = (() => {
var _ref = _asyncToGenerator(function* (userID) {
return yield fetch(`/user/${ userID }`);
});

return function fetchUserDetail(_x) {
return _ref.apply(this, arguments);
};
})();

function _asyncToGenerator(fn) {
...
}

var beautify = function beautify(data) {
return JSON.stringify(data, null, 2);
};

由于Async函数的转译规则存在于plugins中,所以在第一步中先被转译;箭头函数转译规则存在于presets中,所以在第二步中才被转译。

ECMAScript Modules vs. CommonJS

从ES2015开始,JavaScript终于有了标准的模块定义。尽管暂时没有任何一个环境实现原生支持,但这并不能阻止大家对它的热爱。在ECMAScript Modules规范出来之前,存在着AMD、CommonJS、UMD等规范,其中CommonJS规范最受欢迎。

一个符合CommonJS规范的模块,当使用require引入的时候,它的执行流程是这样的:

require

简单来说,首先找到这个模块在系统中的路径;然后根据它是指向内置模块还是本地文件来进行加载;而后,如果是本地文件,则将文件内容包装成一个函数;接下来执行这个函数;最后,缓存执行的结果。整个流程中最重要的环节是包装。假定有如下代码:

1
2
3
const bar = 1

module.exports.bar = bar

包装后:

1
2
3
4
5
function (exports, require, module, __filename, __dirname) {
const bar = 1

module.exports.bar = bar
}

在执行之前,无法得知该模块是否导出bar或foo(不存在)。而ECMAScript Modules则不同:

1
export const bar = 1

根据规范,ECMAScript Modules支持静态分析,无需执行就能从词法上得知bar会被导出,而foo(不存在)不会。

解决

按需引入

有了这些基础,我们知道要解决文件打包体积大,程序运行性能差这个问题,按需引入helper是最佳方案。配置如下:

1
2
$ npm i -D babel-plugin-transform-runtime
$ npm i -S babel-runtime
1
2
3
4
5
6
{
"plugins": [
"transform-runtime",
"transform-es2015-classes"
]
}

其中babel-runtime包含所有helper及其他相关库,而babel-plugin-transform-runtime则可以改变Babel默认行为,使其始终从babel-runtime引入所需模块。假定有以下代码:

1
2
3
class Person {
sayHi() {}
}

转译后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import _classCallCheck from "babel-runtime/helpers/classCallCheck";
import _createClass from "babel-runtime/helpers/createClass";

let Person = function () {
function Person() {
_classCallCheck(this, Person);
}

_createClass(Person, [{
key: "sayHi",
value: function sayHi() {}
}]);

return Person;
}();

特殊处理

但这种方案带来的重复问题如何解决?比如:

1
2
import 'es6-promise';
import 'whatwg-fetch';

前面提到,默认情况下,我们只处理项目源码,不管第三方模块。那么如果同样处理第三方模块,这个问题能否解决?经过一番研究,我们发现Webpack有个不起眼的功能正好解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const path = require('path')

module.exports = {
module: {
loaders: [
{
test: /\.js$/,
include: [
path.resolve(__dirname, 'src'),
/whatwg-fetch/
],
loader: 'babel-loader'
}
]
}
}

除了exclude选项,Webpack还支持include,含义正如其名:只包含特定目录,而且这个选项还支持正则匹配。那么问题就简单了,让所有项目源码通过Babel进行转译,同时包含特定第三方模块。

自动修正

在知道ECMAScript Modules和CommonJS的区别后,这个问题的解决思路也就变得清晰起来。既然通过ES2015解构的方式引入会导致文件体积大,那么改为单个文件引入不就好了?

1
2
3
4
5
6
// 错误引入
import { find, pick } from 'lodash'

// 正确引入
import find from 'lodash/find'
import pick from 'lodash/pick'

引入内容不多的时候还好,一旦数量上去了,这将是一个极为繁琐的过程。既然Babel可以转译源码,自然就可以改写源码,如果能让Babel自动完成上述过程,岂不是完美?

1
npm i -D babel-plugin-transform-imports
1
2
3
4
5
6
7
8
9
10
{
"plugins": [
["transform-imports", {
"lodash": {
"transform": "lodash/${member}",
"preventFullImport": true
}

}]

]
}

借助transform-imports,通过几行简单配置,自动简化繁琐过程,并阻止完全引入,有什么理由不爱Babel?

总结和展望

在解决文件打包体积大,程序运行性能差这个问题的过程中,我们意识到以下几点:

  • 积极拥抱社区,跟随技术潮流,避免闭门造车,交流和分享总能带来新的思路
  • 习以为常的东西并不见得真懂,不要因为暂时没用就不去了解,保持好奇心往往能带来意想不到的收获
  • 基础、基础、基础,在日新月异的前端大时代,不要迷失在各种变化中,多花些时间和精力在不变的领域

在新达达,前端日益重要,每一个高速发展且影响重大的产品中都少不了前端的身影。欢迎感兴趣的同学加入新达达,让我们共同搭乘新达达这艘火箭探索未知的世界!

更多关于新达达技术的文章,敬请关注新达达技术公众号。
新技术公众号