译:手把手教你如何写自定义babel代码转换
今天,我将分享如何一步步写一个自定义babel转换工具。你可以利用这项技术来自动化代码的修改,重构以及生成。
什么是babel?
Babel 是一个Javascript 编译器,它主要被用于将ECMA Script 2015以上的代码转换成目前或者更老版本浏览器或者环境可以兼容的版本。Babel的代码变换启用了插件系统,这个插件系统可以让任何人基于babel写自己的代码转换插件。
在你开始书写babel转换插件之前,你还需要了解什么是抽象语法树(AST)。
什么是抽象语法树(AST)?
我不太确信自己能比以下文章解释得更好:
articles out there on the web:
*
Leveling Up One’s Parsing Game With ASTs
by
Vaidehi Joshi
* (强烈推荐! ?)
* Wikipedia 的
Abstract syntax tree
*
What is an Abstract Syntax Tree
by
Chidume Nnamdi
总的来说,AST是描述代码的树。在Javascript中,Javascript AST遵循了
estree标准
。
AST代表了你的代码,你的代码结构和意义。因此它可以让babel这类编译器理解代码,并对它做一些有意义的变换。
现在,你理解了什么是AST。让我们一起开始用AST来写一个更改你带嘛的自定义babel变换工具吧!
如何利用babal转换代码
以下展示了一个用babel做代码转换的通用样本:
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';
const code = 'const n = 1';
// 将源代码转换为AST
const ast = parse(code);
// 转换AST
traverse(ast, {
enter(path) {
// in this example change all the variable `n` to `x`
if (path.isIdentifier({ name: 'n' })) {
path.node.name = 'x';
}
},
});
// 生成代码 <- ast
const output = generate(ast, code);
console.log(output.code); // 'const x = 1;'
这段代码需要安装
@babe/core
才能运行。
@babel/parser
、
@babel/traverse
、
@babel/generator
都是
@babel/core
的依赖项,因此只需安装
@babel/core
就可以了。
总的来说就是将你的代码转成AST,再转换AST,然后从转换过后的AST生成代码。
源代码 -> AST -> 转换过的AST -> 转换过的代码
然而我们也可以用另一个babel提供的接口一步完成以上过程:
import babel from '@babel/core';
const code = 'const n = 1';
const output = babel.transformSync(code, {
plugins: [
// 你的第一个插件 ??
function myCustomPlugin() {
return {
visitor: {
Identifier(path) {
// 在这个例子里我们将所有变量 `n` 变为 `x`
if (path.isIdentifier({ name: 'n' })) {
path.node.name = 'x';
}
},
},
};
},
],
});
console.log(output.code); // 'const x = 1;'
现在,你完成了第一个将所有
n
的变量转换成
x
的babel插件,酷不酷?
将myCustomPlugin抽取出来放到一个新文件中,并export。然后将这个文件打包发布位一个npm包,你就可以骄傲地说你发布了一个babel插件!??
到这里,你也许会想:“对,我刚写了一个babel插件,但我完全不造它怎么回事……“。不要担心,我们一起来看看你是如何为自己写babel转换插件的。以下是详细步骤:
1. 想好你要把什么转换为什么
在示例里,我想创建一个babel插件对同事恶作剧,这个插件会:
- 反转所有变量和方法名称
- 把字符串分割为多个字符
function greet(name) {
return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
变成
function greet(name) {
return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
这里我们保留了
console.log
,这样即使代码不太可读,但它仍旧可以正常工作。(我可不想破坏线上代码!)
2. 了解你的AST目标
打开看看
babel AST explorer
,点击不同部分的代码,再看看右侧AST中在什么位置,和怎样呈现了这段代码:
假如这是你第一次看到AST,多玩一会,感受它大概的样子,并了解AST上跟你的代码相对应的节点的名字。
现在我们明白目标是什么了:
- 变量和方法名字的标识
- 字符串的StringLiteral
3. 了解转换过后的AST长啥样
再看下
babel AST explorer
,但这次我们看你想要最终生成的代码。
思考并尝试下如何将之前的AST转换成现在的AST吧。
比如说,你可以看到
’H’ + ‘e’ + ‘l’ + ‘l’ + ‘o’ + ‘ ‘ + name
是由
BinaryExpression
嵌套了
StringLiteral
的形式出现。
4. 写代码
现在我们再来看看代码:
function myCustomPlugin() {
return {
visitor: {
Identifier(path) {
// ...
},
},
};
}
这里的转换使用了
访问者模式
。
在遍历阶段,babel会先进行
深度优先遍历
来访问AST的每一个节点。你可以为访问指定一个回调函数,然后每当访问某个节点的时候,babel会调用这个函数,并给函数传入当前访问的节点。
在visitor对象里,你可以为回调指定特定名字的节点:
function myCustomPlugin() {
return {
visitor: {
Identifier(path) {
console.log('identifier');
},
StringLiteral(path) {
console.log('string literal');
},
},
};
}
运行它,你会看到”string literal”和”identifier“在babel每次遇到它们的时候被调用:
identifier
identifier
string literal
identifier
identifier
identifier
identifier
string literal
在我们继续之前,我们可以看到
Identifer(path){}
的参数叫
path
而不是
node
,
path
和
node
有什么区别呢?
在babel里,
path
是基于
node
的一层抽象,它提供了node之间的联系,即父级节点,并提供了
领域
(scope)
上下文
(context)等信息。此外,
path
还提供了
replaceWith
、
insertBefore
之类用于更新AST节点的函数。
你可以在
Jamie Kyle
的
babel handbook
中获得关于
path
的更多详细信息。
好了,让我们继续写我们的babel插件。
转换变量名
我们可以从
AST explorer
中看到,
Identifier
的名字被存储在
name
中。因此,我们需要做的便是反转这个
name
。
Identifier(path) {
path.node.name = path.node.name
.split('')
.reverse()
.join('');
}
运行它你就能看到:
Identifier(path) {
path.node.name = path.node.name
.split('')
.reverse()
.join('');
}
我们就快完成了,除了一点,我们不小心把
console.log
也给反转了。怎样可以避免它呢?
我们再看看AST:
console.log
是
MemberExpression
的一部分,拥有一个叫
"console"
的
object
,和叫
"log"
的
property
。
那让我们在当前节点是
MemberExpression
中的
Identifier
时跳过反转这步:
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('');
}
}
现在就对啦!
function teerg(eman) {
return 'Hello ' + name;
}
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
。
你可以使用
@babel/types
提供的一个工具函数来创建一个AST节点。
@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
替换成了新建的节点。
这就完成啦!除了……碰到如下栈溢出的问题?:
RangeError: Maximum call stack size exceeded
为什么呢??
那是因为对于每遇到一个
StringLiteral
我们都创建更多
StringLiteral
,并且在每个
StringLiteral
中,我们都在“创建”更多
StringLiteral
。虽然我们是将
StringLiteral
替换为另一个
StringLiteral
,但babel会因为将它当作一个新的节点去访问这个
StringLiteral
,因此产生了死循环和栈溢出。
那我们怎么告诉babel一旦已经把
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转换代码样例的最好的地方。
进入
https://github.com/babel/babel
,找到
babel-plugin-transform-*
或是
babel-plugin-proposal-*
文件夹,它们是所有babel提供的转换插件,你可以从这里找到babel如何
转换可为空操作符
,
可选链
等等。
参考
* [Babel docs](https://babeljs.io/docs/en/) & [Github repo](https://github.com/babel/babel)
* [Babel Handbook](https://github.com/jamiebuilds/babel-handbook) by [Jamie Kyle](https://jamie.build/)
* [Leveling Up One’s Parsing Game With ASTs](https://medium.com/basecs/leveling-up-ones-parsing-game-with-asts-d7a6fc2400ff) by [Vaidehi Joshi](https://twitter.com/vaidehijoshi)
翻译自朋友的博文:https://lihautan.com/step-by-step-guide-for-writing-a-babel-transformation/