前言
巴比伦塔 (希伯来语:מִגְדַּל בָּבֶל,Migdal Bāḇēl)也译作巴贝尔塔、巴别塔,或意译为通天塔),本是犹太教《塔纳赫·创世纪篇》中的一个故事,说的是人类产生不同语言的起源。在这个故事中,一群只说一种语言的人在“大洪水”之后从东方来到了示拿地区,并决定在这修建一座城市和一座“能够通天的”高塔;上帝见此情形就把他们的语言打乱,让他们再也不能明白对方的意思,并把他们分散到了世界各地。[en.wikipedia.org/wiki/Tower_…]
Babel是什么
Babel官网是这样定义的:Babel is a JavaScript compiler。Babel 是一套解决方案,主要用来把 ECMAScript 2015+的代码转化为浏览器或者其它环境支持的代码。它主要可以做以下事情:
- 语法转换
- 为目标环境提供Polyfill解决方案
- 源码转换
- 其它可参考
Babel的历史
2014年,高中生Sebastian McKenzie首次提交了babel的代码,当时的名字叫6to5。从名字就能看出来,它主要的作用就是将 ES6 转化为 ES5。于是很多人评价,6to5 只是 ES6 得到支持前的一个过渡方案,但作者非常不同意这个观点,他认为 6to5 不光会按照标准逐步完善,依然具备非常大的潜力反过来影响并推进标准的制定。正因为如此,后来的 6to5 团队觉得 '6to5' 这个名字并没有准确的传达这个项目的目标。加上 ES6 正式发布后,被命名为 ES2015,对于 6to5 来说更偏离了它的初衷。于是 2015 年 2 月 15 号,6to5 正式更名为 Babel。(把 ES6 送上天的通天塔)
Babel的使用
了解完babel是什么后,我们接下来看如何使用它。根据官网中提供的用法,我们初始化一个基础项目并安装依赖。
npm install --save-dev @babel/core @babel/cli @babel/preset-env
- 目录结构如下
- package.json中新增babel命令
- babel.config.js配置
- 配置中的debug用于打印babel命令执行的日志
- presets 主要是配置用来编译的预置,plugins 主要是配置完成编译的插件,具体的含义后面会讲
- src/index.js
接下来,在命令行执行 npm run babel 命令,看看转换效果。
从上图中可以看到,const被转换成了var,箭头函数转换成了普通function,同时打印出来如下日志:
Babel原理
了解完成babel的基础使用后,我们来分析babel的工作原理。babel作为一个编译器,主要做的工作内容如下:
- 解析源码,生成AST
- 对AST进行转换,生成新的 AST
- 根据新的AST生成目标代码
整体流程图下:
暂时无法在飞书文档外展示此内容
根据上图中的流程,我们依次进行分析。
Parse(解析)阶段
一般来说,Parse 阶段可以细分为两个阶段:词法分析(Lexical Analysis, LA)和语法分析(Syntactic Analysis, SA)。
- 词法分析
词法分析是对代码进行分词,把代码分割成被称为Tokens 的东西。Tokens 是一个数组,由一些代码的碎片组成,比如数字、标点符号、运算符号等等,例如这样:
// 代码
const a = 1;
// Tokens https://esprima.org/demo/parse.html#
[ { "type": "Keyword", "value": "const" }, { "type": "Identifier", "value": "a" }, { "type": "Punctuator", "value": "=" }, { "type": "Numeric", "value": "1" }, { "type": "Punctuator", "value": ";" }]
- 语法分析
词法分析之后,代码就已经变成了一个 Tokens 数组,现在需要通过语法分析把 Tokens 转化为 AST。例如上面的代码转成的AST结构如下(在线查看):
在babel中,以上工作是通过 @babel/parser 来完成的,它基于ESTree 规范,但也存在一些差异。从上图中,我们可以看到最终生成的AST结构中有很多相似的元素,它们都有一个 type 属性(可以通过官网提供的说明文档来查看所有类型),这样的元素被称作节点。一个节点通常含有若干属性,可以用于描述 AST 的节点信息。
Transform(转换)阶段
转换阶段,Babel 对 AST 进行深度优先遍历,对于 AST 上的每一个分支 Babel 都会先向下遍历走到尽头,然后再向上遍历退出刚遍历过的节点,然后寻找下一个分支。在遍历的过程中,可以增删改这些节点,从而转换成实际需要的 AST。
以上是babel转换阶段操作节点的思路,具体是实现是:babel维护一个称作 Visitor 的对象,这个对象定义了用于 AST 中获取具体节点的方法,如果匹配上一个 type,就会调用 visitor 里的方法,实现如下:
暂时无法在飞书文档外展示此内容
一个简单的Visitor对象如下:
const visitor = {
FunctionDeclaration(path, state) {
console.log('我是函数声明');
}
};
在遍历AST的过程中,如果当前节点的类型匹配visitor中的类型,就会执行对应的方法。上面提到,遍历AST节点的时候会遍历两次(进入和退出),因此,上面的Vistor也可以这样写:
const visitor = {
FunctionDeclaration: {
enter(path, state) {
console.log('enter');
},
exit(path, state) {
console.log('exit');
}
}
};
Visitor 中的每个函数接收2个参数:path 和 state
path:表示两个节点之间连接的对象,对象包含:当前节点、父级点、作用域等元信息,以及增删改查 AST 的 api。
state:遍历过程中 AST 节点之间传递数据的方式,插件可以从 state 中拿到 opts,也就是插件的配置项
例如使用上面visitor遍历如下代码时
// 源码
function test() {
console.log(1)
}
输出如下:
-
Generator(生成)阶段
经过上面两个阶段,需要转译的代码已经经过转换,生成新的 AST ,最后一个阶段理所应当就是根据这个 AST 来输出代码。在生成阶段,会遍历新的AST,递归将节点数据打印成字符串,会对不同的 AST 节点做不同的处理,在这个过程中把抽象语法树中省略掉的一些分隔符重新加回来。比如 while 语句 WhileStatement 就是先打印 while,然后打印一个空格和 '(',然后打印 node.test 属性的节点,然后打印 ')',之后打印 block 部分。
export function WhileStatement(this: Printer, node: t.WhileStatement) {
this.word("while");
this.space();
this.token("(");
this.print(node.test, node);
this.token(")");
this.printBlock(node);
}
@babel/generator 的 src/generators 下定义了每一种AST节点的打印方式,通过上述处理,就可以生成最终的目标代码了。
Plugin插件
- 上面介绍了Babel的原理,知道了babel是如何进行代码解析和转换,以及生成最终的代码。那么转换阶段,babel是怎么知道要进行哪些转换操作呢?答案是通过plugin,babel 为每一个新的语法提供了一个插件,在 babel 的配置中配置了哪些插件,就会把插件对应的语法给转化掉。插件被命名为 @babel/plugin-xxx 的格式。
-
插件的使用:
-
// babel配置文件 "plugins": [ "pluginA", ['pluginB'], ["babel-plugin-b", { options }] // 如果需要传参就用数组格式,第二个元素为参数。 ]
-
常用插件介绍
-
- @babel/plugin-transform-react-jsx:将jsx转换成react函数调用
-
// 源码 const profile = ( <div> <img src="avatar.png" className="profile" /> <h3>{[user.firstName, user.lastName].join(" ")}</h3> </div> );
-
// 出码 const profile = React.createElement( "div", null, React.createElement("img", { src: "avatar.png", className: "profile" }), React.createElement("h3", null, [user.firstName, user.lastName].join(" ")) );
-
- @babel/plugin-transform-arrow-functions:将箭头函数转成普通函数
-
// 源码 var a = () => {};
-
// 出码 var a = function() {};
-
- @babel/plugin-transform-destructuring:解构转换
-
// 源码 let { x, y } = obj; let [a, b, ...rest] = arr;
-
// 出码 function _toArray(arr) { ... } let _obj = obj, x = _obj.x, y = _obj.y; let _arr = arr, _arr2 = _toArray(_arr), a = _arr2[0], b = _arr2[1], rest = _arr2.slice(2);
更多插件请参考官网。
插件的形式:
babel插件支持两种形式,一是函数,二是对象。
- 函数形式
export default funciton(babel, options, dirname) {
return {
// 继承某个插件
inherits: parentPlugin,
// 修改参数
manipulateOptions(options, parserOptions) {
options.xxx = '';
},
// 遍历前调用
pre(file) {
this.cache = new Map();
},
// 指定 traverse 时调用的函数
visitor: {
FunctionDeclaration(path, state) {
this.cache.set(path.node.value, 1);
}
},
// 遍历后调用
post(file) {
console.log(this.cache);
}
}
}
- 对象形式
export default plugin = {
pre(state) {
this.cache = new Map();
},
visitor: {
FunctionDeclaration(path, state) {
this.cache.set(path.node.value, 1);
}
},
post(state) {
console.log(this.cache);
}
};
执行顺序:从前往后
Preset预设
上面介绍了插件的使用和具体实现,在实际的项目中,转换时会涉及到非常多的插件,如果我们依次去添加对应的插件,效率会非常低,而且记住插件的名字和其对应功能本身就是一件很难的事。我们能不能把通用的插件封装成一个集合,用的时候只需要安装一个插件即可,这就是preset。一句话总结:preset 就是对 babel 配置的一层封装。
暂时无法在飞书文档外展示此内容
暂时无法在飞书文档外展示此内容
预设的使用
使用详情可参考官网
// babel配置文件
{
"presets": [
"presetA", // 字符串
["presetA"], // 数组
[
"presetA", // 如果有参数,数组第二项为对象
{
target: {
chrome: '58' // 目标环境是chrome版本 >= 58
}
}
]
]
}
执行顺序:从后往前
插件&预设执行顺序:先执行插件,后执行预设
Polyfill
让我们再次回到开始的源码转换
从转换结果来看,const和var都进行了转换,但 startsWith 方法却保留原样,这是怎么回事呢?原因是在babel中,把 ES6 的标准分为 syntax 和 built-in 两种类型。syntax 就是语法,像 const、=> 这些默认被 Babel 转译的就是 syntax 类型。而对于那些可以通过改写覆盖的语法就认为是 built-in,像 startsWith 和 includes 这些都属于 built-in。而 Babel 默认只转译 syntax 类型的,对于 built-in 类型的就需要通过 @babel/polyfill 来完成转译。 @babel/polyfill 实现的原理也非常简单,就是覆盖那些 ES6 新增的 built-in。示意如下:
Object.defineProperty(Array.prototype, 'startsWith',function(){...})
由于 Babel 在 7.4.0 版本中宣布废弃 @babel/polyfill ,而是通过 core-js 替代,所以本文直接使用 core-js 来讲解 polyfill 的用法
core-js使用
- 安装:
npm install --save core-js
- 配置corejs
// babel.config.js
const presets = [
[
'@babel/env',
{
debug: true,
+ useBuiltIns: 'usage', // usage | entry | false
+ corejs: 3, // 2 | 3
}
]
]
- 再次执行npm run babel
可以看到,代码顶部多了require("core-js/modules/es.string.starts-with.js"),通过阅读require进来的源码,它内部实现了字符串的startsWith方法,这样就完成了built-in类型的转换。
手写babel插件
通过上面的介绍,我们对插件的形式和实现有了基本的了解,接下来我们将通过手写一个简单的插件来切身的感受下babel的魅力。
在我们的日常开发中,经常会在async函数中使用tryCatch来封装代码,例如:
async function getName() {
try {
// code
const name = await api.getName();
} catch(error) {
// do somethine
}
}
上述每个这样的函数我们都需要封装一次,我们能否把封装的工作交给babel来处理呢?答案是肯定的,让我们一起看看怎么实现?
- 我们先给插件起个名字:babel-plugin-try-catch
- 实现功能
const template = require('@babel/template'); // 使用它来将代码批量生成节点
function babelPlugintryCatch({ types: t }) {
return {
visitor: {
FunctionDeclaration: {
enter(path) {
/**
* 1. 获取当前函数体
* 2. 如果是async函数,则创建tryCatch并将原函数内容放到try体内
* 3. 替换原函数
*/
// 1. 获取当前函数节点信息
const { params, generator, async, id, body } =path.node;
// 如果是async,则执行替换
if (async) {
// 生成 console.log(error) 的节点数据
const catchHandler = template.statement('console.log(error)')();
// 创建trycatch节点,并把原函数体内的代码放到try{}中,把刚刚生成的catchHandler放到catch体内
const tryStatement = t.tryStatement(body, t.catchClause(t.identifier('error'), t.BlockStatement([catchHandler])));
// 创建一个新的函数节点并替换原节点
path.replaceWith(t.functionDeclaration(id, params, t.BlockStatement([tryStatement]), generator, async))
// 跳过当前节点,否则会重新进入当前节点
path.skip();
}
}
}
}
}
}
module.exports = babelPlugintryCatch
- 添加配置
- 执行命令npm run babel,看转换结果
从结果来看,我们已经实现了基本的转换需求,但还不是一个完善的插件,例如如果已经有trycatch了就不需要再转换了,又例如可以在catch体内做一些错误上报等。其它功能留给大家去探索~
总结
babel是一款javascript编译器,它的作用是将js编译成目标环境可运行的代码,编译原理是先解析源代码生成AST,对AST进行操作并生成新的AST,最后根据新的AST生成最终的代码。在转换过程中,遍历到不同的节点类型时,会调用在插件中定义的访问者函数来处理,而单个插件的管理成本太大,因此,babel在插件的基础上通过抽象一层preset来批量引入 plugin 并进行配置。【没有什么问题是不能通过增加一个抽象层解决的,如果有,再增加一层】最后我们一起手动实现了一个简单的babel插件,对babel的转换原理有了更加深入的理解;更多babel详情,敬请期待下一篇。