我们在如何写一个 Babel 插件 中,了解了 Babel 插件的写法。Babel 插件处理的对象是 AST,那 Babel 究竟是如何调用插件的,插件是如何生效的呢。在分析 Babel 调用插件的代码时,会顺带解决以下几个问题。
- Babel 插件的准备和收尾函数。
- 多个插件是如何合并成一个的。
- AST 遍历方式。
- 插件的访问者模式如何实现的。
- 插件的 path 对象是怎么来的。
Babel 插件调用
Babel 在完成 AST 编译后,会调用插件对 AST 做修改,调用的方法就是 transformFile。transformFile 方法的入参是用户配置的所有插件集合,该方法将插件调用分为了四个阶段。
- 遍历插件集合,执行插件的 pre 方法。
- 遍历插件集合,合并插件的 visitor 方法,输出是一个包含了所有插件逻辑的 visitor 方法。
- 执行第二步合成的 visitor 方法。
- 遍历插件集合,执行插件的 post 方法。
babel-core\src\transformation\index.js
function* transformFile(file: File, pluginPasses: PluginPasses): Handler<void> {
for (const pluginPairs of pluginPasses) {
...
// 执行插件的 pre 方法
for (const [plugin, pass] of passPairs) {
const fn = plugin.pre;
if (fn) {
const result = fn.call(pass, file);
...
}
}
// 插件合并
const visitor = traverse.visitors.merge(
visitors,
passes,
file.opts.wrapPluginVisitorMethod,
);
// 执行插件 visitor 中定义的方法
traverse(file.ast, visitor, file.scope);
// 执行插件 post 方法
for (const [plugin, pass] of passPairs) {
const fn = plugin.post;
if (fn) {
const result = fn.call(pass, file);
...
}
}
}
}
从这段代码中我们可以发现,Babel 插件遵循这样的一个执行顺序,pre -> visitor -> post。而且比较特殊的是,pre 和 post 是标准的方法,而 visitor 是个对象,这主要是由于 visitor 的特殊设计方法决定的。
pre 和 post 的入参只有 state,这也决定了这两个方法是无法改变 AST 结构的,pre 和 post 方法的用法就比较单一了,一般在 pre 中创建一些临时对象, post 中再将这些对象销毁掉。。
export default function({ types: t }) {
return {
pre(state) {
this.cache = new Map();
},
visitor: {
StringLiteral(path) {
this.cache.set(path.node.value, 1);
}
},
post(state) {
console.log(this.cache);
}
};
}
Babel 插件集合在执行之前做合并,主要是为了提高后续的处理效率。Babel 的插件集合来自于 Babel 读取配置文件(.babelrc)中的 presets 和 plugins 配置项。
之前Babel 编译流程分析 文章中分析过,Babel 会读取配置文件(.babelrc)中的 presets 和 plugins 配置项,根据配置项,依次读取插件的代码,将其包装为一个数组,数组中的每一项都是一个插件对象,包括插件的名称,配置项,pre 和 post 生命周期方法,以及最重要的 visitor 对象,visitor 中定义了该插件遇到不同类型节点的处理方法。
我们如果在 .babelrc 中配置了 preset-env。那么会引入 40 个插件用于处理新语法。
"presets": ["@babel/preset-env"]
以下是插件集合的一部分,transform-block-scoping 插件用于转换 const 和 let, transform-arrow-functions 插件用于编译箭头函数。
[
{
key:"transform-block-scoping",
options: {...},
post: undefined,
pre: undefined,
visitor: {
BlockStatement: {...},
DoWhileStatement: {...},
ForInStatement: {...},
ForOfStatement: {...},
VariableDeclaration: {...}
...
}
},
{
key:"transform-arrow-functions",
options: {...},
post: undefined,
pre: undefined,
visitor: {
ArrowFunctionExpression: {...}
}
}
...
]
Babel 在 AST 的遍历过程中,会遇到不同类型的节点,不同的插件中可能会对同一种节点做处理,Babel 为了提升处理效率,会一次性获得插件对于该节点的处理逻辑,这也是 Babel 对插件的 visitor 对象做合并的原因。合并的原则是对于相同类型的节点,将处理方法组合成一个数组,当遇到该类型节点的时候,一次执行处理方法,traverse.visitors.merge 的逻辑较为简单,下面列出合并后 visitor 的数据结构。
{
ArrowFunctionExpression: {
enter: [...]
},
BlockStatement: {
enter: [...],
exit: [...]
},
DoWhileStatement: {
enter: [...]
}
}
合并后的 visitor 对象,和单个 visitor 的区别在于每个节点的处理函数变成了一个数组,该数组是由之前的不同插件中同种处理函数组成的。
AST 遍历方式
接下来就是执行 traverse 方法,该方法由 babel-traverse 包定义。
import traverse from "@babel/traverse";
traverse(file.ast, visitor, file.scope);
在看 traverse 方法之前,我们先了解 AST 的遍历方式,AST 是一个多叉树,Babel 采用递归的方式对其进行深度优先遍历。类似于二叉树的先序遍历。
如图是模拟的一个 AST 结构,程序会访问根结点 A,然后递归访问 B,C。我们在递归调用的过程中,递归条件是该节点有子节点,如果没有子节点则返回。Babel 为了提供更灵活的配置方式,将访问阶段分为 enter 和 exit。enter 代表程序进入这个节点,也就是入栈,exit 表示程序退出节点。很少有插件的行为必须在 exit 阶段执行,所以我们在插件中定义的 Identifier() { ... } 其实是 Identifier: { enter() { ... } } 的简写形式。
我们在图中有箭头标出了每个节点的 enter 和 exit 阶段。红色的箭头代表 enter,绿色的箭头代表 exit。数字表示每个阶段的触发顺序。以节点 B 为例。阶段 1 表示访问节点 B,如果节点 B 的类型满足插件的执行条件,那此时执行插件中 visitor 对象的 enter 方法,阶段 8 表示离开节点 B,执行插件中 visitor 对象的 exit 方法。
了解了遍历方式后,回过头来看 traverse 方法,traverse 会调用 traverse.node 方法。
packages/babel-traverse/src/index.js
export default function traverse(
parent: Object | Array<Object>,
opts?: Object,
scope?: Object,
state: Object,
parentPath: Object,
) {
...
traverse.node(parent, opts, scope, state, parentPath);
}
我们先忽略中间一系列的调用方法,节点最终会调用到 visit 方法。visit 方法是执行插件调用的入口,首先是执行插件 enter 方法,接着就是递归调用 traverse.node 方法,处理子节点,子节点处理完毕后,执行插件 exit 方法。
packages/babel-traverse/src/path/context.js
export function visit(): boolean {
...
// 执行插件 enter 方法
if (this.shouldSkip || this.call("enter") || this.shouldSkip) {
return this.shouldStop;
}
// 递归调用 traverse.node
traverse.node(
this.node,
this.opts,
this.scope,
this.state,
this,
this.skipKeys,
);
// 执行插件 exit 方法
this.call("exit");
return this.shouldStop;
}
了解 Babel 遍历 AST 的方法后,我们看下 Babel 是如何在遍历的过程中执行插件的。
访问者模式
我们写 Babel 插件的时候,最重要的就是要定义一个 visitor 对象。 这是因为 Babel 插件的设计遵循访问者(Visitor)模式。什么是访问者模式呢?简单来说,访问者模式是能把处理方法从数据结构中分离出来的一种模式,这种模式的好处就是可以根据需求增加新的处理方法,且不用修改原来的程序代码与数据结构。
我们拿 Babel 来说,AST 结构基本上是固定的,但是插件确是多变的,特别是 Babel 还希望用户可以自定义插件来做一些定制化的需求。在这种情况下,对于 AST 的操作就一定需要独立出去。这就是所谓的处理方式从数据结构中抽离。访问者模式中有两个比较重要的对象,访问者以及被访问的对象。我们可以认为,每个插件都是访问者,被访问的对象则是 AST 节点。由于 访问者并不需要访问整个 AST,有时候仅需访问特定节点,Babel 中定义一个控制器用于限制访问行为。
我们接着之前的 traverse.node 方法分析。该方法中新建了一个 TraversalContext 对象,并且将插件的调用委托给了该对象。
packages/babel-traverse/src/index.js
traverse.node = function (
node: Object,
opts: Object,
scope: Object,
state: Object,
parentPath: Object,
skipKeys?,
) {
// VISITOR_KEYS 中存储了所有节点的属性
const keys: Array = t.VISITOR_KEYS[node.type];
if (!keys) return;
const context = new TraversalContext(scope, opts, state, parentPath);
for (const key of keys) {
if (skipKeys && skipKeys[key]) continue;
if (context.visit(node, key)) return;
}
};
TraversalContext 对象就是 Babel 定义的控制器。从名字也可以看出,是用于管理遍历上下文的对象。
export default class TraversalContext {
constructor(scope, opts, state, parentPath) {...}
parentPath: NodePath;
scope;
state;
opts;
queue: ?Array<NodePath> = null;
shouldVisit(node): boolean {...}
create(node, obj, key, listKey): NodePath {...}
maybeQueue(path, notPriority?: boolean) {...}
visitMultiple(container, parent, listKey) {...}
visitSingle(node, key): boolean {...}
visitQueue(queue: Array<NodePath>) {...}
visit(node, key) {...}
}
当我们遍历到某个 AST 节点的时候,其实并不是每个节点都会遍历到,因为该控制器会判断某个节点是否需要访问。主要根据两个来判断,首先判断是否有插件对该类型节点有修改,如果有的话,那就进入该节点。其次判断该节点是否有子节点,如果没有子节点,那就不需要进入该节点了。也就是说,AST 树的所有非叶子节点都会被遍历到,而叶子节点如果不是插件关注的节点,是不会被访问到的。这么做可以减少很多不必要的判断逻辑,提升遍历性能。
shouldVisit(node): boolean {
const opts = this.opts;
if (opts.enter || opts.exit) return true;
// check if we have a visitor for this node
if (opts[node.type]) return true;
// check if we're going to traverse into this node
const keys: ?Array<string> = t.VISITOR_KEYS[node.type];
if (!keys?.length) return false;
// we need to traverse into this node so ensure that it has children to traverse into!
for (const key of keys) {
if (node[key]) return true;
}
return false;
}
TraversalContext 提供了多种 visit 方法,区别在于节点是数组还是单个对象,最终都会调用到 visitQueue 方法。
this.visitQueue([this.create(node, node, key)]);
这里有个关键点,那就是 visitQueue 的入参是一个由 NodePath 组成的 queue(其实就是数组)。NodePath 就是根据当前 AST 结构生成的 path 对象,也是每个插件的 visitor 对象的入参。path 执行的 visit 方法,递归调用了 traverse.node 方法,这些我们在上文中讲 AST 遍历的时候讲过。
visitQueue(queue: Array<NodePath>) {
this.queue = queue;
...
for (const path of queue) {
...
if (path.visit()) {
stop = true;
break;
}
...
}
...
}
Babel 访问者模式的结构也就清晰了,Babel 插件作为访问者,定义了作用在被访问者上的操作。TraversalContext 负责控制整个访问的上下文环境,串起了整个访问的流程。NodePath 对象是根据当前 AST 节点生成的被访问者,通过 NodePath,我们不仅可以获得当前的 AST 对象,还能获得 Babel 为我们提供的很多工具方法,方便我们修改 AST 结构。
NodePath
TraversalContext 在调用 visitQueue 方法之前,生成了 NodePath 对象对列作为入参。我们现在看下生成 NodePath 的 create 方法。
create(node, obj, key, listKey): NodePath {
return NodePath.get({
parentPath: this.parentPath,
parent: node,
container: obj,
key: key,
listKey,
});
}
create 方法调用了 NodePath 的静态方法 get 来生成 NodePath 对象,NodePath 由于对象的成员变量比较多,初始化代码也很长。我们在如何写一个 Babel 插件 中也看过 NodePath 的数据结构,就不详细看构造逻辑了。
export default class NodePath {
constructor(hub: HubInterface, parent: Object) {...}
parent: Object;
hub: HubInterface;
contexts: Array<TraversalContext>;
data: Object;
shouldSkip: boolean;
shouldStop: boolean;
removed: boolean;
state: any;
opts: ?Object;
_traverseFlags: number;
skipKeys: ?Object;
parentPath: ?NodePath;
context: TraversalContext;
container: ?Object | Array<Object>;
listKey: ?string;
key: ?string;
node: ?Object;
scope: Scope;
type: ?string;
static get({ hub, parentPath, parent, container, listKey, key }): NodePath {...}
getScope(scope: Scope) {...}
setData(key: string, val: any): any {...}
getData(key: string, def?: any): any {...}
traverse(visitor: Object, state?: any) {...}
...
}
NodePath 本身定义了一些成员变量,以及少量的方法。NodePath 会作为参数传递给 Babel 插件,Babel 插件修改 NodePath 的结构,一切看似很完美。有个致命的问题就是 AST 的操作不是那么简单的,自己写很用以出错,于是 Babel 提供了巨量的工具函数,并将其绑在了 NodePath 上。
如下,NodePath 集成了 11 个工具类。通过这些工具类我们可以做很多事情,比如 NodePath_ancestry 用于向上查找路径上满足条件的节点。NodePath_removal 用于删除某个节点。NodePath_modification 用于插入或编辑某个节点。
Object.assign(
NodePath.prototype,
NodePath_ancestry,
NodePath_inference,
NodePath_replacement,
NodePath_evaluation,
NodePath_conversion,
NodePath_introspection,
NodePath_context,
NodePath_removal,
NodePath_modification,
NodePath_family,
NodePath_comments,
);
不仅如此,NodePath 还集成了 types 中所有用于判断,校验节点类型的方法。
for (const type of (t.TYPES: Array<string>)) {
const typeKey = `is${type}`;
const fn = t[typeKey];
NodePath.prototype[typeKey] = function (opts) {
return fn(this.node, opts);
};
NodePath.prototype[`assert${type}`] = function (opts) {
if (!fn(this.node, opts)) {
throw new TypeError(`Expected node path of type ${type}`);
}
};
}
for (const type of Object.keys(virtualTypes)) {
if (type[0] === "_") continue;
if (t.TYPES.indexOf(type) < 0) t.TYPES.push(type);
const virtualType = virtualTypes[type];
NodePath.prototype[`is${type}`] = function (opts) {
return virtualType.checkPath(this, opts);
};
}
如此巨无霸的一个对象,我们可以依赖它完成任何工作。但从设计模式的角度来将,访问者可以获得所有的被访问者的方法和数据,这其实违背了迪米特法则。这也是访问者模式的弊端之一。 Babel 将该模式用在插件上,最重要的还是看中了它能分离处理方法和数据结构的特点,毕竟插件最重要的就是扩展性。
总结
-
Babel 插件遵循这样的执行顺序,pre -> visitor -> post,为了提高执行效率,Babel 将多个插件的 visitor 对象合并成为了一个 visitor 对象,方便后续的判断以及执行。
-
对于多个插件中 visitor 组成的数组,Babel 是这样合并的,以节点类型作为 key,处理方法作为 value,由于多个插件会对同一种节点做处理,所以 value 是个数组。这样合并的好处有两点,第一是在遍历 AST 节点的时候,能够快速判断是否存在该节点的处理函数,第二是能保证单个节点上插件的执行顺序。
-
AST 遍历就是深度递归遍历,比较特殊的是,Babel 将这个阶段做了 enter 和 exit 的区分,默认使用 enter 阶段。
-
Babel 插件的设计遵循访问者模式,插件就是一个个的访问者。TraversalContext 负责控制整个访问的上下文环境,串起了整个访问的流程。NodePath 对象是根据当前 AST 节点生成的被访问者。
-
NodePath 作为被访问者,是一个集合了多种功能的大对象,不仅包含了本身的一些成员变量,还包含了很对方便用户判断,操作 AST 的函数,该对象由 TraversalContext 创建,作为访问者的入参,交由访问者控制。
Babel 插件的设计思路并不复杂,本质是在一个如何能够灵活变更数据结构的问题,Babel 的方式是,使用一个对象控制数据的遍历,使用访问者模式,以插件的形式将数据的变更抽离为一个个单独的程序。Babel 在初始化的时候读取这些插件。理解了这些,我们写 Babel 插件的时候会更加了解我们的代码是何时生效的,又是如何生效的。以后在遇到这种结构基本不变,但是处理方式经常变的的情形时,可以参考 Bable 插件的设计方式,相信会有所帮助。
如果您觉得有所收获,就请点个赞吧!