如何手写一个符合需求的Babel插件
作为一个强大的多功能JS转译器,Babel有许许多多的模块可用于静态分析。而在项目中,有时候我们也需要创建一个符合项目需求的Babel插件。本文旨在帮助大家深入理解Babel插件工作流程的同时,让大家掌握如何按需求编写自己的Babel插件。
一、基础知识
1. Babel对代码处理分为三个步骤:
解析(parse)、转换(transform)、生成(generate) 。
2. 解析
在 解析 这一步,Babel接收代码,处理后生成 AST 。该步骤分为 词法分析(Lexical Analysis) 和 语法分析(Syntactic Analysis) 两个阶段。
2.1 词法分析
Babel接收的源代码为代码字符串,词法分析会把字符串代码转化为 令牌(tokens) 流,令牌是对代码词法的描述。可以看作一个扁平的语法片段数组。例如,对于以下语法片段:
1 | n * n; |
将被转化为如下令牌,可以看到,除了具有type和value属性以外,令牌还和AST一样具有start, end, loc属性。
1 | [ |
其中,每个type都有一组属性来描述该令牌:
1 | { |
2.2 语法分析
词法分析将字符串代码转化为tokens,语法分析则是根据tokens生成AST的表述结构,这样更易于进行后续操作。
3. 转换
转换 步骤接收AST并对其进行遍历,这个过程中主要对节点进行增删改,是编译器最为复杂的过程,也是插件将要介入工作的部分。本文的主要内容也将围绕这个部分展开。
4. 生成
生成 的步骤相对简单,主要是对 经过一系列转化得到的最终 AST 进行 深度优先遍历 来生成字符串形式的代码,并创建 源码映射(source maps) 。
5. 抽象语法树 AST
Babel的处理过程每一步都涉及创建或操作AST,使用的是基于ESTree并修改过的AST。例如对于以下程序片段:
1 | function square(n) { |
会被处理为如下AST:
1 | - FunctionDeclaration: |
或者以JS对象来表示:
1 | { |
可以看到,不同的层级各自具有一致的结构:
1 | { |
1 | { |
1 | { |
这样的每一层结构叫做 节点(Node) 。一个AST可以由一个或许多个节点构成。这些节点组合在一起可用以描述用于静态分析的程序语法。而每一个节点都有如下接口:
1 | interface Node { |
string形式的type字段表示节点的类型,如"FunctionDeclaration", "Identifier", "BinaryExpression" 等。且每种类型的节点,又定义了一些附加属性来进一步描述该节点。此外,Babel还为每个节点生成了额外属性(start, end, loc 等),用于描述该节点在原始代码中的位置,每个节点都有这些属性。
1 | { |
6. 遍历
要想转换AST,则需要对AST进行递归遍历。假如有一个FunctionDeclaration类型,它有如下几个属性:id, params, body,且每个属性都有一些内嵌节点。
1 | { |
按照如下顺序遍历以上AST:
- 我们从
FunctionDeclaration开始依次访问每一个属性及其子节点; - 随后来带
id,是个Identifier,且它没有任何子节点属性; - 于是继续访问下一个属性
params,而params是一个数组节点,因此要遍历访问其中的每一个,它们都是Identifier类型的单一节点; - 继续下去,变来到了
body,是个BlockStatement,且内部还有个body节点,内部的body节点是个数组节点,因此我们遍历访问其中的每一个; - 而在这里,内部的
body节点唯一的子节点是个ReturnStatement,它有个argument,访问这个argument便找到了BinaryExpression。 BinaryExpression有一个operator,一个left,一个right。operator只是一个值而不是一个节点,因此,我们只需要访问left和right。
7. Visitors (访问者)
说到 “进入” 一个节点,实际上是说我们在 访问 他们。这个术语出自 访问者模式(visitor) 的概念。访问者是一个用于AST遍历的跨语言的模式。简单的说,他们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。例如:
1 | // 访问者 |
这便是一个极为简单的访问者,每当在AST中遇到Identifier时,就会调用Identifier方法。如果将这个访问者应用到如下代码中,Identifier方法便会执行4次(square和3个n各一次):
1 | function square(n) { |
1 | path.traverse(MyVisitor); |
这些调用都发生在 进入 节点时,有时候我们也可以在 退出 节点时调用访问者方法。例如对于如下树状结构:
1 | - FunctionDeclaration |
当向下遍历每一个分支时,会走到该分支的尽头,此时,便要向上遍历回去,以便访问下一个节点。向下遍历时我们 进入(enter) 每个节点,而向上遍历时我们 退出(exit) 每个节点。以遍历上面的树为例:
进入
FunctionDeclaration- 进入
Identifier (id) - 走到尽头
退出
Identifier (id)进入
Identifier (params[0])走到尽头
退出
Identifier (params[0])进入
BlockStatement (body)进入
ReturnStatement (body)进入
BinaryExpression (argument)- 进入
Identifier (left) - 走到尽头
- 退出
Identifier (left) - 进入
Identifier (right) - 走到尽头
- 退出
Identifier (right)
- 进入
退出
BinaryExpression (argument)
退出
ReturnStatement (body)
退出
BlockStatement (body)
- 进入
退出
FunctionDeclarati
因此,当创建访问者时,我们实际上有两次机会来访问一个节点,分别是 进入(enter) 和 退出(exit):
1 | const MyVisitor = { |
而我们之前写的Identifier() { ... } 则是 Identifier: { enter() { ... } } 的简写形式,表示在进入时访问节点。
在有需要的情况下,也可以使用|来把方法名分隔为Idenfifier|MemberExpression形式的字符串,把同一个函数应用到多个访问节点。注意此时需要给方法名加上引号:
1 | const MyVisitor = { |
也可以使用别名作为方法名,例如Function是FunctionDeclaration、FunctionExpression、ArrowFunctionExpression、ObjectMethod和ClassMethod的别名。
1 | const MyVisitor = { |
8. Paths 路径
现在我们知道了,AST中有许多的节点。那么如何去把节点进行相互关联呢?我们可以使用一个巨大的可操作对象来描述节点之间的关系,但是显然这会比较麻烦。而使用 路径(Paths) 便能够解决这个问题。
Path是一个对象,用来描述两个节点之间的连接。例如,对于以下节点和其子节点:
1 | { |
将子节点表示为一个路径(Path):
1 | { |
同时,它还会包含该路径的其它元数据:
1 | { |
此外,路径对象上还包含了增删、更新、移动等许多其它方法。在某种意义上说,路径是一个节点在AST中的位置以及和节点相关的信息的响应式(Reactive)表示。每当调用了修改树的方法,相关路径信息也会随之更新,而这些都由Babel管理,使得我们操作节点更加简单,尽可能地做到无状态。
Path in Visitors 存在于访问者中的路径
当我们有一个Identifier()方法的Visitor时,实际上我们访问的是路径,而不是节点。通过这种方式,我们操作的就是节点的响应式表示(即路径),而非节点本身。
1 | const MyVisitor = { |
1 | a + b + c; |
1 | path.traverse(MyVisitor); |
9. State 状态
对于AST而言,状态管理是极其麻烦的,往往会有一些未考虑到的语法来推翻我们之前对状态的假设。例如,对于以下代码:
1 | function square(n) { |
如果我们要写一个把n重命名为x的实现:
1 | let paramName; |
以上访问者可以将函数参数中的n重命名为x,且之后所有名为n的Identifier,都将重命名为x。确实可以让square函数中的n重命名为x,但是也会出现预料之外的情况,因为全局的名字为n的Identifier都被会污染:
1 | function square(n) { |
因此,给访问者添加方法时,最好使用递归,以便消除对全局状态的影响。如下,可将一个Visitor放进另一个Visitor中,这时候的Identifier()只在MyVisitor的FunctionDeclaration中生效,不会造成全局污染。
1 | const updateParamNameVisitor = { |
10. Scopes 作用域
JS支持词法作用域,在树状嵌套结构中代码块创建出新的作用域。
1 | // global scope 全局作用域 |
在JS中,当一个引用被创建出来,无论是通过变量(variable),函数(function),类型(class),参数(params),模块导入(import) 还是 标签(label),它都属于当前作用域。更深的内部作用域可以使用其外部作用域中的引用,也可以创建和外部作用域同名的引用。当编写转换时,尤其需要注意作用域,确保在改变代码各个部分时不会破坏已经存在的代码。
- 当添加一个新的引用时,确保不会和已有的引用发生冲突;
- 查找使用某个引用的所有变量时,确保是在给定的作用域中进行;
作用域可以表示为如下形式:
1 | { |
每当创建一个新的作用域时,需要给出它的路径和父作用域。之后在遍历过程中它会收集所有的引用(“Bindings”),一旦收集完毕,就可以在作用域上使用各种方法,这些方法会在后续介绍。
Bindings 绑定
每个 引用 都属于特定的 作用域,引用 和 作用域 之间的这种关系被称为 绑定(binding)。单个绑定看起来如下所示:
1 | { |
根据这些信息,就可以查找到一个绑定的所有引用,以及该绑定的类型信息、所属作用域、是否是常量,或者拷贝它的标识符等等。
有些情况下,知道一个绑定是否是常量非常有帮助,最有用的一种情形就是压缩代码时:
1 | function scopeOne() { |
二、API
Babel是一组模块的集合,这里我们介绍一些主要模块的功能与使用。
1. babylon
Babylon是Babel的解析器。
- 安装
1 | npm install --save babylon |
先来试着使用Babylon的parse()方法,来解析一个简单的代码字符串:
1 | import * as babylon from "babylon"; |
parse()方法还能接收第二个参数,用于指示Babylon如何解析。
1 | babylon.parse(code, { |
sourceType 可以是 "module" 或者 "script",它表示 Babylon 应该用哪种模式来解析。 "module" 将会在严格模式下解析并且允许模块定义,"script" 则不会。默认值为"script",该模式下如果发现import或者export则会报错,需要指定scourceType: "module" 来避免这些错误。
Babylon使用了基于插件的架构,有一个plugins来控制内部插件的启用和关闭。对于详细的插件列表,可参考Babylon README文件。
2. babel-traverse
这个模块负责维护整个树的状态,且负责替换、移除、添加节点。
- 安装
1 | npm install --save babel-traverse |
可以将其和Babylon一起使用来遍历和更新节点:
1 | import * as babylon from "babylon"; |
3. babel-types
这是一个用于AST节点的工具库,包含了构造、验证以及变换AST节点的工具方法。
- 安装
1 | npm install --save babel-types |
- 使用
1 | import traverse from "babel-traverse"; |
3.1 Definitions 定义
每一个但一类型的节点都具有相应的定义,包括节点包含哪些属性,什么是合法值,如何构建节点、遍历节点,节点的别名信息等。单一类型节点的定义形式如下:
1 | defineType("BinaryExpression", { |
3.2 Builders 构建器
在上面的DefineType中,有一个builder字段:builder: ["operator", "left", "right"]。每一个节点类型都有构造器方法,按类似以下方式使用:
1 | // builder: ["operator", "left", "right"] |
创建的AST如下:
1 | { |
打印出来则是:a * b。
构建器还会验证自身创建的节点,并在错误使用的情形下抛出描述性错误。验证时使用了验证器方法。
3.3 Validators 验证器
BinaryExpression 的定义中包含了节点的字段 fields 信息,以及如何验证这些字段。
可以创建两种验证方法:
isX:
1 | t.isBinaryExpression(maybeBinaryExpressionNode); |
也可以传入第二个参数来确保节点包含特定的属性和值:
1 | t.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" }); |
assertX:
断言式校验版本,会抛出异常而不是返回 true 或 false。
1 | t.assertBinaryExpression(maybeBinaryExpressionNode); |
3.4 Converts 变换器
此处不做介绍。
4. babel-generator
这是Babel的代码生成器,负责读取AST并将其转化为代码和源码映射(source maps)。
- 安装
1 | npm install --save babel-generator |
- 使用
1 | import * as babylon from "babylon"; |
也可以通过第二个参数传递一些选项:
1 | generate(ast, { |
5. babel-template
这个模块能让我们编写字符串形式且带有占位符的代码来代替手动编码,在生成大规模AST的时候尤其有用。
- 安装
1 | npm install --save babel-template |
- 使用
1 | import template from "babel-template"; |
于是generate(ast).code便能得到:
1 | var myModule = require("my-module"); |
三. 编写第一个 Babel 插件
终于熟悉了Babel的基础知识了,现在可以愉快地写插件了。
先从一个接收当前babel对象作为参数的function开始:
1 | export default function(babel) { |
由于babel对象里常用的是babel.types,因此可以在参数中解构以方便使用:
1 | export default function({ types: t }) { |
然后返回一个对象,其中的visitor属性是这个插件的主要访问者:
1 | export default function({ types: t }) { |
visitor的每个方法都接收两个参数:path和state。
1 | export default function({ types: t }) { |
接下来完成一个简单的插件,来展示一下插件是如何工作的,假设要处理的源代码为:
1 | foo === bar; |
其AST形式如下:
1 | { |
可以看到,有BinaryExpression和Identifier两种节点。我们先从添加BinaryExpression访问者方法开始。
1 | export default function({ types: t }) { |
对于源代码foo === bar;,我们可以更加精确一点,只关注操作符为 === 的BinaryExpression。
1 | { |
现在,用新的标识符来替换left属性:
1 | BinaryExpression(path) { |
此时,如果运行这个插件,则会将源代码转换成:
1 | cc === bar; |
接下来,就剩下替换右边的标识符的工作了。
1 | BinaryExpression(path) { |
这时候运行这个插件,就能得到最终结果了:
1 | cc === yy; |
于是,轻松实现了第一个Babel插件!
四、转换操作
项目中需要用到的插件,应用场景往往没这么简单。因此,我们还得学会更多的操作,来使我们拥有能编写更强大的插件的能力。
1. 访问
1.1 获取子节点的 Path
要得到一个AST节点的属性值,我们一般先访问到该节点,再通过path.node.property即可。
1 | // BinaryExpression AST 节点 有三个属性: `left`, `right`, `operator` |
如果要访问到该属性内部的path,则需要用到path对象的get方法,传递该属性的字符串形式作为参数:
1 | BinaryExpression(path) { |
1.2 检查节点的类型
检查节点的类型可以通过path.node来进行,但是不建议这么做:
1 | BinaryExpression(path) { |
推荐的做法是使用babel-types模块提供的方法来进行检查:
1 | BinaryExpression(path) { |
1.3 检查路径类型
路径具有和babel-types提供的相同的检查方法:
1 | BinaryExpression(path) { |
这就相当于:
1 | BinaryExpression(path) { |
1.4 检查标识符(Identifier)是否被引用
1 | Identifier(path) { |
或者使用babel-types提供的能力:
1 | Identifier(path) { |
1.5 找到特定的父路径
有时候需要向上遍历AST,直到满足某些条件。
- 以下方法,对于每一个父路径调用
cb,当cb返回真值,则将其NodePath返回。
1 | path.findParent((path) => path.isObjectExpression()); |
- 如果是从当前节点开始遍历,则使用
find方法:
1 | path.find((path) => path.isObjectExpression()); |
- 查找最临近的父函数或者程序:
1 | path.getFunctionParent(); |
- 向上遍历语法树,知道找到在列表中的父节点路径:
1 | path.getStatementParent(); |
1.6 查找同级路径
当一个路径是在一个Function/Program中的列表里面,则它会有同级路径。
- 使用
path.inList可以判断是否有同级路径; - 使用
path.getSibling(index)来获得同级路径; - 使用
path.key获取路径在容器中的索引; - 使用
path.container获取路径的容器(包含所有同级节点的数组); - 使用
path.listKey获取容器的key。
在transform-merge-sibling-variables插件中使用到了这些API。
1.7 停止遍历
如果需要插件在某些情况下不继续运作,应该尽早返回。
1 | BinaryExpression(path) { |
如果是在顶级的path中进行子遍历,则可以通过path.skip或者path.stop来停止遍历。
path.skip:跳过当前路径的子遍历;path.stop:停止当前路径的遍历(包括子遍历)。
1 | outerPath.traverse({ |
2. 处理
在实际编写插件时,我们常需要进行各种增删改等处理。
2.1 替换一个节点
使用path.replaceWith来替换一个节点。
1 | BinaryExpression(path) { |
1 | function square(n) { |
2.2 用多节点来替换单节点
path.replaceWithMultiple方法用多节点来替换单个节点。注意,参数是声明数组。当用多个节点替换一个表达式时,它们必须是 声明。 这是因为Babel在更换节点时广泛使用启发式算法,这意味着您可以做一些非常疯狂的转换,否则将会非常冗长。
1 | ReturnStatement(path) { |
1 | function square(n) { |
2.3 用字符串源码替换节点
使用path.replaceWithSourceString,将直接用源码字符串来替换当前节点(不建议使用)。
1 | FunctionDeclaration(path) { |
1 | - function square(n) { |
2.4 插入兄弟节点
使用path.insertBefore和path.insertAfter来在前或后一个位置插入兄弟节点。如果是插入单个节点,则使用声明,如果是插入多个节点,则应使用声明数组。
1 | FunctionDeclaration(path) { |
1 | + "Because I'm easy come, easy go."; |
2.5 插入到容器中
在容器中插入节点和插入兄弟节点类似,只不过需要指定listKey。注意要使用声明。
1 | ClassMethod(path) { |
1 | class A { |
2.6 删除一个节点
使用path.remove即可轻松删除当前节点。
1 | FunctionDeclaration(path) { |
1 | - function square(n) { |
2.7 替换父节点
只需要获取到父节点的路径parentPath,然后调用replaceWith方法即可。
1 | BinaryExpression(path) { |
2.8 删除父节点
获取父节点并执行remove方法即可。
1 | BinaryExpression(path) { |
1 | function square(n) { |
2.9 处理作用域
path.scope.hasBinding():检查本地变量是否被绑定
1 | FunctionDeclaration(path) { |
这个方法会遍历范围树来检查特定的绑定。
path.scope.hasOwnBinding():检查一个作用域是否有自己的绑定。
1 | FunctionDeclaration(path) { |
path.scope.generateUidIdentifierBasedOnNode:创建UID。
1 | FunctionDeclaration(path) { |
path.scope.parent.push:提升变量声明到父级作用域。
1 | FunctionDeclaration(path) { |
1 | - function square(n) { |
path.scope.rename:重命名绑定及其引用。
1 | FunctionDeclaration(path) { |
1 | - function square(n) { |
也可以将绑定重命名为唯一的标识符:
1 | FunctionDeclaration(path) { |
1 | - function square(n) { |
五、插件选项
1. 插件选项的使用
接收插件选项,可以允许用户自定义插件的某些行为,如下:
1 | { |
这些选项通过状态参数state来传递给访问者。这些选项是特定于插件的,我们不能访问其它插件中的选项。
1 | export default function({ types: t }) { |
2. 插件的准备和收尾工作
插件可以具有在插件之前(pre)或之后(post)执行的函数,他们可用于设置或清理/分析的目的。
1 | export default function({ types: t }) { |
3. 在插件中启用其它语法
插件可以启用babylon plugins,以便不需要用户来安装/启用他们。
1 | export default function({ types: t }) { |
也可以结合babel-code-frame来抛出错误。
1 | export default function({ types: t }) { |
六。构建节点
在编写转换时,往往需要构建一些要插入的节点进AST。可以使用babel-types包中的builder方法来简化操作。
构建器的方法名称就是我们想要的节点类型的名称,除了首字母小写。例如想建立一个Identifier则可以使用t.identifier(...)。这些构建器的参数由节点定义决定。
这里正好再复习一下节点定义的形式:
1 | defineType("MemberExpression", { |
这里附上全部节点定义。
七、最佳实践
1.创建 构建器 和 校验器 辅助函数
将节点检验和节点类型抽离出来作为单独的函数。
1 | function isAssignment(node) { |
2. 尽量避免遍历 AST
遍历AST是一项消耗巨大的任务,应尽量避免不必要的AST遍历。
3. 及时合并访问者
另外,如果合并后能子一次遍历中完成多个访问者的工作,那就将访问者合并到一起。
1 | path.traverse({ |
以上可以合并到一个visitor中:
1 | path.traverse({ |
4. 优化嵌套的访问者对象
将访问者进行嵌套往往是有意义的,如下:
1 | const MyVisitor = { |
但是,每当调用FunctionDeclaration()时,都会创建一个新的访问者对象。因此,将该访问者保存在一个变量里进行复用,可以减少开销。
1 | const nestedVisitor = { |
如果嵌套的访问者中需要一些状态:
1 | const MyVisitor = { |
可以将状态通过traverse方法来传递,在内嵌的visitor中就可以通过this来访问:
1 | const nestedVisitor = { |
事实上,可以优化的点还有不少,例如结构嵌套、手动查找代替遍历等,以及对Babel插件进行单元测试等,就不在此展开了。掌握了这些知识,想必写出一个符合需要的Babel插件也不再是什么难事。
