Visitor(访问者)
当 Babel 处理一个节点时,是以访问者的形式获取节点信息,并进行相关操作,这种方式是通过一个 visitor 对象来完成的。
在 visitor 对象中定义了对于各种节点的访问函数,这样就可以针对不同的节点做出不同的处理。
我们编写的 Babel 插件其实也是通过定义一个实例化 visitor 对象处理一系列的 AST 节点,来完成我们对代码的修改操作。
例子
举个栗子:我们想要处理代码中用来加载模块的import命令语句
1 | import { Ajax } from '../lib/utils'; |
当把这个插件用于遍历中时,每当处理到一个 import 语句,即 ImportDeclaration 节点时,都会自动调用ImportDeclaration() 方法,这个方法中定义了处理 import 语句的具体操作。
值得注意的是,AST的遍历采用深度优先遍历。所以当创建访问者时实际上有两次机会来访问一个节点。
1 | ─ Program.enter() |
Path(路径)
从上面的visitor对象中,可以看到每次访问节点方法时,都会传入一个 path 参数。
Path 是表示两个节点之间连接的对象。
在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。
当你调用一个修改树的方法后,路径信息也会被更新。
Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。
这个对象不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path对象包含的属性和方法主要如下:
1 | ── 属性 |
具体的可以查看babel-traverse。
例子
继续上面的例子,看看 path 参数的 node 属性包含哪些信息:
1 | visitor: { |
打印结果如下:
1 | Node { |
State(状态)
State 是 visitor 对象中每次访问节点方法时传入的第二个参数。
简单来说,state 就是一系列状态的集合,包含诸如当前 plugin 的信息、plugin 传入的配置参数信息,甚至当前节点的 path 信息也能获取到,当然也可以把 babel 插件处理过程中的自定义状态存储到state对象中。
副作用的处理
实际上访问者的工作比我们想象的要复杂的多,上面示范的是静态 AST 的遍历过程。而 AST 转换本身是有副作用的,比如插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 代码如下。
1 | traverse(ast, { |
上面的代码, 将console.log('hello' + v + '!')
语句替换为return "hello" + v;
, 下图是遍历的过程:
我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点… 当这些操作’污染’了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果。
Scopes(作用域)
这里的作用域其实跟 js 说的作用域是一个道理,也就是说 babel 在处理 AST 时也需要考虑作用域的问题,比如函数内外的同名变量需要区分开来。
例子
举一个栗子:比如你要将 add
函数的第一个参数 foo
标识符修改为a
,就需要递归遍历子树,查出foo
标识符的所有引用
, 然后替换它:
1 | const a = 1, b = 2 |
然后就发现,替换成 a
之后, console.log(a, b)
的行为就被破坏了。所以这里不能用 a
,得换个标识符, 譬如c
。这里需要借助 Scope 对象来处理。
Scope 对象
在Babel中,使用Scope
对象来表示作用域。 我们可以通过Path对象的scope
字段来获取当前节点的Scope
对象。它的结构如下:
1 | { |
Scope 对象和 Path 对象差不多,它包含了作用域之间的关联关系(通过 parent 指向父作用域),收集了作用域下面的所有绑定(bindings), 另外还提供了丰富的方法来对作用域仅限操作。
bindings 属性
我们可以通过 bindings 属性获取当前作用域下的所有绑定(即标识符),每个绑定由 Binding 类来表示:
1 | export class Binding { |
例子2
有了 Scope
和 Binding
, 现在有能力实现安全的变量重命名转换了。 为了更好地展示作用域交互,在上面代码的基础上,我们再增加一下难度:
1 | const a = 1, b = 2 |
现在要重命名函数参数 foo
, 不仅要考虑外部的作用域
, 也要考虑下级作用域
的绑定情况,确保这两者都不冲突。
上面的代码作用域和标识符引用情况如下图所示:
1 | // 用于获取唯一的标识符 |
上面的例子虽然没有什么实用性,而且还有Bug(没考虑label
),但是正好可以揭示了作用域处理的复杂性。
generateUid 方法
Scope 对象提供了一个generateUid方法来生成唯一的、不冲突的标识符。
1 | traverse(ast, { |
应用
作用域操作最典型的场景是代码压缩,代码压缩会对变量名、函数名等进行压缩… 然而实际上很少的插件场景需要跟作用域进行复杂的交互。