对于前端人员而言,Bable 的重要性再怎么说都不为过,它不只是一款语法转换工具,借助 Babel 插件的力量,JavaScript 实现了很多有想象力的功能。我们也可以借助 Babel 插件的力量完成一些很麻烦的工作,比如代码版本升级,自定义语法,自定义规范等功能。本文我们先了解下 Babel 插件的定义方式,以及如何编写一个 Babel 插件。
Babel 插件通过对 AST 的修改实现代码的修改,过程是这样的,AST 以树状结构表示代码,程序会遍历这个树,在遍历的过程中,如果碰到了插件匹配的节点,则执行插件中的逻辑,进行节点的修改或替换。
开发环境搭建
为了方便开发和调试插件,我搭建一个插件的开发环境,其实就是 babel 的最小化环境, 只需要安装 babel 的核心包,然后在 .babelrc 文件中配置 plugins ,该配置项是一个数组,表示 Babel 需要加载的插件列表,我们将其指向自定义插件的路径就可以了。
package.json
{
"scripts": {
"build": "babel src -d dist",
"debug": "node --inspect-brk node_modules/@babel/cli/bin/babel src -d dist",
},
"devDependencies": {
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
}
}
.babelrc
{
"plugins": ["./plugin/babel-plugin-shenmax-plugin"]
}
正式环境下,应该将插件单独打包,发布到 npm 进行引用。
插件的基本结构
插件本身是一个函数,函数的入参是 babel 对象,从中我们可以拿到 babel 的所有成员,最常用的是 types 对象,它是由 babel-types 包提供的一个工具对象,用于构造、验证以及变换 AST 节点。对编写处理 AST 逻辑非常有用,编写插件会频繁的使用这个对象。
如下是一个插件的最基本的结构,返回一个对象,对象中有定义了一个访问者(visitor)。
module.exports = (babel) => {
return {
visitor: {
...
}
}
};
那这个 visitor 怎么定义呢?我们在Babel AST 生成之路中提到,AST 是由一个个的 Node 构成的,每个 Node 通过 type 来进行区分。
interface Node {
type: string;
}
visitor 中会定义遇到某个类型的 Node 执行的操作。比如我们有一个标识符 a,某个 vister 想要改变这个标志符为 b,如何修改呢?
{
"type": "Identifier",
"name": "a"
}
我们首先确定这个 vister 的访问类型,即 Identifier,将其作为方法名,这样当 AST 遍历到类型为 Identifier 的节点的时候,就会进入这个方法,方法的参数为当前节点的路径 path 以及状态 state。我们在方法中获得当前路径的 node,将其 name 修改为 'b'。
module.exports = (babel) => {
return {
visitor: {
Identifier(path, state) {
const node = path.node;
if (node.name === 'a'){
node.name = 'b'
}
}
}
}
};
这样,第一个插件就完成了,它会将所有叫做 'a' 的标识符全部修改为 'b'。
const a = 1;
在经过插件转换后,打包结果如下。
const b = 1;
编写一个插件,我们需要关注以下几点。
- babel:插件的入参,可以从中拿到 types 对象,操作 AST 节点,由于 types 对象太常用了,babel 大部分情况下写做 {types:t}。
- visitor:插件核心对象,其中定义了插件生效的节点类型,以及生效方式。
- Identifier 等方法名:声明了插件作用的 AST 节点类型,入参是 path 和 state,每个 visitor 可以包含多个这样的方法,每个方法的方法名称都是一种或多种的节点类型。
- path:path 对象代表了当前节点的路径,通过 path 节点可以访获得当前的 node 对象,以及和该路径相关的对象,比如父节点、兄弟节点等。path 对象上还包含一些操作路径的方法等。
- state:表示代码和插件的状态,一般通过该对象访问插件的配置项。
节点定义
完成第一个插件后,我们详细看下这个插件的写法。
首先,就是 visitor 中方法名称,上例中方法名称是 Identifier,表示在 AST 遍历中遇到了一个标识符,我们还可以写 VariableDeclaration,表示遇到了一个函数申明(比如 let | const),还可以是 ArrowFunctionExpression,表示箭头函数。这些类型都是在 AST 生成过程中解析代码结构获得的。我们可以在 astexplorer 在线编译代码为 AST,查看节点类型。
对于某个节点而言,只了解它的类型是不够的,因为编写插件的时候,我们会频繁的操作节点,比如修改节点的参数,或者自己创建一个节点。这时候我么需要了解它的构造函数,校验方法,该节点的属性类型的等等。这些都定义在 babel-types 包中。具体是定义在 babel-types/src/definitions 目录下。如下是 Identifier 的定义。
defineType("Identifier", {
builder: ["name"],
visitor: ["typeAnnotation", "decorators"],
aliases: ["Expression", "PatternLike", "LVal", "TSEntityName"],
fields: {
name: {
validate: chain(
assertValueType("string")
),
optional: {
validate: assertValueType("boolean"),
optional: true,
}
},
},
validate(parent, key, node) {...},
});
上面的 Identifier 定义有一个 builder 字段。.
builder: ["name"]
builder 表明了该类型节点的构造方法,每一个节点类型都有构造器方法 builder,按类似下面的方式使用:
t.identifier("b"); // t 是 types 的简写
可以创建如下所示的 AST:
{
type: "Identifier",
name: "b"
}
我们在编写 Babel 插件的时候,会创建 AST 节点,由于 Babel API 的文档较少,有时需要看下 Babel 中对于每个类型的定义,才能确定这些节点的构造参数。
visitor 字段表示该节点的属性,表明该类型节点下可能存在的节点名称。
aliases 字段是该类型的别名,插件中允许使用别名进行定义,比如说使用 Function 就可以表示 FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, ObjectMethod 和 ClassMethod 这 5 中类型,因为 Function 是这 5 中类型的别名。
module.exports = (babel) => {
return {
visitor: {
Function(path, state) {
...
}
}
}
};
fields 中定义了节点的字段信息,以及如何验证这些字段。
t.isIdentifier(nodeInstance);
了解节点是如何定义之后,我们就能比较容易的通过 types 对象来创建和校验一些节点,极大的简化了插件中节点的操作。
Path
AST 中使用 Path 表示节点之间的关系,这个对象会作为入参传递给每个插件,插件通过修改 Path 对象达到修改 AST 结构的目的。
我们举例看下 path 对象的结构。
const a = 1;
其生成的 AST 如下所示。最外层是 File --> Program 节点,没什么看的,Program 的 body 表示当前代码结构,是一个变量声明(VariableDeclaration),类型是 const,declarations 数组表示本地声明的变量,是一个标识符(Identifier),具体名称是 a。
{
"type": "File",
"program": {
"type": "Program",
"sourceType": "module",
"body": [{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "NumericLiteral",
"value": 1
}
}]
}]
}
}
我们写一个插件,在 Identifier 节点打印下 path,看看 path 的数据结构。
module.exports = (babel) => {
return {
visitor: {
Identifier(path, state) {
console.log(path)
}
}
}
};
path 有很多属性,包括当前节点,父节点,以及父节点的路径等等。parentPath 也是一个 path 对象,通过这个属性可以一直链接到根节点。
{
"node": {
"type": "Identifier",
"name": "a"
},
"parent": {
"type": "VariableDeclarator",
"id": {...},
"init": {...}
},
"parentPath": {
"node": {},
"parent": {},
"parentPath": {}
},
"container": {},
"context": {},
"hub": {},
"data": {},
"listKey": null,
"inList": false,
"parentKey": null,
"key": null,
"scope": null,
"type": null
...
}
除了这些属性外,path 还有一些方法,用于添加、更新、移动和删除节点。方法非常多,基本包含了我们编写插件中需要的所有方法。babel-handbook 中有很多使用 path 的示例,建议仔细阅读。
写一个 Babel 插件
Babel 插件的编写属于很容易上手,但也很容易上头的类型,容易上手是因为插件结构简单,理解起来也没有什么太大的问题,比如我想写一个将 const 和 let 变量插件都变成 var 的插件,只需要三行代码。是不是很简单。
VariableDeclaration(path) {
const { node } = path;
if ( node.kind === "let" || node.kind === "const"){
node.kind = "var";
}
}
容易上头是因为你很快就会发现,这么写代码会出现一些莫名其妙的问题。就比如我们上面写的转换插件,在很多情况下转换出来的代码是错误的,因为 let 和 var 的作用域存在很大的差异,不能简简单单的替换(后续我会单独写篇文章说明这个问题)。
再举个例子,我们要写一个箭头函数的转换插件。
const a = () => {}
代码的 AST 结构如下所示。
{
"type": "VariableDeclaration",
"kind": "const"
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "ArrowFunctionExpression",
"id": null,
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"body": [],
}
}
}
]
}
第一个思路就是只要遇到 ArrowFunctionExpression 类型的节点,将其修改为 FunctionExpression。插件代码如下。
ArrowFunctionExpression(path) {
const node = path.node;
if (node.type === "ArrowFunctionExpression") {
node.type = "FunctionExpression";
}
}
看结果,箭头函数被转换为了函数,插件很成功。
const a = function () {};
我们很快发现,箭头函数还有一种写法,那就是省略大括号的写法,如果按照我们写的插件转换的话,结果就很搞笑了。
// 转换前
const a = (b) => b;
// 转换后
const a = function (b) b;
箭头函数有这样一个规则,如果函数体没有包在 {} 中,那么表示后续的表达式需要被 return。我们在转化的时候,需要兼顾这个原则。所以我们就需要判断 ArrowFunctionExpression 的 body 是否是一个 BlockStatement,如果不是的话,需要将其包装为 BlockStatement,并加上 retrun 关键字。这个插件就比较复杂了。
如下,我们使用 types 构造了一个 returnStatement 和 blockStatement,将其生成的节点替换掉 body 节点,完成语法转换。
module.exports = ({types:t}) => {
return {
visitor: {
ArrowFunctionExpression(path) {
const node = path.node;
if (node.type === "ArrowFunctionExpression") {
const body = path.get("body");
const bodyNode = body.node;
if (bodyNode.type !== 'BlockStatement') {
const statements = [];
statements.push(t.returnStatement(bodyNode));
node.body = t.blockStatement(statements);
}
node.type = "FunctionExpression";
}
}
}
}
};
上述插件的功能虽然完成了,但是插件写的比较粗糙,存在一些不严谨的判断。比如对于 type 的判断是直接通过字符串比较做的,这种方式不是 Babel 所提倡的。我们最好用 types 或者是 path 提供的方法。比如 node.type === "ArrowFunctionExpression" 完全可以使用 path.isArrowFunctionExpression() 来替换,更加严谨。
即使如此,箭头函数的转换依旧存在问题,那就是箭头函数本身是没有作用域的,使用父级作用域,所以在箭头函数的转普通函数的过程中,涉及到 this 的转换,这就更复杂了。
实际上,babel-plugin-transform-arrow-functions 插件就是负责箭头函数转换的,我们看下它的源码,发现代码很简单,核心就一句话。
path.arrowFunctionToExpression();
转换操作 path 帮我们封装了,我们直接调用接可以了。内部处理 BlockStatement 的逻辑和我们写的一致,至于是如何解决 this 作用域问题的,有兴趣的可以自行阅读源码的。Babel 插件的编写易学难精,目前网上也没有看到比较详细的 API 手册,学习的主要途径还是看官网以及看 babel 插件源码。
总结
Babel 插件通过定义访问者(visitor)对象,定义了需要处理的节点类型以及执行的操作。babel-types 中定义了不同类型节点的构建方式,校验方式,遍历方式以及别名,这些信息可以帮助我们编写插件。理解 Babel 插件,最重要的就是理解 path 对象,该对象表示节点之间的关系,我们通过 path 对象可以拿到任意的节点信息,插件通过修改 path 对象达到修改 AST 结构的目的。
Babel 插件比较难以编写的点就在于它修改的是代码底层中的底层,我们需要保证代码的健壮性,否则很可能导致整个程序的崩溃。Babel 通过 .babelrc 文件配置生效的插件列表,目前官网也提供了很多插件,大部分是做语法转换的,我们在编写一个插件之前最好看看这些插件是怎么写的。
如果您觉得有所收获,就请点个赞吧!