11

一文彻底读懂Babel

 3 years ago
source link: https://xieyufei.com/2020/11/18/Babel-Practice.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

  在我们平时写代码的过程中可能没有感觉Babel的存在,但其实只要我们写JS代码,Babel已经无处不在、无时不刻的在影响着我们的代码;在Webpack基础篇中,我们简单的介绍Babel的安装,也知道了它能够将高版本的ES6转为低版本的ES5代码,这篇文章我们就对Babel的配置以及使用做一个深入的学习和总结。

Babel是什么

  Babel官网对Babel的定义就是:

Babel 是一个 JavaScript 编译器。

  用通俗的话解释就是它主要用于将高版本的JavaScript代码转为向后兼容的JS代码,从而能让我们的代码运行在更低版本的浏览器或者其他的环境中。

  比如我们在代码中使用了ES6箭头函数:

1
var fn = (num) => num + 2;

  但我们如果用IE11浏览器(鬼知道用户会用什么浏览器来看)运行的话会出现报错;但是经过Babel编译之后的代码就可以运行在IE11以及更低版本的浏览器中了:

1
2
3
var fn = function fn(num) {
return num + 2;
}

  Babel就是做了这样的编译转换工作,来让我们不用考虑浏览器的兼容性问题,只要专心于代码的编写工作。

Babel历史

  Babel的前身是从6to5这个库发展而来,6to5的作者是Facebook的澳大利亚工程师Sebastian McKenzie在2014年发布的;从它的名字我们也能看出来,主要的功能就是将ES6转成ES5,我们如今也还能在npm官网看到这个包,不过作者提示已经迁移到Babel了:

6to5.png

  在2015年1月份,6to5Esnext的团队决定一起开发6to5,并且改名为Babel,解析引擎改名为Babylon

Babel和Babylon含义

  • Babylon的翻译是巴比伦,意指巴比伦文明
  • Babel的翻译是巴别塔,又名通天塔;当时地上的人们都说同一种语言,当人们离开东方之后,他们来到了示拿之地。在那里,人们想方设法烧砖好让他们能够造出一座城和一座高耸入云的塔来传播自己的名声,以免他们分散到世界各地。上帝来到人间后看到了这座城和这座塔,说一群只说一种语言的人以后便没有他们做不成的事了;于是上帝将他们的语言打乱,这样他们就不能听懂对方说什么了,还把他们分散到了世界各地,这座塔也停止了修建,这座塔就被称为“巴别塔”。

Babel版本及区别

  • 2015-02-15,6to5重命名为babel
  • 2015-03-31,babel 5.0发布
  • 2015-10-30,babel 6.0发布
  • 2018-08-27,babel 7.0发布

  babel5及之前是一个包含CLI工具+编译器+转换器的集合工具包;babel6之后进行了拆分,集合包被分成多个包:

  • babel-cli,其中包含babel命令行界面
  • babel-core,包括了Node有关的API和require钩子
  • babel-polyfill,可以建立一个完整的ES2015环境

  babel6默认情况下不携带任何转换器,需要自行安装所需的插件和转换器,通过babel-xxx来安装对应的工具包。

  而Babel7用了npm的private scope,把所有的包都挂载@babel下,通过@babel/xxx来安装,不用在node_modules下看到一堆的babel-xxx包。

  本文主要以Babel7作为开发工具。

@babel/core和@babel/cli

  @babel/core我们在很多地方都看到,它是Babel进行转码的核心依赖包,我们常用的babel-cli和babel-node都依赖于它,我们通过例子来看一下它是如何来进行解析(相关代码在demo0):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var babelCore = require("@babel/core");
var sourceCode = `let fn = (num) => num + 2`;

var options = {
//是否生成解析的代码
code: true,
//是否生成抽象语法树
ast: true,
//是否生成sourceMap
sourceMaps: true,
plugins: [],
presets: [],
};

babelCore.transform(sourceCode, options, function (err, result) {
console.log(sourceCode);
console.log(result.code);
console.log(result.map);
console.log(result.ast);
});
babel-core.png

  可以发现原来的es6箭头函数在结果中几乎原封不动的返回出来了;Babel的运行方式总共可以分为三个阶段:解析(parsing)、转换(transforming)和生成(generating);负责解析阶段的插件是@babel/parser,其作用就是将源码解析成AST;而负责生成阶段的插件是@babel/generator,其作用就是将转好好的AST重新生成代码。

  而@babel/core本身不具备转换处理的功能,它把转换的功能拆分到一个个插件(plugins)中;因此当我们不添加任何插件的时候,输入输出代码是相同的。

  在@babel/core转换时还有几个副产物:code、mast和map,我们可以通过options配置,根据需要对这几个副产物进行选择性的输出。除了transform这个转换方法,还有transformSynctransformAsynctransformFileSync等同步异步API,可以在babel官网找到。

  @babel/cli是Babel自带了一个内置的CLI命令行工具,我们就可以通过命令行来编译文件;它有两种调用方式,可以通过全局安装或者本地安装调用,选用一种即可,推荐在项目本地安装。

1
2
3
4
5
6
//全局安装调用
npm install --g @babel/cli
babel index.js -o output.js
//本地安装调用
npm install --save-dev @babel/cli
npx babel index.js -o output.js

  @babel/cli还可以使用以下命令参数:

命令参数 缩写

作用 示例 –out-file -o 输出文件名称

babel a.js -o b.js –watch -w 实时监控输出 babel a.js -o b.js -w –source-maps

-s 输出map文件 babel a.js -o b.js -s –source-maps inline

-s inline 行内source map babel a.js -o b.js -s inline

–out-dir -d 编译文件夹 babel src -d dist –ignore

无 忽略某些文件

babel src -d dist –ignore src/*/.spec.js –copy-files 无 复制不需要编译的文件 babel src -d dist –copy-files

–plugins 无 指明使用插件 babel a.js -o b.js –plugins @babel/plugin-transform-arrow-functions

–presets 无 指明使用预设 babel src -d dist –presets @babel/preset-env

  @babel/cli命令行的具体用法在demo1

  我们虽然可以在命令行中配置各种插件(plugins)或者预设(presets,也就是一组插件),但是这样并不利于后期的查看或者维护,而且大多时候babel都是结合webpack或者gulp等打包工具开发,不会直接通过命令行的方式;因此Babel推荐通过配置文件的方式来进行管理。

  Babel的配置文件主要有.babelrc.babelrc.jsbabel.config.jspackage.json,他们的配置选项都是相同的,作用也是一样,主要区别在于格式语法的不同,因此我们在项目中只需要选择其中一种即可。

  对于.babelrc,它的配置主要是JSON格式的,像这样:

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

  而.babelrc.jsbabel.config.js同样都是JS语法,通过module.exports输出配置:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = function (api) {
api.cache(true);
const presets = [ ... ];
const plugins = [ ... ];
if (process.env["ENV"] === "prod") {
plugins.push(...);
}
return {
presets,
plugins
};
}

  我们还可以根据环境来进行动态的配置。而在package.json中,需要增加babel的属性:

1
2
3
4
5
6
7
8
{
"name": "demo",
"version": "1.0.0",
"babel": {
"presets": [ ... ],
"plugins": [ ... ],
}
}

  我们可以在配置文件中加入一些插件或者预设,来扩展@babel/core的转换功能;只需要将对应的插件或预设名字加入数组即可;比如我们常用的ES6箭头函数,就是通过@babel/plugin-transform-arrow-functions这个插件来转换:

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

  但有时候我们需要对插件和预设设置参数,就不能直接使用字符串的形式了;而应再包裹一层数组,数组的第一项是名称,第二项是设置的参数对象:

1
2
3
4
5
6
7
8
9
//.babelrc
{
"plugins": [
[
"@babel/plugin-transform-arrow-functions",
{ "spec": true }
]
]
}

  这样我们的箭头函数就能正常转换了,相关代码在demo2

Babel插件和预设

  Babel的插件大致可以分为语法插件转换插件

  • 语法插件:作用于解析阶段,使得babel能够解析更多的语法,官方的语法插件以babel-plugin-syntax开头。
  • 转换插件:在转换这一步把源码转换并输出,官方的转换插件以babel-plugin-transform(正式)或 babel-plugin-proposal(提案)开头。

转换插件将启用相应的语法插件,因此不必同时指定这两种插件。

  语法插件虽名为插件,但其本身并不具有功能性。语法插件所对应的语法功能其实都已在@babel/parser里实现,插件的作用只是将对应语法的解析功能打开。所以本文提及的 Babel 插件将专指转换插件。

  Babel官网提供了近一百个插件,但是如果我们的代码中一个一个的配置插件就需要对每一个插件有所了解,这样必然会耗费大量的时间精力;为此,Babel提供了预设(presets)的概念,意思就是预先设置好的一系列插件包;这就相当于肯德基中的套餐,将众多产品进行搭配组合,适合不同的人群需要;总有一款适合我们的套餐。

  比如@babel/preset-es2015就是用来将部分ES6语法转换成ES5语法,@babel/preset-stage-x可以将处于某一阶段的js语法编译为正式版本的js代码,而@babel/preset-stage-x也已经被Babel废弃了,有兴趣的童鞋可以看这篇官方的文章

  我们实际会用到的预设有以下:

  • @babel/preset-env
  • @babel/preset-flow
  • @babel/preset-react
  • @babel/preset-typescript

  根据名字我们可以大致猜出每个预设的使用场景,我们重点了解一下@babel/preset-env,它的作用是根据环境来转换代码。

  插件和预设都是通过数组的形式在配置文件中配置,如果插件和预设都要处理同一个代码片段,那么会根据以下执行规则来判定:

  • 插件比预设先执行
  • 插件执行顺序是插件数组从前向后执行
  • 预设执行顺序是预设数组从后向前执行

@babel/preset-env

  我们来看一下官网对它的描述:

@babel/preset-env是一个智能预设,可让您使用最新的JavaScript,而无需微观管理目标环境所需的语法转换(以及可选的浏览器polyfill)。这都使您的生活更轻松,JavaScript包更小!

  我们在项目中不会关心Babel用了哪些插件,支持哪些ES6语法;我们更多关心的是支持哪些浏览器版本这个层面,比如我们在项目中使用了箭头函数、Class、Const和模板字符串:

1
2
3
4
5
6
7
8
9
10
11
let fun = () => console.log("hello babel.js");
class Person {
constructor(name) {
this.name = name;
}
say() {
console.log(`my name is:${this.name}`);
}
}
const tom = new Person("tom");
tom.say();

  但是假如我们的项目需要支持IE10,因此我们需要修改.babelrc

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

  或者对它进行缩写:

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

  通过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
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; }

var fun = function fun() {
return console.log('hello babel.js');
};

var Person = /*#__PURE__*/function () {
function Person(name) {
_classCallCheck(this, Person);

this.name = name;
}

_createClass(Person, [{
key: "say",
value: function say() {
console.log("my name is\uFF1A".concat(this.name));
}
}]);

return Person;
}();

var tom = new Person('tom');
tom.say();

  可以发现虽然我们没有配置任何转换插件,但是上面写的的箭头函数、Class、Const和模板字符串语法都已经被转换了;默认情况下,@babel/env等于@babel/preset-es2015、@babel/preset-es2016和@babel/preset-es2017三个套餐的叠加。

  那如果我们只需要支持最新的Chrome了,可以继续修改.babelrc

1
2
3
4
5
6
7
8
9
10
{
"presets": [
[
"@babel/env",
{
"targets": "last 2 Chrome versions"
}
]
]
}

  targets中的含义是最新的两个Chrome版本,Babel再次编译输出:

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

let fun = () => console.log("hello babel.js");

class Person {
constructor(name) {
this.name = name;
}

say() {
console.log(`my name is:${this.name}`);
}

}

const tom = new Person("tom");
tom.say();

  而最新版本的Chrome已经支持箭头函数、Class、Const和模板字符串,所以在编译时不会在进行转换,相关代码在demo3

  上面的target字段不少同学肯定看着很眼熟,这个工具能够根据项目中指定的目标浏览器自动来进行配置,这里我们就不展开深入讨论了;它也可以单独在项目中配置一个.browserslistrc文件:

1
last 2 Chrome versions

  这样和targets字段的使用效果是一样的;正常情况下,推荐使用browserslist的配置而很少单独配置@babel/preset-env的targets;@babel/preset-env有一些常用的配置项让我们来看一下:

targets

  虽然targets不推荐使用,但是我们还是来了解一下它的用法,它是用来描述我们在项目中想要支持的目标浏览器环境,它可以是Browserslist格式的查询:

1
2
3
{
"targets": "> 0.25%, not dead"
}

  或者可以是一个对象,用来描述支持的最低版本的浏览器:

1
2
3
4
5
6
{
"targets": {
"chrome": "58",
"ie": "11"
}
}

  其他的浏览器版本还可以是:operaedgefirefoxsafariiosandroidnodeelectron

  这个属性主要是给其他插件传递参数(比如@babel/plugin-transform-arrow-functions),默认是false,设为true后,我们的箭头函数会有以下改变:

  1. 将箭头函数生成的函数用.bind(this)包裹一下,以便在函数内部继续使用this,而不是重命名this。
  2. 加一个检查防止函数被实例化
  3. 给箭头函数加一个名字

loose

  这个属性也主要是给其他插件传递参数(比如@babel/plugin-transform-classes),默认是false,类的方法直接定义在构造函数上;而设置为true后,类的方法被定义到了原型上面,这样在类的继承时可能会引起问题。

include

  转换时总是会启用插件的数组,格式是Array<string|RegExp>,它可以是一下两种值:

  1. Babel插件
  2. 内置的core-js,比如es.mapes.set

  比如我们在last 2 Chrome versions目标浏览器环境下,不会转换箭头函数和Class,但是我们可以将转换箭头函数的插件配置到include中,这样不管我们的目标浏览器怎么更换,箭头函数语法总是会转换:

1
2
3
4
5
6
7
8
9
10
11
{
"presets": [
[
"@babel/env",
{
"targets": "last 2 Chrome versions",
"include": ["@babel/plugin-transform-arrow-functions"]
}
]
]
}

useBuiltIns和corejs

  useBuiltIns这个属性决定是否引入polyfill,可以配置三个值:false(不引入)、usage(按需引入)和entry(项目入口处引入);corejs表示引入哪个版本的core-js,可以选择2(默认)或者3,只有当useBuiltIns不为false时才会生效。

@babel/polyfill

  虽然@babel/preset-env可以转换大多高版本的JS语法,但是一些ES6原型链上的函数(比如数组实例上的的filter、fill、find等函数)以及新增的内置对象(比如Promise、Proxy等对象),是低版本浏览器本身内核就不支持,因此@babel/preset-env面对他们时也无能为力。

  比如我们常用的filter函数,在IE浏览器上就会出现兼容性问题,因此我们通过polyfill(垫片)的方式来解决,下面是filter函数简单的兼容代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (!Array.prototype.filter) {
Array.prototype.filter = function (fun /*, thisp*/ ) {
var len = this.length;
if (typeof fun != "function") {
throw new TypeError();
}
var res = new Array();
var thisp = arguments[1];
for (var i = 0; i < len; i++) {
if (i in this) {
var val = this[i];
if (fun.call(thisp, val, i, this)) {
res.push(val);
}
}
}
return res;
};
}

  但是ES有那么多函数和内置对象,我们不可能一个一个都手写来解决,这就到了@babel/polyfill用武之处了;首先我们需要在项目中安装它:

1
npm install --save @babel/polyfill

  安装完成后在需要转换的文件入口加入引用代码:

1
import '@babel/polyfill'

  或者我们也可以在Webpack入口处进行引入:

1
2
3
module.exports = {
entry: ["@babel/polyfill", "./src/index.js"],
};

  然后通过webpack来打包,这样就能看到在我们的代码中加入了很多的兼容代码,相关代码在demo4

polyfill-bundle

  发现我们数组的fill、filter和findIndex等方法都打包进去了,但是看到这么多密密麻麻的兼容代码,眼尖的童鞋肯定会发现以下两个问题:

  1. 打包出来生成的文件非常的大;有一些语法特性可能是我们没用到的,但是Webpack不管三七二十一全都引用进去了,导致打包出来的文件非常庞大。
  2. 污染全局变量;polyfill给很多类的原型链上添加函数,如果我们开发的是一个类库给其他开发者使用,这种情况会非常不可控。

  因此从Babel7.4开始@babel/polyfill就不推荐使用了,而是直接引入core-jsregenerator-runtime两个包;而@babel/polyfill本身也是这两个包的集合;在上面webpack打包出来的dist文件我们也可以看到,引用的也是这两个包。那core-js到底是什么呢?

  • 它是JavaScript标准库的polyfill
  • 它尽可能的进行模块化,让你能选择你需要的功能
  • 它和babel高度集成,可以对core-js的引入进行最大程度的优化

  目前我们使用的默认都是core-js@2,但它已经封锁了分支,在此之后的特性都只会添加到core-js@3,因此也是推荐使用最新的core-js@3

@babel/preset-env与core-js

  在上面@babel/preset-env配置中有useBuiltIns和corejs两个属性,是用来控制所需的core-js版本;我们以Object.assign、filter和Promise为例,相关代码在demo5

1
2
3
4
5
6
7
8
9
Object.assign({}, {});

[(1, 5, 10, 15)].filter(function (value) {
return value > 9;
});

let promise = new Promise((resolve, reject) => {
resolve(1);
});

  然后修改配置文件,如果我们将useBuiltIns配置为非false而没有指定corejs的版本,Babel会提示我们需要配置corejs的版本:

env-corejs

  秉承着用新不用旧的原则,毅然选择core-js@3

1
2
3
4
5
6
7
8
9
10
11
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": 3
}
]
]
}

  可以看到我们的打包的文件自动引入了core-js中的模块:

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

require("core-js/modules/es.array.filter");

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

require("core-js/modules/es.object.to-string");

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

Object.assign({}, {});
[(1, 5, 10, 15)].filter(function (value) {
return value > 9;
});
var promise = new Promise(function (resolve, reject) {
resolve(1);
});

  而且我们发现它只引入了部分模块;这就比较厉害了,它不仅会考虑到代码中用到的新特性,还会参考目标浏览器的环境来进行按需引入;而useBuiltIns设置为entry的情况则会将core-js中的模块在入口处全部引入,这里就不再演示。

@babel/runtime

  我们在上面通过@babel/preset-env转换Class类时发现输出文件的头部多了_classCallCheck、_defineProperties和_createClass三个函数声明,这就是注入的函数,称为辅助函数;@babel/preset-env在转换时注入了函数声明,以便语法转换后使用。

  但是我们开发项目时,文件少则几十个,多个上百个,如果每个文件都注入了函数声明,再通过打包工具打包后输出文件又会非常庞大,影响性能。

  因此,Babel提供的解决思路是把这些辅助函数都放到一个npm包里面,在每次需要使用的时候就从这个包里把函数require出来;这样即使有几千个文件,也都是对函数进行引用,而不是复制代码;最后通过webpack等工具打包时,只会将npm包中引用到的函数打包一次,这样就复用了代码,减少打包文件的大小。

  @babel/runtime就是这些辅助函数的集合包,我们查看@babel/runtime下面的helpers,可以发现导出了很多函数,以及我们上面提及到的_classCallCheck函数:

babel-runtime

  首先当然是需要安装@babel/runtime这个包,除此之外还需要安装@babel/plugin-transform-runtime,这个插件的作用是移除辅助函数,将其替换为@babel/runtime/helpers中函数的引用。

1
2
npm install --save @babel/runtime
npm install --save-dev @babel/plugin-transform-runtime

  然后修改我们的配置文件,相关代码在demo6

1
2
3
4
{
"presets": ["@babel/env"],
"plugins": ["@babel/transform-runtime"]
}

  再次打包发现我们的辅助函数已经变成下面的引用方式了:

1
2
3
4
5
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");

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

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

@babel/plugin-transform-runtime

  上面我们说到@babel/polyfill会建立一个完整的ES2015环境,因此造成了全局变量的污染;虽然使用core-js不会引入全部模块,但是也会污染部分全局变量。

  而@babel/plugin-transform-runtime除了能够转换上面的辅助函数,还能对代码中的新特性API进行一个转换,还是以我们的filter函数和Promise对象为例,相关代码在demo7

1
2
3
4
5
6
[1, 5, 10, 15].filter((value) => {
return value > 9;
});
let promise = new Promise((resolve, reject) => {
resolve(1);
});

  然后修改我们的配置文件.babelrc:

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

  再次查看打包出来的文件发现filter和Promise已经转换成了引用的方式:

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

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

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

var _filter = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/filter"));

var _context;

(0, _filter["default"])(_context = [1, 5, 10, 15]).call(_context, function (value) {
return value > 9;
});
var promise = new _promise["default"](function (resolve, reject) {
resolve(1);
});

  我们发现打包出来的模块是从@babel/runtime-corejs3这个包里面引用的;经过查看,发现它下面包含了三个文件夹:core-js、helpers和regenerator,因此我们可以发现:

@babel/runtime-corejs2 ≈ @babel/runtime+core-js+regenerator ≈ @babel/runtime+@babel/polyfill

@babel/polyfill和@babel/runtime的区别

  经过下面这么多例子,总结一下@babel/polyfill和@babel/runtime的区别:前者改造目标浏览器,让你的浏览器拥有本来不支持的特性;后者改造你的代码,让你的代码能在所有目标浏览器上运行,但不改造浏览器。

  一个显而易见的区别就是打开IE11浏览器,如果引入了@babel/polyfill,在控制台我们可以执行Object.assign({}, {});而如果引入了@babel/runtime,会提示你报错,因为Object上没有assign函数。

更多前端资料请关注作者公众号``【前端壹读】``。
PS:公众号接入了图灵机器人小壹,欢迎各位老铁来撩。
follow.png

本文地址: http://xieyufei.com/2020/11/18/Babel-Practice.html

@谢小飞的网站

本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号后回复【转载】。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK