从0到1手写babel插件
概要
当我们对babel工作原理有了较为深入的了解后,我们就可以根据日常的业务场景开发一些实用的babel插件用于优化我们的业务代码,使我们打包后的代码更加小巧快速。这篇文章主要介绍如何实现babel插件的开发,从0到1手摸手,成为大佬不是梦。
餐前准备
一顿好的饭菜不仅需要高超的技艺,还需要必要的基础烹饪知识和得心应手的工具等。因此在开发babel插件之前,很有必要了解一些插件开发基础知识,先让我们上几道前菜解解馋。
Babel 很有用的那些库
Babel7 用了 npm 的 private scope,把全部的包都挂在在 @babel 下,所以17年文档中有些库名已经变了,简单介绍一下比较重要的几个库:
-
Babylon -> @babel/parser:
Babel 的解析器。最初是从 Acorn 项目 fork 出来的。Acorn 非常快,易于使用,并且针对非标准特性(以及那些未来的标准特性) 设计了一个基于插件的架构。
可以用于接受一段代码,生成一段 Babel AST 格式的 AST -
Babel-traverse -> @babel/traverse
负责维护整棵树的状态
,并且负责替换
、移除
和添加
节点。
接受一段AST,然后会遍历该AST,并且提供了许多钩子来协助我们在遍历到某种 AST 节点类型时进行处理,如 callExpression 等 -
Babel-types -> @babel/types
一个用于 AST 节点的 Lodash 式工具库, 它包含了构造
、验证
以及变换
AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理 AST 逻辑非常有用。
这个库提供了 babel AST 所有的节点类型,用户可以使用这个库构造出一个新的 AST 节点,或者判断某个节点是什么类型等 -
Babel-generator -> @babel/generator
Babel 的代码生成器,它读取 AST 并将其转换为代码和源码映射(sourcemaps)。
接受一段 AST ,返回一段代码。 -
Babel-template -> @babel/template
另一个虽然很小但却非常有用的模块。 它能让你编写字符串形式且带有占位符的代码来代替手动编码, 尤其是生成大规模 AST 的时候。 在计算机科学中,这种能力被称为准引用(quasiquotes)。
将一些变量注入到模版代码中,然后获得注入后代码的 AST
Babel 工作流程
-
步骤一:解析
使用@babel/parser
解析器进行语法解析,获得 AST。 -
步骤二:转换
使用@babel/traverse
对 AST 进行深度遍历,处理各种 AST 节点。
遍历过程中,能对每一种节点进行处理,这里可以使用到@babel/types
对节点进行增删查改,或者也可以使用@babel/template
来生成大量 AST 进行修改。 -
步骤三:生成
使用@babel/generato
r 将处理后的 AST 转换回正常代码。
一句话描述:input string -> @babel/parser parser -> AST -> @babel/traverse transformer[s] -> AST -> @babel/generator -> output string
Babel 插件开发三大问题
前菜吃的差不多了,现在想想我们开发一个插件还需要解决那些问题,善于提问题的人才有可能去解决问题,因此我们总结出了如下三大哲学问题:
- 如何访问到需要处理的 AST 节点
- 如何获取到 AST 节点
- 获取到 AST 节点后,如何进行操作
带着这些问题,我们开始了漫长的道路探索。
如何访问到需要处理的 AST 节点
首先我们看看如何访问到需要处理 AST 节点。首先我们要处理的节点一般来说是有某种特种的某种类型的节点,比如我要找到所有的 console.log()
,那么我们首先会发现这一定是一个函数调用(CallExpression
),所以我们首先要找到 CallExpression 的 AST 节点。
babel 已经为我们处理好了 如何找到不同类型节点 这一步。在上一段我们知道,babel 工作主要经过了三个流程,而我们的插件则是在 转换
这一步被调用的。而根据 babel 插件文档,我们的插件本质上是返回一个符合 babel 插件规范的对象
,其中最核心的是对象中的 visitor
属性。
babel 在使用 @babel/traverse
对 AST 进行深度遍历时,会 访问 每个 AST 节点,这个便是跟我们的 visitor 有关了(这个名字来自 访问者模式(visitor))。babel 会在 访问 AST 节点的时候,调用 visitor 中对应节点类型的方法,这便是 babel 插件暴露给开发者的核心。
visitor = {
CallExpression() {}, // 当节点是一个函数调用表达式时
MemberExpression() {}, // 当节点是一个成员表达式时,如 foo.bar
FunctionDeclaration() {}, // 当节点是一个函数声明时
}
对于 babel 暴露了哪些类型供开发者处理,请参考 @babel/types文档
如何获取到 AST 节点
由此,插件开发者便可以针对不同类型的 AST 节点编写代码了。但是我们发现,babel 只是调用了我们处理不同类型节点的方法,所以下一步,就是如何获取 AST 节点了。当我们如上文访问一个函数调用表达式时,babel 会向我们的方法传递一些参数: CallExpression(path, state) {}
,其中 path
对象便是我们获取 AST 节点的入口。我们看看 path 对象里都有啥:
{
"parent": {},
"node": {},
"hub": {},
"contexts": [],
"data": {},
"_traverseFlags": 0,
"skipKeys": null,
"state": {},
"opts": {},
"parentPath": {},
"context": {},
"container": {},
"listKey": [],
"key": "expression",
"scope": {},
"type": "CallExpression",
...
}
其中比较重要的有:
node
当前 AST 节点信息parent
父节点的 AST 节点信息parentNode
父节点的 path 信息,通过这个属性可以一路向上找scope
作用域context
当前 traverse 的上下文
通过 path.node
,我们便拿到了一个 AST 节点。
如何操作 AST 节点
下一步,就是针对 AST 节点进行增删改查。babel 最后仍然会使用根节点的 AST 树重新生成代码,同时由于 JavaScript 对于对象是地址引用,因此我们只要操作这个 node 对象即可,不需要额外有其他返回操作,babel 会使用原来的引用。
但是直接对 node 对象(也就是 AST 节点)进行操作成本不低,特别是需要构造出一些比较复杂的 AST 节点对原节点 进行 插入(增)替换(改)等操作的时候。所以 @babel/types
也贴心的提供了诸多构造出一个新的 AST 节点的方法,比如:
- 构造一个 解构对象 :
t.spreadElement(t.identifier(data))
- 构造一个 对象表达式:
t.objectExpression([t.spreadElement(t.identifier(data))])
- 在节点内新增一行注释:
t.addComment(node, 'inner', 'commment')
- ......
这样我们便实现了对 AST 节点的增删改查。如果你的 AST 极其复杂,可以考虑使用上文提到的 @babel/template
来将一段字符串转化为 AST 节点,从而不必再从一个一个最原始的节点开始构造
开始做饭了--插件开发
先看看我们今天要做一道什么菜,初级选手就来个西红柿炒鸡蛋吧。
假设今天我们要开发一个插件,用来把 **
运算符 转为 Math.pow()
(毕竟 幂运算法 在 ES6 出现的,所以这个需求也是合理的)。
理清思路
首先我们分析一下需求,可以发现有两种情况需要处理:
a ** b => Math.pow(a, b)
a **= b => var _a = a; a = Math.pow(_a, b)
注意, _a 只是一个例子,我们需要的是作用域内不存在的一个变量名,不然就会炸,可以使用这个来处理:babel-helper-explode-assignable-expression
首先我们进入 AST Explorer 看看两种情况的 AST :
如图,a ** b
被认为是一个 BinaryExpression
(二元表达式)。而 a **= b
被认为是一个 AssignmentExpression
(赋值表达式)。
那我们再看看 var _a = a; a = Math.pow(_a, b)
又是什么样的呢?
观察发现,var _a = a
是一个 VariableDeclaration
(变量申明),a = Math.pow(_a, b)
则是一个 AssignmentExpression
(赋值表达式),其右是一个 CallExpression
(调用表达式),同时调用表达式的调用方是一个 MemberExpression (成员表达式)。
到此,我们的思路清晰了起来:
访问到 BinaryExpression
时,看它的操作符是不是 **
如果是的话,将整个表达式替换为 一个函数调用(Math.pow(a, b)
)
访问到 AssignmentExpression
时,看它的操作符是不是 **=
如果是的话,将整个表达式替换为 一个变量声明(var _a = a
) + 一个函数调用&赋值(a = Math.pow(_a, b)
)
开始开发插件
首先我们装一下依赖
npm i @babel/cli @babel/core @babel/helper-explode-assignable-expression @babel/types -S
然后在 src/index.js 中开始编写代码:
const t = require("@babel/types");
const operator = '**';
module.exports = function() {
return {
name: 'babel-plugin-exponentiation-operator',
visitor: {
AssignmentExpression(path) {
const { node, scope } = path;
// 只处理 **=
if (node.operator === `${operator}=`) {
// 修改 AST
}
},
BinaryExpression(path) {
const { node } = path;
if (node.operator === operator) {
// 修改 AST
}
},
}
};
}
首先我们按照刚刚的分析,我们需要处理的是 AssignmentExpression
和 BinaryExpression
,于是在插件 visitor
属性中加入这两种节点类型的处理方法,并且排除掉不是我们需要处理的运算符。
第二步,就是构造出我们需要的 callExpression
, memberExpression
,assignmentExpression
等并把原来的 AST 替换掉了
如何构造 Math.pow(a, b)
首先 Math.pow(a, b)
整体是一个函数表达式,查文档可以得到其构造需要的参数: t.callExpression(callee, arguments)
其次 Math.pow
部分又是一个成员表达式,查文档可以得到其构造需要的参数: t.memberExpression(object, property, computed, optional)
最后,如何构造出 Math
和 pow
这两个变量名呢?只需要使用 identifier(name)
就可以啦
所以最终我们构造出来的代码如下,其中 left
和 right
为 Math.pow(a, b)
中 a
, b
两个参数
const mathPowExpression = t.callExpression(
t.memberExpression(t.identifier("Math"), t.identifier("pow")),
[left, right],
);
如何构造 _a = Math.pow(a, b)
这里比上一步多了一个 赋值(Assignment) 操作,通过查文档我们可以找到如何使用 AssignmentExpression :t.assignmentExpression(operator, left, right)
const mathPowExpression = t.callExpression(
t.memberExpression(t.identifier("Math"), t.identifier("pow")),
[left, right],
);
t.assignmentExpression(
"=",
identifier("_a"),
mathPowExpression,
),
如何替换 AST 节点
path.replaceWith
: 接受一个 AST 节点对象
path.replaceWithMultiple
:接受一个 AST 节点对象数组
path.replaceWithSourceString
: 接受一串字符串
因此我们替换节点只需要如下写法即可
const mathPowExpression = t.callExpression(
t.memberExpression(t.identifier("Math"), t.identifier("pow")),
[left, right],
);
path.replaceWith(mathPowExpression);
如何把 a
变成 _a
(如何找到一个当前作用域唯一的变量名)
使用官方插件 babel-helper-explode-assignable-expression
调用方式:explode(node, nodes, file, scope)
;
参数说明:
node
: 你希望对那个节点进行操作nodes
: 你希望在哪个 List 里帮你维护新旧节点关系file
: 当前操作哪个文件scope
: 当前作用域
其不但会返回新的变量节点(uid
)和旧的变量节点(ref
),还会将变量赋值给塞到入参的 nodes
中
所以我们在这里要获得一个新的变量名,只需要:
// 找一个不会炸掉的变量名
const exploded = explode(node.left, nodes, this, scope);
完整的代码如下:
// src/index.js
const explode = require("@babel/helper-explode-assignable-expression").default;
const t = require("@babel/types");
const operator = '**';
const getMathPowExpression = (left, right) => {
return t.callExpression(
t.memberExpression(t.identifier("Math"), t.identifier("pow")),
[left, right],
);
}
module.exports = function() {
return {
name: 'babel-plugin-exponentiation-operator',
visitor: {
AssignmentExpression(path) {
const { node, scope } = path;
// 只处理 **=
if (node.operator === `${operator}=`) {
const nodes = [];
// 找一个不会炸掉的变量名
const exploded = explode(node.left, nodes, this, scope);
nodes.push(
t.assignmentExpression(
"=",
exploded.ref,
getMathPowExpression(exploded.uid, node.right),
),
);
path.replaceWithMultiple(nodes);
}
},
BinaryExpression(path) {
const { node } = path;
if (node.operator === operator) {
path.replaceWith(getMathPowExpression(node.left, node.right));
}
},
}
};
}
菜肴品鉴--插件测试
简单自测
如果需要简单自测,则只需要在 .babelrc
中配置上插件的本地路径即可:
{
"plugins": [["./src/index.js"]]
}
然后
npx babel test/index.js
使用 babel-plugin-tester 进行测试
编写完 babel 插件后,我们虽然简单进行了测试,但是对于复杂一些的插件来说,我们需要对其有更加完善的单元测试并尽可能覆盖多的情况。
这个测试工具需要和 jest 一同使用,本质上是简化使用 jest 对 babel-plugin 的测试成本。
首先我们按照文档进行安装(同时还要安装一下 jest)
npm install --save-dev babel-plugin-tester jest
由于你可能有大量的 case 需要验证,为了简化测试代码的编写,建议使用 jest Snapshot(快照) 的形式配合 babel-plugin-tester 的 fixtures 来进行测试。snapshot 通常用来测试 UI ,Ant-design 便是使用这种方法进行测试的。简单来说就是测试前有一份基准快照,每次运行测试用例的时候,便是将测试输出结果和原来的基准快照进行对比,看结果是不是一致。对于我们的 babel-plugin 测试来说,这一点非常适合,因为我们主要是对比文件转换后是否符合预期。甚至只要先编写了测试输入和预期的测试结果,我们可以用 TDD 的方式进行开发。
fixtures 目录结构:
.__fixtures__
├── first-test # test title will be: "first test"
│ ├── code.js # required
│ └── output.js # required
└── second-test
├── .babelrc # optional
├── options.json # optional
├── code.js
└── output.js
测试入口代码编写:(注意,jest 只会扫描 xx.test.js 和 xx.spec.js 文件作为测试文件,所以注意文件命名)
// import pluginTester from "babel-plugin-tester";
const pluginTester = require('babel-plugin-tester').default
const myPlugin = require("../src/index")
const path = require("path")
pluginTester({
plugin: myPlugin,
pluginName: 'myPlugin',
title: 'describe block title',
pluginOptions: {
optionA: true
},
snapshot: true,
fixtures: path.join(__dirname, '__fixtures__')
});
最后,在 package.json 中加入以下代码方便测试:
"scripts": {
"compiler": "babel test/index.js --out-dir lib --watch",
"test": "jest --verbose --watchAll"
},