# Babel 插件
babel的插件有两种,一种是语法插件
,这类插件是在解析阶段
辅助解析器工作;另一类插件是转译插件
,这类插件是在转换阶段
参与进行代码的转译工作,这也是我们使用babel最常见也最本质的需求
# 插件原理
为了开发babel插件,我们必须先要了解babel的插件设计模式,想象一下,Babel 有那么多插件,如果每个插件自己去遍历AST,对不同的节点进行不同的操作,维护自己的状态。这样子不仅低效,它们的逻辑分散在各处,会让整个系统变得难以理解和调试, 最后插件之间关系就纠缠不清,乱成一锅粥,所以转换器操作 AST 一般都是使用访问器模式。同理,babel插件设计也采用访问者模式
# 访问者模式
由一个访问者(Visitor)来进行统一的遍历操作,提供节点的操作方法,响应式维护节点之间的关系; 而插件(设计模式中称为"具体访问者")只需要定义自己感兴趣的节点类型,当访问者访问到对应节点时,就调用插件的访问(visit)方法
Babel在遍历时以深度优先
的顺序,或者说递归地对 AST 进行遍历。
# Visitor 节点的遍历
当Babel处理一个节点时,是以访问者的形式获取节点信息,并进行相关操作,这种方式是通过一个visitor对象来完成的,在visitor对象中定义了对于各种节点的访问函数,这样就可以针对不同的节点做出不同的处理。我们编写的Babel插件其实也是通过定义一个实例化visitor对象处理一系列的AST节点来完成我们对代码的修改操作
我们想要处理代码中用来加载模块的import命令语句
import { Ajax } from '../lib/utils';
那么我们的Babel插件就需要定义这样的一个visitor对象:
visitor: {
Program: {
enter(path, state) {
console.log('start processing this module...');
},
exit(path, state) {
console.log('end processing this module!');
}
},
ImportDeclaration(path, state) {
console.log('processing ImportDeclaration...');
// do something
}
}
当把这个插件用于遍历中时,每当处理到一个import语句,即ImportDeclaration节点时,都会自动调用ImportDeclaration()方法,这个方法中定义了处理import语句的具体操作。ImportDeclaration()都是在进入enter()
ImportDeclaration节点时调用的,我们也可以让插件在退出节点时调用exit()
方法进行处理
visitor: {
ImportDeclaration: {
enter(path, state) {
console.log('start processing ImportDeclaration...');
// do something
},
exit(path, state) {
console.log('end processing ImportDeclaration!');
// do something
}
},
}
当进入ImportDeclaration节点时调用enter()方法,退出ImportDeclaration节点时调用exit()方法。上面的Program节点(Program节点可以通俗地解释为一个模块节点)也是一样的道理。值得注意的是,AST的遍历采用深度优先遍历,所以上述import代码块的AST遍历的过程如下
─ Program.enter()
─ ImportDeclaration.enter()
─ ImportDeclaration.exit()
─ Program.exit()
同理还有其他的节点定义:
traverse(ast, {
// 访问标识符
Identifier(path) {
console.log(`enter Identifier`)
},
// 访问调用表达式
CallExpression(path) {
console.log(`enter CallExpression`)
},
// 上面是enter的简写,如果要处理exit,也可以这样
// 二元操作符
BinaryExpression: {
enter(path) {},
exit(path) {},
},
// 更高级的, 使用同一个方法访问多种类型的节点
"ExportNamedDeclaration|Flow"(path) {}
})
有关AST中各种节点类型的定义:
- babylon 老 (opens new window)
- babel parser 新 (opens new window)
- babel parser 新typescript (opens new window)
# Path 节点的上下文
从上面的visitor对象中,可以看到每次访问节点方法时,都会传入一个path参数,这个path参数中包含了节点的信息以及节点和所在的位置,以供对特定节点进行操作。具体来说Path 是表示两个节点之间连接的对象。这个对象不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path对象包含的属性和方法主要如下:
── 属性
- node 当前节点
- parent 父节点
- parentPath 父path
- scope 作用域
- context 上下文
- ...
── 方法
- get 当前节点
- findParent 向父节点搜寻节点
- getSibling 获取兄弟节点
- replaceWith 用AST节点替换该节点
- replaceWithMultiple 用多个AST节点替换该节点
- insertBefore 在节点前插入节点
- insertAfter 在节点后插入节点
- remove 删除节点
- ...
这里我们继续上面的例子,看看path参数的node属性包含哪些信息:
visitor: {
ImportDeclaration(path, state) {
console.log(path.node);
// do something
}
}
打印结果如下
{
type: 'ImportDeclaration',
start: 5,
end: 41,
loc: SourceLocation {
start: Position { line: 2, column: 4 },
end: Position { line: 2, column: 40 }
},
specifiers: [
Node {
type: 'ImportSpecifier',
start: 14,
end: 18,
loc: [SourceLocation],
imported: [Node],
local: [Node]
}
],
source: Node {
type: 'StringLiteral',
start: 26,
end: 40,
loc: SourceLocation {
start: [Position],
end: [Position]
},
extra: { rawValue: '../lib/utils', raw: '\'../lib/utils\'' },
value: '../lib/utils'
}
}
可以发现除了type、start、end、loc这些常规字段,ImportDeclaration节点还有specifiers和source这两个特殊字段,specifiers表示import导入的变量组成的节点数组,source表示导出模块的来源节点。这里再说一下specifier中的imported和local字段,imported表示从导出模块导出的变量,local表示导入后当前模块的变量,还是有点费解,我们把import命令语句修改一下
import { Ajax as ajax } from '../lib/utils';
然后继续打印specifiers第一个元素的local和imported字段:
Node {
type: 'Identifier',
start: 22,
end: 26,
loc: SourceLocation {
start: Position { line: 2, column: 21 },
end: Position { line: 2, column: 25 },
identifierName: 'ajax' },
name: 'ajax'
}
Node {
type: 'Identifier',
start: 14,
end: 18,
loc: SourceLocation {
start: Position { line: 2, column: 13 },
end: Position { line: 2, column: 17 },
identifierName: 'Ajax' },
name: 'Ajax'
}
这样就很明显了。如果不使用as关键字,那么imported和local就是表示同一个变量的节点了。
# State
State是visitor对象中每次访问节点方法时传入的第二个参数。如果看Babel手册里的解释,可能还是有点困惑,简单来说,state就是一系列状态的集合,包含诸如当前plugin的信息、plugin传入的配置参数信息,甚至当前节点的path信息也能获取到,当然也可以把babel插件处理过程中的自定义状态存储到state对象中
# Scopes(作用域)
这里的作用域其实跟js说的作用域是一个道理,也就是说babel在处理AST时也需要考虑作用域的问题,比如函数内外的同名变量需要区分开来,这里直接拿Babel手册里的一个例子解释一下。考虑下列代码
function square(n) {
return n * n;
}
我们来写一个把 n 重命名为 x 的visitor。
visitor: {
FunctionDeclaration(path) {
const param = path.node.params[0];
paramName = param.name;
param.name = "x";
},
Identifier(path) {
if (path.node.name === paramName) {
path.node.name = "x";
}
}
}
对上面的例子代码这段访问者代码也许能工作,但它很容易被打破
function square(n) {
return n * n;
}
var n = 1;
上面的visitor会把函数square外的n变量替换成x,这显然不是我们期望的。更好的处理方式是使用递归,把一个访问者放进另外一个访问者里面
visitor: {
FunctionDeclaration(path) {
const updateParamNameVisitor = {
Identifier(path) {
if (path.node.name === this.paramName) {
path.node.name = "x";
}
}
};
const param = path.node.params[0];
paramName = param.name;
param.name = "x";
path.traverse(updateParamNameVisitor, { paramName });
},
}
# 副作用
AST 转换本身是有副作用的,比如插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点,当这些操作'污染'了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果。
# 插件顺序
Babel 会按照插件定义的顺序来应用访问方法,比如你注册了多个插件,babel-core 最后传递给访问器的数据结构大概长这样:
{
Identifier: {
enter: [plugin-xx, plugin-yy,] // 数组形式
}
}
当进入一个节点时,这些插件会按照注册的顺序
(从前到后)被执行。大部分插件是不需要开发者关心定义的顺序的,有少数的情况需要稍微注意以下,例如plugin-proposal-decorators
:
{
"plugins": [
"@babel/plugin-proposal-decorators", // 必须在plugin-proposal-class-properties之前
"@babel/plugin-proposal-class-properties"
]
}
所有插件定义的顺序,按照惯例,应该是新的或者说实验性的插件在前面,老的插件定义在后面。因为可能需要新的插件将 AST 转换后,老的插件才能识别语法(向后兼容)。下面是官方配置例子, 为了确保先后兼容,stage-*
阶段的插件先执行
{ "presets": ["es2015", "react", "stage-2"] }
注意Preset的执行顺序相反(从后向前),详见官方文档 (opens new window)
- Plugins run before Presets. //插件在Presets运行前执行
- Plugin ordering is first to last. //插件按定义顺序执行
- Preset ordering is reversed (last to first). //Preset 按定义顺序反向执行