今天,我将分享如何一步步写一个自定义babel转换工具。你可以利用这项技术来自动化代码的修改,重构以及生成。
Babel 是一个Javascript 编译器,它主要被用于将ECMA Script 2015以上的代码转换成目前或者更老版本浏览器或者环境可以兼容的版本。Babel的代码变换启用了插件系统,这个插件系统可以让任何人基于babel写自己的代码转换插件。
在你开始书写babel转换插件之前,你还需要了解什么是抽象语法树(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变换工具吧!
以下展示了一个用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转换插件的。以下是详细步骤:
在示例里,我想创建一个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
,这样即使代码不太可读,但它仍旧可以正常工作。(我可不想破坏线上代码!)
打开看看babel AST explorer,点击不同部分的代码,再看看右侧AST中在什么位置,和怎样呈现了这段代码:
假如这是你第一次看到AST,多玩一会,感受它大概的样子,并了解AST上跟你的代码相对应的节点的名字。
现在我们明白目标是什么了:
再看下babel AST explorer,但这次我们看你想要最终生成的代码。
思考并尝试下如何将之前的AST转换成现在的AST吧。
比如说,你可以看到’H’ + ‘e’ + ‘l’ + ‘l’ + ‘o’ + ‘ ‘ + name
是由BinaryExpression
嵌套了StringLiteral
的形式出现。
现在我们再来看看代码:
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);
总结一下我们做过的步骤:
如果你感兴趣,想学习更多的话,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/