Babel插件开发&测试与简易源码分析
本文主要从 Babel 基础 开始介绍,以一个例子完整阐述了插件的开发与测试如何进行,并在最后提供了简易的 Babel 代码解析及其他扩展资料
前置知识
一些工具
AST Explorer - 实时编辑看AST,还带高亮(但是这个和我们代码里拿到的不完全一样,仅作参考)
Javascript 可视化分词
前置资料与知识点
本节简单介绍一些如果了解了可以更好的理解后文内容的知识点
官方文档
首先建议阅读 Babel Plugin Handbook,这份文档虽然比较老(2017),但是还是介绍了编写插件的基础知识。(当然这里还有一份不完整的中文版)
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 工作流程,为后续开发组件做铺垫
步骤一:解析
使用 @babel/parser 解析器进行语法解析,获得 AST
步骤二:转换
使用 @babel/traverse 对 AST 进行深度遍历,处理各种 AST 节点
遍历过程中,能对每一种节点进行处理,这里可以使用到 @babel/types 对节点进行增删查改,或者也可以使用 @babel/template 来生成大量 AST 进行修改
步骤三:生成
使用 @babel/generator 将处理后的 AST 转换回正常代码
一句话描述:input string -> @babel/parser
parser -> AST
-> @babel/traverse transformer[s] -> AST
-> @babel/generator
-> output string
这一点也可以在后文的 简易的 babel 代码解析 中看见
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 插件暴露给开发者的核心。
visister = {
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 节点
但是直接对 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 节点,从而不必再从一个一个最原始的节点开始构造
本节提供如下资料参考
babel-handbook-visitor
babel-handbook-path
@babel/template
@babel/types
插件开发
本节将以开发一个增改查的插件例子来简单介绍插件开发流程与思路
假设今天我们要开发一个一个插件,用来把 **
运算符 转为 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));
}
},
}
};
}
本节需要用到的 @babel/types 类型文档如下:
callExpression
memberExpression
[identifier]()
assignmentExpression
插件自测
如果需要简单自测,则只需要在 .babelrc 中配置上插件的本地路径即可:
{
"plugins": [["./src/index.js"]]
}
然后
npx babel test/index.js
插件测试
编写完 babel 插件后,我们虽然简单进行了测试,但是对于复杂一些的插件来说,我们需要对其有更加完善的单元测试并尽可能覆盖多的情况。
使用 babel-plugin-tester 进行测试
这个测试工具需要和 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 文件作为测试文件,所以注意文件命名)
// index.test.js
import pluginTester from 'babel-plugin-tester'
import myPlugin from '../src'
pluginTester({
plugin: myPlugin,
pluginName: 'myPlugin',
// 默认插件名
title: 'describe block title',
// 传递给插件的 options,详见:https://babeljs.io/docs/en/plugins/#plugin-options
pluginOptions: {
optionA: true,
},
// 使用 jest 的 snapshot
snapshot: true,
// 读取的目录
fixtures: path.join(__dirname, '__fixtures__'),
})
如果你有其他需求,可以参考完整的测试代码
最后,在 package.json 中加入以下代码方便测试:
{
"scripts": {
"test": "jest --verbose --watch", // 加不加 watch 都行,加上方便边改边测
"test-cov": "jest --verbose --coverage", // 生成覆盖率报告
}
}
于是我们就可以开心的 npm run test
来测试我们的插件啦
进阶-测试 TS 代码
如果你的 babel-plugin 是通过 TypeScript 写的,则还需要额外几个步骤才能开心的测试 TS 代码:
新增以下依赖(默认你已经装了 typescript 依赖)
npm install --save-dev ts-jest @types/jest
新增配置文件
jest.config.js
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node' // 测试环境
};
然后就可以继续开心的 npm run test
如果你需要参考完成的 jest config,为你提供以下几篇文章:
jest.config.js
的配置文档:https://jestjs.io/docs/en/configurationjest 部分配置的中文介绍:https://juejin.im/post/5d7cf006f265da03970be8b0#heading-16
ts-jest 的配置文档:https://kulshekhar.github.io/ts-jest/user/config/
使用 @babel/helper-plugin-test-runner 进行测试
这是一个 babel 官方的测试工具库,可以理解为 babel-plugin-tester 的简化版本,只会扫对应目录下的 fixtures
目录,不推荐使用
简易的 babel 代码解析
babel 的核心代码在 @babel/core 中,读取代码、生成AST、转换、重新生成代码的流程都是由 @babel/core 模块来控制的,它通过调用其他模块来完成 babel 的整个转换。
我们在 babel-core/src/transformation/index.js 这里可以找到其最核心的代码:runSync
( runAsync
只是在上面包了一层 callback ),我们来看看这个方法做了什么:
export function runSync(
config: ResolvedConfig,
code: string,
ast: ?(BabelNodeFile | BabelNodeProgram),
): FileResult {
// 生成 AST,以及后面 transform 等需要用到的 scope(有AST传进去就会用,没有传入 AST 则会重新 parse)
const file = normalizeFile(
config.passes,
normalizeOptions(config),
code,
ast,
);
const opts = file.opts;
try {
// 转换代码
transformFile(file, config.passes);
} catch (e) {
// 如果 ?? 左边的值是 null 或者 undefined,那么就返回右边的值
e.message = `${opts.filename ?? "unknown"}: ${e.message}`;
if (!e.code) {
e.code = "BABEL_TRANSFORM_ERROR";
}
throw e;
}
let outputCode, outputMap;
// 重新生成代码
try {
if (opts.code !== false) {
({ outputCode, outputMap } = generateCode(config.passes, file));
}
} catch (e) {
e.message = `${opts.filename ?? "unknown"}: ${e.message}`;
if (!e.code) {
e.code = "BABEL_GENERATE_ERROR";
}
throw e;
}
// 返回
return {
metadata: file.metadata,
options: opts,
ast: opts.ast === true ? file.ast : null,
code: outputCode === undefined ? null : outputCode,
map: outputMap === undefined ? null : outputMap,
sourceType: file.ast.program.sourceType,
};
}
从这部分代码来看,babel 的核心确实就是三步走:解析 -> 转换 -> 生成代码
解析(Parse)
直接调用 @babel/parser 进行 AST 解析
转换(Transform)
在 babel-core/src/transformation/index.js 中我们可以看见:
import traverse from "@babel/traverse";
// ...
function transformFile(file: File, pluginPasses: PluginPasses): void {
for (const pluginPairs of pluginPasses) {
const passPairs = [];
const passes = [];
const visitors = [];
// loadBlockHoistPlugin 帮你处理优先级,谁在前谁在后
for (const plugin of pluginPairs.concat([loadBlockHoistPlugin()])) {
// 为每个插件实例化一个 PluginPass 对象,用作插件执行的上下文
const pass = new PluginPass(file, plugin.key, plugin.options);
passPairs.push([plugin, pass]);
passes.push(pass);
visitors.push(plugin.visitor);
}
for (const [plugin, pass] of passPairs) {
执行插件的 pre 方法,详见:https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#-pre-and-post-in-plugins
const fn = plugin.pre;
if (fn) {
const result = fn.call(pass, file);
if (isThenable(result)) {
throw new Error(
`You appear to be using an plugin with an async .pre, ` +
`which your current version of Babel does not support. ` +
`If you're using a published plugin, you may need to upgrade ` +
`your @babel/core version.`,
);
}
}
}
// merge all plugin visitors into a single visitor
// 合并插件中的 visitor
const visitor = traverse.visitors.merge(
visitors,
passes,
file.opts.wrapPluginVisitorMethod,
);
// 开始执行 transform
traverse(file.ast, visitor, file.scope);
for (const [plugin, pass] of passPairs) {
执行插件的 post 方法,详见:https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#-pre-and-post-in-plugins
const fn = plugin.post;
if (fn) {
const result = fn.call(pass, file);
if (isThenable(result)) {
throw new Error(
`You appear to be using an plugin with an async .post, ` +
`which your current version of Babel does not support. ` +
`If you're using a published plugin, you may need to upgrade ` +
`your @babel/core version.`,
);
}
}
}
}
}
// ...
为每一个插件实例化一个 PluginPass 对象(插件执行上下文)
帮你塞进去一个 loadBlockHoistPlugin 插件,处理变量优先级
尝试执行插件
pre
方法合并每一个插件中的所有
visitor
直接调用 @babel/traverse 来处理 AST
尝试执行插件
post
方法
这里暂时不进一步分析 @babel/traverse 是如何深度遍历 AST 的
生成(Generate)
首先,我们可以从 runSync
方法中的 generateCode
追起,可以发现其调用的是 @babel/generator
中默认导出的 generate
方法,而这个方法则本质上又调用到了 Printer
类的 generate
方法
// packages/babel-generator/src/index.js
export default function(ast: Object, opts: Object, code: string): Object {
const gen = new Generator(ast, opts, code);
return gen.generate();
}
class Generator extends Printer {
constructor(ast, opts = {}, code) {
const format = normalizeOptions(code, opts);
const map = opts.sourceMaps ? new SourceMap(opts, code) : null;
super(format, map);
this.ast = ast;
}
ast: Object;
generate() {
return super.generate(this.ast);
}
}
于是最终可以定位到 packages/babel-generator/src/printer.js 中的 print
方法:
// 核心,各种类型都靠它
print(node, parent) {
if (!node) return;
const oldConcise = this.format.concise;
if (node._compact) {
this.format.concise = true;
}
// 处理类型保护
const printMethod = this[node.type];
if (!printMethod) {
throw new ReferenceError(
`unknown node of type ${JSON.stringify(
node.type,
)} with constructor ${JSON.stringify(node && node.constructor.name)}`,
);
}
this._printStack.push(node);
const oldInAux = this._insideAux;
this._insideAux = !node.loc;
this._maybeAddAuxComment(this._insideAux && !oldInAux);
let needsParens = n.needsParens(node, parent, this._printStack);
if (
this.format.retainFunctionParens &&
node.type === "FunctionExpression" &&
node.extra &&
node.extra.parenthesized
) {
needsParens = true;
}
if (needsParens) this.token("(");
// 前面的注释
this._printLeadingComments(node);
const loc = t.isProgram(node) || t.isFile(node) ? null : node.loc;
this.withSource("start", loc, () => {
// 根据不同类型处理 AST
printMethod.call(this, node, parent);
});
// 后面的注释
this._printTrailingComments(node);
if (needsParens) this.token(")");
// end
this._printStack.pop();
this.format.concise = oldConcise;
this._insideAux = oldInAux;
}
从中可以看见除了上下杂七杂八的保护和处理,最核心的就是根据类型拿到 printMethod
,然后调用 printMethod
进行代码转换了。那么这个 printMethod
根据不同节点类型处理各种 AST 的代码在哪里呢?
import * as generatorFunctions from "./generators";
// Expose the node type functions and helpers on the prototype for easy usage.
Object.assign(Printer.prototype, generatorFunctions);
通过上述代码,将整个 generators
下的代码挂到了 prototype
上用以直接调用,我们可以在 generators 文件夹中看到这些处理方法:
其大概功能如下:
base:基础源码生成函数,比如处理File、Program等节点
classes:类源码生成函数
expressions:表达式的源码生成函数
flow:flow格式的源码生成函数
jsx:jsx的源码生成函数
methods:函数和方法的源码生成函数
modules:modules的源码生成函数
statements:表达式的源码生成函数
Template-literals:字符串模版的源码生成函数
types:其他基础的类型生成函数,如
RestElement
ObjectExpression
ObjectMethod
ArrayExpression
等Typescript:ts的源码生成函数
延伸阅读
我想编写 PostCss 插件
postCss 也有现成工具可以被解析成 AST ,如果你想为 PostCss 编写一个插件,可以参考:https://github.com/postcss/postcss/blob/master/docs/writing-a-plugin.md
我不想用 babel 编写转换插件,可以用其他的库吗?
可以使用 https://github.com/facebook/jscodeshift
前端技术优选为你精选前端领域优质技术博文,欢迎关注。Official Account在看点这里