原文地址:Step-by-step guide for writing a custom babel transformation,2019.12.12,by Tan Li Hau
今天,我将分享一份逐步指南(step-by-step guide),教你如何编写自定义的 Babel 转换。你可以使用这种技术来编写自己的自动化代码修改、重构和代码生成工具。
什么是 Babel?
Babel 是一个 JavaScript 编译器,主要用于将 ECMAScript 2015+ 代码转换为当前或与旧版浏览器环境(向后)兼容的 JavaScript 代码。Babel 使用一套 插件系统 进行代码转换,因此任何人都可以为 Babel 编写自己的转换插件。
在开始为 Babel 编写转换插件之前,你需要了解什么是抽象语法树(Abstract Syntax Tree,简称 AST)。
什么是抽象语法树?
我不确定我能比网络上那些精彩的文章更好地解释这个问题:
-
Leveling Up One’s Parsing Game With ASTs by Vaidehi Joshi (强烈推荐这个!👍)
-
维基百科: Abstract syntax tree
总结下来,AST 就是你代码的树形表示。对 JavaScript 而言,AST 遵循 estree 规范。
AST 代表你的代码、结构以及含义。因此,它能让 Babel 这样的编译器理解代码并对其进行特定的有意义的转换。
现在你知道了什么是 AST,让我们编写一个自定义的 Babel 转换来借助 AST 修改你的代码吧。
如何使用 Babel 转换代码?
以下是使用 Babel 进行代码转换的通用模板:
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generator from '@babel/generator';
const code = 'const n = 1';
// 解析 code -> AST
const ast = parse(code);
// 转换 AST
traverse(ast, {
enter(path) {
// 本例中,将所有变量 `n` 改为 `x`
if (path.isIdentifier({ name: 'n' })) {
path.node.name = 'x';
}
}
})
// 生成 code <- AST
const output = generator(ast, code);
console.log(output.code); // 'const x = 1;'
TIP: 你需要安装
@babel/core
才能运行此程序。@babel/parser
、@babel/traverse
和@babel/generator
都是@babel/core
的依赖项,因此只需安装 @babel/core 即可。
因此,一般的办法是将你的代码解析为 AST,再转换 AST,然后从转换后的 AST 生成代码。
code -> AST -> 转换后的 AST -> 转换后的 code
然而,我们可以使用 Babel 的另一个 API 来完成上述所有操作:
import babel from '@babel/core';
const code = 'const n = 1';
const output = babel.transformSync(code, {
plugins: [
// 你的第一个 Babel 插件 😎😎
function myCustomPlugin() {
return {
visitor: {
Identifier(path) {
// 本例中,将所有变量 `n` 改为 `x`
if (path.isIdentifier({ name: 'n' })) {
path.node.name = 'x';
}
}
}
}
}
]
});
console.log(output.code); // 'const x = 1;'
现在,你已经编写了第一个 Babel 转换插件,将所有名为 n
的变量替换为 x
,这是多酷啊!
将
myCustomPlugin
函数提取到一个新文件中并导出它。将该文件 打包并发布为 npm 包,你就可以自豪地说你已经发布了一个 Babel 插件!🎉🎉
此时,你一定会想:“是,我刚刚写了一个 Babel 插件,但我不知道它是如何工作的...”。别担心,让我们深入探讨一下如何编写自己的 Babel 转换插件吧!
你可以参照下列步骤开始编写:
1、首先你要知道想转换成什么样的代码
在这个例子中,我想通过创建一个 Babel 插件来恶作剧我的同事,该插件将会:
-
反转所有变量和函数的名称
-
将字符串拆分为单个字符
function greet(name) {
return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
转换成
function teerg(eman) {
return 'H' + 'e' + 'l' + 'l' + 'o' + ' ' + eman;
}
console.log(teerg('t' + 'a' + 'n' + 'h' + 'a' + 'u' + 'h' + 'a' + 'u')); // Hello tanhauhau
当然,我们必须要保留 console.log
。这样,即使代码难以阅读,仍然可以正常工作。(我不想破坏生产代码!)
2、知道在 AST 上要定位什么
前往 babel AST explorer(译注:类似的工具还有 AST Explorer),点击代码的不同部分,查看在 AST 中是如何表示的:
如果这是你第一次接触 AST,请花点时间感受一下,了解它的样子,并参照代码了解 AST 上的节点名称。
所以,现在我们知道需要针对以下内容进行定位:
-
Identifier:表示变量和函数名称
-
StringLiteral:表示字符串字面量
3、知道转换后的 AST 长什么样子
再次前往 babel AST Explorer,但这一次使用你想要生成的结果代码。
玩一下,思考如何将之前的 AST 转换为当前的 AST。
比如,你可以看到 'H' + 'e' + 'l' + 'l' + 'o' + ' ' + eman
是由嵌套的 BinaryExpression
和StringLiteral
构成的。
4、编写代码
现在再看一下我们的代码:
function myCustomPlugin() {
return {
visitor: {
Identifier(path) {
// ...
},
},
};
}
转换使用了 访问者模式(visitor pattern)。
在遍历阶段,Babel 将进行 深度优先搜索遍历 并访问 AST 中的每个节点。你可以在访问器(visitor)中指定回调方法,当访问节点时,Babel 将使用当前正在访问的节点调用回调方法。
在访问者对象中,您可以指定要回调的节点名称:
function myCustomPlugin() {
return {
visitor: {
Identifier(path) {
console.log('identifier');
},
StringLiteral(path) {
console.log('string literal');
},
},
};
}
运行上述代码,你会看到每当 Babel 遇到"字符串字面量(string literal)"和"标识符(identifier)"时都会被调用:
identifier
identifier
string literal
identifier
identifier
identifier
identifier
string literal
在我们继续之前,让我们看一下 Identifer(path) {}
的参数。它使用了 path
而不是 node
,path
和 node
有什么区别呢?🤷
在 Babel 中, path
是 node
的抽象,它提供了节点之间的关系链接。比如节点的父级(parent
)以及作用域(scope
)、上下文(context
)等信息。此外,path
还提供诸如 replaceWith
、insertBefore
、remove
等方法,这些方法将更新并反映在底层 AST 节点上。
TIP: 你可以在 Jamie Kyle 的 Babel 手册 中阅读有关
path
的更多详细信息。
那么让我们继续编写我们的 Babel 插件。
变量名转换
从 AST explorer 中可以看出,Identifier
的名称存储在名为 name
的属性中,因此我们要做的就是反转名称。
Identifier(path) {
path.node.name = path.node.name
.split('')
.reverse()
.join('');
}
执行会看到:
function teerg(eman) {
return 'Hello ' + eman;
}
elosnoc.gol(teerg('tanhauhau')); // Hello tanhauhau
快成功了,只是我们不小心把 console.log
也反转了。我们该如何防止这种情况发生?
请再次查看 AST:
console.log
是 MemberExpression
的一部分,由一个 object
(即 "console"
) 和 一个 property
(即 "log"
) 组成。
因此,我们检查下当前 Identifier
是否在 MemberExpression
中,如果是,就不做反转操作:
Identifier(path) {
if (
// 排除 console.log
!(
path.parentPath.isMemberExpression() &&
path.parentPath
.get('object')
.isIdentifier({ name: 'console' }) &&
path.parentPath.get('property').isIdentifier({ name: 'log' })
)
) {
path.node.name = path.node.name
.split('')
.reverse()
.join('');
}
}
是的,现在你对了!
function teerg(eman) {
return 'Hello ' + eman;
}
console.log(teerg('tanhauhau')); // Hello tanhauhau
那么,为什么我们要检查 Identifier
的父级是否不是 console.log
MemberExpression
?而不能只比较当前 Identifier.name === 'console' || Identifier.name === 'log'
?
也可以这样做,但如果有名为 console
或 log
的变量,则无法反转这种变量名了。
const log = 1;
那么我是如何知道
isMemberExpression
和isIdentifier
方法的呢?因为在 @babel/types 中指定的所有节点类型都有相应的isXxxx
校验函数,例如:anyTypeAnnotation
函数就有一个对应的isAnyTypeAnnotation
校验函数。如果您想了解验证器函数的详尽列表,可以前往 翻阅源代码。
字符串字面量转换
下一步是将 StringLiteral
生成嵌套的 BinaryExpression
。
要创建 AST 节点,你可以使用 @babel/types
中的工具函数。 @babel/types
也可通过 @babel/core
的 babel.types
获得。
StringLiteral(path) {
const newNode = path.node.value
.split('')
.map(c => babel.types.stringLiteral(c))
.reduce((prev, curr) => {
return babel.types.binaryExpression('+', prev, curr);
});
path.replaceWith(newNode);
}
因此,我们将位于 path.node.value
中的 StringLiteral
内容拆分为每个字符一个 StringLiteral
,并使用 BinaryExpression
组合起来。最后,我们用新创建的节点替换 StringLiteral
。
...就是这样!但我们遇到了堆栈溢出(Stack Overflow) 😅:
RangeError: Maximum call stack size exceeded
为什么 🤷?
这是因为对于每个 StringLiteral
,我们创建了更多的 StringLiteral
。在每个 StringLiteral
中,我们又“创建”了更多的 StringLiteral
。虽然,我们将一个 StringLiteral
替换为另一个 StringLiteral
,但 Babel 会将其视为新节点,继续访问新创建的 StringLiteral
,从而导致无限递归和堆栈溢出。
那么,我们如何告诉 Babel 一旦用 newNode
替换了 StringLiteral
后就可以停止,不必继续向下访问新创建的节点呢?
使用 path.skip()
就行,它会跳过遍历当前路径的子节点:
StringLiteral(path) {
const newNode = path.node.value
.split('')
.map(c => babel.types.stringLiteral(c))
.reduce((prev, curr) => {
return babel.types.binaryExpression('+', prev, curr);
});
path.replaceWith(newNode);
path.skip();
}
...现在代码可以正常工作,而且没有堆栈溢出错误了!
总结
以下,就是我们用 Babel 进行的第一个代码转换:
const babel = require('@babel/core');
const code = `
function greet(name) {
return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
`;
const output = babel.transformSync(code, {
plugins: [
function myCustomPlugin() {
return {
visitor: {
StringLiteral(path) {
const concat = path.node.value
.split('')
.map(c => babel.types.stringLiteral(c))
.reduce((prev, curr) => {
return babel.types.binaryExpression('+', prev, curr);
});
path.replaceWith(concat);
path.skip();
},
Identifier(path) {
if (
!(
path.parentPath.isMemberExpression() &&
path.parentPath
.get('object')
.isIdentifier({ name: 'console' }) &&
path.parentPath.get('property').isIdentifier({ name: 'log' })
)
) {
path.node.name = path.node.name
.split('')
.reverse()
.join('');
}
},
},
};
},
],
});
console.log(output.code);
到这里,我们再总结一下转换步骤:
-
首先你要知道想转换成什么样的代码
-
知道在 AST 上要定位什么
-
知道转换后的 AST 长什么样子
-
编写代码
更多资源
如果你有兴趣想要了解更多,Babel 的 Github 仓库 始终是查找编写 Babel 转换代码示例的最佳位置。
前往 github.com/babel/babel,查找 babel-plugin-transform-*
或 babel-plugin-proposal-*
文件夹,它们都是 Babel 转换插件。在这里,你可以找到关于如何使用 Babel 转换 链判断运算符(optional chaining operator)?.、 Null 判断运算符(optional chaining)?? 等更多内容的代码。
使用 Babel 和 JavaScript 操作 AST
如果你喜欢迄今为止所读的内容,并想学习如何使用 Babel 实现它。我创建了一个 视频课程,逐步展示如何编写 Babel 插件和 codemod。
在视频课程中,我详细介绍了一些技巧和窍门。例如:如何处理作用域、如何使用状态以及嵌套遍历等。