前言
Babel
在前端拥有着举足轻重的地位,几乎所有大型的项目都离不开Babel
。也许你配置过Babel
,但是配置工程师向来都只是一个起点,只是浅尝辄止的研究往往都是知其然不知其所以然。所以,让我们开始吧,真正的去了解和拥抱Babel
。
Babel是什么
Babel
是一个工具链,主要用于将采用 ECMAScript 2015+
语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。它需要完成以下内容:
- 语法转换,一般是指高级语言特性的降级
- 通过
Polyfill
(例如core-js
) 方式在目标环境中添加缺失的功能 - 源码转换,比如
JSX
等
总的来说,编译是Babel
的核心,所以Babel
它自身的实现就是基于编译原理,深入AST
来生成目标代码,同时通过各种工具(比如Webpack
, core-js
)来相互配合,进行工程化协作,最后构建出符合产出的良好项目结构代码。
Babel包解析
Babel
本身是一个使用Lerna
构建的Monorepo
风格的仓库,其中包含了上百个包,Babel
的包大致分为两种:
- 一种是在工程本身上起作用的,对于业务而言并不透明的,比如支持
@babel/core
的能力的包例如@babel/preser
、@babel/code-frame
等等 - 一种是外供给工程使用的,对于业务而言是透明的可用的,比如编写插件使用的
@babel/types
,配置时候经常使用的@babel/preset-env
等等。
下面,我们对Babel
中比较重要的包进行梳理和简单讲解。
@babel/core
@babel/core
是Babel
实现转换的核心,它可以根据配置实现源码的编译转换,提供了基础的编译能力。而它的能力是由更底层的@babel-parser
、@babel-traverse
、@babel-generator
、@babel/types
等包所提供。这些基础的包提供了基础的AST处理能力。
下面,我们一一分析每个包的作用和功能。
@babel-parser
是Babel
用来对JavaScript
进行解析的解析器,它提供了parse()
方法用来将源码编译成一个AST语法树。- 有了语法树,我们就需要对这个AST进行遍历和修改,这个时候,
@babel-traverse
提供了对AST遍历的功能,而@babel/types
提供了对AST节点修改的能力(后面我们会使用这个包来自定义插件)。 - 修改完成AST后,我们还需要将这个AST进行聚合生成符合要求的
JavaScript
代码,这个能力是由@babel-generator
提供的。
以上就是典型的Babel
底层编译的流程了。
@babel/cli
@babel/cli
是Babel
提供的命令行,可以在终端中通过命令行的方式运行、编译。其原理很简单,就是使用commander
库搭建基本的命令行,@babel/cli
主要是负责获取配置内容,最终依赖@babel/core
来完成编译。
@babel/preset-env
相比于前面两个插件,想必大家更熟悉@babel/preset-env
,因为我们会通过@babel/preset-env
来配置编译降级,@babel/preset-env
允许我们配置需要支持的目标环境(浏览器环境或者node
环境),利用babel-polyfill
完成补丁接入。@babel/preset-env
的实现原理也比较简单,主要分三步:
@babel/preset-env
会收集目标环境的特性支持情况。这包括浏览器或 Node.js 的版本号,以及目标环境中支持的 ECMAScript 特性和语法(主要是调用@babel/helper-compilation-targets
支持)。@babel/preset-env
会根据收集到的特性支持情况,确定需要转换的特性和语法。这包括需要转换的 ECMAScript 版本、需要转换的特性和语法等(主要是调用@babel/preset-env/lib/normalize-options
支持)。@babel/preset-env
会根据确定的需要转换的特性和语法,加载相应的插件,并对代码进行转换。这包括语法转换、特性转换、模块转换等等。
Babel的使用
上面简单介绍了一下Babel
的部分插件包,现在我们自己动手完成一个Babel
的基础使用,以及Babel
插件的编写。
初始化
我们新建一个文件夹,然后初始化项目
npm init -y
上面提到,Babel
的使用离不开@babel/core
和@babel/cli
这两个,所以,我们安装一下:
npm install @babel/core @babel/cli -D
接着,我们在根目录创建两个文件夹src
和dist
,分别用来当做源码目录,和打包后的输出目录。
现在,我们在src
下创建index.js
:
const add = (a, b) => a+b;
同时在package.json
中增加以下配置:
"scripts": {
"babel": "babel ./src --out-dir ./dist"
},
这个时候,我们运行npm run babel
进行编译,可以看到,编译成功了,并且在dist
目录下生成了编译后的index.js
文件。
引入插件
细心的同学会发现,上面编译后的代码并没有将箭头函数编译成普通函数,这是因为上面提到的问题,Babel
本身拥有上百个包,每个包各司其职,因为Babel
不可能毫无设计的大包大揽,Babel
是可插拔的。
为了解决箭头函数的“问题”,我们需要使用@babel/plugin-transform-arrow-functions
,所以,我们安装一下:
npm install @babel/plugin-transform-arrow-functions -D
接着,增加文件.babelrc
(当然还有其他的配置方式,可以看官网):
{
"plugins": ["@babel/plugin-transform-arrow-functions"]
}
我们再运行npm run babel
,可以看到dist
下面的index.js
编译成了如下:
const add = function (a, b) {
return a + b;
};
这个效果是符合预期的。
现在,修改一下src/index.js
:
const add = (a, b) => a+b;
class Person {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(`Hello, ${this.name}!`);
}
}
const person = new Person('Alice');
person.sayHello();
运行npm run babel
,发现编译成功了,但是Class
并没有被转换,原因如上,我们缺少了对Class
进行转换的插件,安装一下:
npm install @babel/plugin-proposal-class-properties -D
需要注意的是,在某些情况下,Babel 会根据目标环境的支持情况,自动判断是否需要进行转换。如果目标环境已经支持某种语法,Babel 就不会对其进行转换,这样可以减少代码的体积和运行时性能开销。所以我们还需要引入一下我们最常使用的@babel/preset-env
,来明确我们的环境:
npm install @babel/preset-env -D
修改.babelrc
:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["last 2 versions", "ie >= 11"]
}
}
]
],
"plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-transform-arrow-functions"]
}
需要注意一下,
.babelrc
的plugins
是从左向右执行的,上面的配置就是先对类进行转换,再对箭头函数进行转换。而presets
是从右向左执行,刚好相反。
运行npm run babel
,我们发现打包的文件中class
也被转换了。
编写插件
上面我们通过@babel/plugin-proposal-class-properties
和@babel/plugin-transform-arrow-functions
熟悉了Babel
中如何使用人们编写好的插件,那么我们是否可以自己去写一个Babel
的插件来实现特定的功能呢,答案显示是可以的。那么,我们就开始编写一个属于自己的插件吧。
编写的插件功能: 将编译的文件中的加法变成减法:例如 var a = 2 + 1,编译后变成 var a = 2 – 1
如何加载插件
第一种方式,就是我们和别的插件一样,发布到npm上,然后下载下来,在.babelrc
中配置好插件名称就好了(当然也可以使用npm link
来将本地的插件软链接过来,详细的使用可以参考我另一篇文章:npm核心原理和操作指南)。
另一种方式,直接在项目中编写插件,然后在.babelrc
中通过访问相对路径的方式来加载插件。
我们采用第二种方式,首先,我们在根目录下创建一个文件夹plugins
,然后在该文件夹下增加一个conversion-calc.js
:
module.exports = function (babel) {
// 这个types就是@babel/types,可以用来修改AST的节点
const { types } = babel;
return {
visitor: {
BinaryExpression(path) { //需要处理的节点路径
let node = path.node;
let left = node.left;
let operator = node.operator;
let right = node.right;
if (!isNaN(left.value) && !isNaN(right.value) && operator === '+') {
// binaryExpression 是一个 AST(抽象语法树) 节点类型,表示二元表达式
const expression = types.binaryExpression('-', left, right);
// 替换
path.replaceWith(expression);
}
}
}
}
}
这个插件很简单,上面注释已经解释了,只要跟着API来即可。
现在,我们将这个插件放入.babelrc
:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["last 2 versions", "ie >= 11"]
}
}
]
],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-arrow-functions",
"./plugins/conversion-calc.js"
]
}
然后,我们在src/index.js
中增加一行代码: var a = 2 + 1
;
运行npm run babel
,发现dist/index.js
编译后如下:
"use strict";
/// ...
var person = new Person('Alice');
person.sayHello();
var result = 1 - 2;
可以看到,我们的插件起作用了~~~
AST部分这边先略过了,下篇文章会详细的讲解,如果想看AST的结构,可以访问 AST操练场
预设
前面,我们已经在.babelrc
中使用了预设(presets
),预设其实是一组预定义的转换规则集合,可以在一个预设中包含多个转换规则和插件,从而简化配置过程。比如我们使用的@babel/preset-env
,这个预设包含了一组转换规则和插件,无需单独指定每个转换规则和插件,用于根据目标环境自动确定需要转换的 ECMAScript 特性和语法,并将其转换为向后兼容的代码。
典型的预设
@babel/preset-stage-xxx
@babel/preset-stage-xxx
用于提供 ECMAScript
的实验性特性。这些预设包含了一些还没有被正式纳入 ECMAScript
规范中,但是已经被提案并且正在积极开发和测试的特性。
这些预设按照提案的阶段进行分类,分别为 Stage 0
、Stage 1
、Stage 2
和 Stage 3
。其中,Stage 0
表示最初的草案,而 Stage 3
表示即将成为 ECMAScript
规范的最终草案。通常来说,只有 Stage 3
的特性才有可能被纳入下一个 ECMAScript
规范中。
需要注意的是,从
Babel 7.0
开始,这些预设已经被废弃,不再建议使用。如果你需要使用实验性特性,可以直接使用对应的插件,例如@babel/plugin-proposal-xxx
等,以确保更好的灵活性和可控性。其实我们并不需要使用这些预设,因为我们已经有@bebel/preset-env
了
@babel/preset-env
@babel/preset-env
是根据浏览器的不同版本中缺失的功能确定代码转换规则的,在配置的时候我们只需要配置需要支持的浏览器版本就好了,@babel/preset-env
会根据目标浏览器生成对应的插件列表然后进行编译:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["last 2 versions", "ie >= 11"]
}
}
]
]
}
在默认情况下 @babel/preset-env
支持将 JS 目前最新的语法转成 ES5,但需要注意的是,如果你代码中用到了还没有成为 JS 标准的语法,该语法暂时还处于 stage 阶段,这个时候还是需要安装对应的 stage 预设,不然编译会报错。
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["last 2 versions", "ie >= 11"]
}
}
]
],
"stage-0"
}
虽然可以采用默认配置,但如果不需要照顾所有的浏览器,建议配置目标浏览器和环境,这样可以保证编译后的代码体积足够小,因为在有的版本浏览器中,新语法本身就能执行,不需要编译。
Polyfill
上面有提到,Babel
除了对高级语言降级已适配当前环境外,还可以通过 Polyfill
(例如core-js
) 方式在目标环境中添加缺失的功能。那么,我们来实验一下。
首先,我们安装一下插件包:
npm install @babel/polyfill -D
需要注意的是,@babel/polyfill
是在我们的代码中引入,而不是再.babelrc
中配置,为了验证@babel/polyfill
,我们在src
目录增加一个test-polyfill.js
:
import '@babel/polyfill';
const arr = [1, 2, 3, 4, 5];
console.log(arr.includes(3));
Promise.resolve(true);
这个时候,运行npm run babel
看看dist/test-polyfill.js
:
"use strict";
require("@babel/polyfill");
var arr = [1, 2, 3, 4, 5];
console.log(arr.includes(3));
Promise.resolve(true);
这样在低版本的浏览器中也能正常运行,只是我们可以看到全量的引入了polyfill
,但是我们在这里其实只需要处理Array.includes
和Promise
,其他的并不需要,所以按需引入才是最优解。
按需引入其实非常简单,@babel/preset-env
已经为我们想好了,只需要配置useBuiltIns
和corejs
即可,我们修改.babelrc
如下:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["last 2 versions", "ie >= 11"]
},
"corejs": "3",
"useBuiltIns": "usage"
}
]
],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-arrow-functions",
"./plugins/conversion-calc.js"
]
}
同时,我们把dist/test-polyfill.js
中的引入删掉,再运行npm run babel
,可以看到编译后的dist/test-polyfill.js
:
"use strict";
require("core-js/modules/es.array.includes.js");
require("core-js/modules/es.object.to-string.js");
require("core-js/modules/es.promise.js");
var arr = [1, 2, 3, 4, 5];
console.log(arr.includes(3));
Promise.resolve(true);
可以看到,已经做到按需加载了。
结语
Babel
的整个过程就差不多讲完了,希望能够帮助到大家~~~