3、核心原理(二):AST遍历与节点操作、使用@babel/traverse修改AST、生成格式化后的代码
上一章我们聊了AST长什么样,怎么把它解析出来。但光看不动手,那叫观光,不叫开发。这一章我们真正开始“动刀子”——遍历AST、找到目标节点、修改它,最后再变回代码。
说白了,格式化工具的核心就三步:解析 → 遍历修改 → 生成。第二步是最考验功力的地方。我刚开始写格式化插件时,就栽在“改完AST不知道怎么变回代码”这个坑里,后来才发现,原来@babel/generator早就帮我们准备好了。
核心流程回顾:
源代码 → 解析器 → AST → 遍历器(traverse) → 修改后的AST → 生成器 → 格式化代码
3.1 遍历AST:从根节点开始“走一遍”
AST是一棵树,遍历就是把这棵树从头到尾走一遍。@babel/traverse用的是深度优先遍历,说白了就是“先钻到最深的叶子节点,再往回走”。
我个人习惯把遍历器想象成一个“探照灯”,它照到哪个节点,我们就处理哪个节点。你只需要告诉它:“嘿,遇到VariableDeclaration的时候,帮我做点事。”
import traverse from '@babel/traverse';
traverse(ast, {
// 进入节点时触发
enter(path) {
console.log('进入节点:', path.node.type);
},
// 离开节点时触发
exit(path) {
console.log('离开节点:', path.node.type);
}
});
这里有个关键概念——path。它不是节点本身,而是节点在树中的“位置信息”。path包含了当前节点、父节点、兄弟节点,还有一堆操作方法。我在项目中遇到过有人直接操作path.node,结果改完发现父节点没更新,代码生成出来乱七八糟。记住:永远通过path来操作节点。
小技巧:path对象里有个path.parent可以拿到父节点,path.getSibling()可以拿到兄弟节点。调试时我经常用path.toString()看看当前节点对应的代码片段,非常方便。
3.2 节点操作:增删改查四板斧
遍历只是手段,修改才是目的。@babel/traverse提供了四类操作:查、改、增、删。我们一个个来看。
3.2.1 查:找到你要的节点
最常用的就是按类型查找。比如我想找到所有变量声明:
traverse(ast, {
VariableDeclaration(path) {
console.log('找到变量声明:', path.node.kind, path.node.declarations.length);
}
});
还可以按条件过滤。比如只找const声明:
traverse(ast, {
VariableDeclaration(path) {
if (path.node.kind === 'const') {
// 只处理const声明
}
}
});
嗯,这里要注意:遍历器是按节点类型匹配的,不是按节点名称。你写VariableDeclaration(path),它就会在所有VariableDeclaration节点上触发。如果你只想匹配某个特定变量名,得在回调里自己判断。
3.2.2 改:修改节点属性
改节点是最常见的操作。比如把所有的var改成let:
traverse(ast, {
VariableDeclaration(path) {
if (path.node.kind === 'var') {
path.node.kind = 'let';
}
}
});
就这么简单?对,就这么简单。但坑在后面——改完节点后,子节点可能也需要同步更新。我曾经在改函数参数时,只改了参数名没改引用,结果生成出来的代码里,函数体还在用旧参数名,直接报错。
避坑指南:修改节点后,记得检查所有引用该节点的地方是否也需要同步更新。特别是变量名、函数名这种有引用关系的属性。
3.2.3 增:插入新节点
插入节点稍微复杂一点,因为你要决定插在哪里。@babel/traverse提供了insertBefore和insertAfter:
traverse(ast, {
ReturnStatement(path) {
// 在return语句前插入一行console.log
path.insertBefore(
types.expressionStatement(
types.callExpression(
types.memberExpression(
types.identifier('console'),
types.identifier('log')
),
[types.stringLiteral('before return')]
)
)
);
}
});
看着有点复杂对吧?因为你要用@babel/types来构建AST节点。我刚开始也觉得麻烦,后来习惯了反而觉得这样更安全——直接操作AST节点,比拼接字符串靠谱一万倍。
3.2.4 删:移除节点
删除节点最简单,直接调用path.remove():
traverse(ast, {
DebuggerStatement(path) {
path.remove(); // 移除所有debugger语句
}
});
但要注意:删除节点后,遍历器不会自动跳过子节点。如果你在enter里删了父节点,子节点的enter回调还会触发,这时候path.parent可能已经是null了。我的建议是:删除操作尽量在exit阶段做,或者用path.skip()跳过子节点。
3.3 实战:写一个简单的格式化规则
光说不练假把式。我们来写一个真实场景:把所有的==改成===。这是ESLint的eqeqeq规则的核心逻辑。
import traverse from '@babel/traverse';
import * as types from '@babel/types';
function eqeqeqFixer(ast) {
traverse(ast, {
BinaryExpression(path) {
// 只处理 == 和 !=
if (path.node.operator === '==' || path.node.operator === '!=') {
const newOperator = path.node.operator === '==' ? '===' : '!==';
// 创建新的BinaryExpression节点
const newNode = types.binaryExpression(
newOperator,
path.node.left,
path.node.right
);
// 替换当前节点
path.replaceWith(newNode);
}
}
});
return ast;
}
你看,核心逻辑就十几行。但实际项目中要考虑的更多:要不要忽略== null的情况?要不要考虑类型注解?这些细节才是格式化工具的难点。
关键点总结:
- 遍历器用
enter和exit两个阶段,exit阶段做删除更安全 - path是操作的核心,不要直接操作
path.node以外的属性 - 增删改查四类操作各有适用场景,
replaceWith是最常用的替换方法 - 构建新节点用
@babel/types,不要手动拼接字符串
3.4 生成格式化后的代码
AST改完了,怎么变回代码?用@babel/generator:
import generate from '@babel/generator';
const result = generate(ast, {
retainLines: false, // 不保留原始行号
compact: false, // 不压缩
concise: false, // 不精简
quotes: 'single', // 使用单引号
}, code); // 第三个参数是原始代码,用于保留注释位置
console.log(result.code);
这里有个细节:第三个参数传原始代码,生成器会尽量保留注释的位置。如果不传,注释可能会跑到奇怪的地方去。我记得有一次忘了传这个参数,结果所有注释都堆到了文件开头,被同事吐槽了好久。
生成器还支持很多配置项,比如缩进风格、行尾分号、引号类型等。这些配置就是格式化工具的“可选项”,用户可以根据喜好调整。
个人经验:生成代码后,最好再用prettier或eslint --fix过一遍。因为@babel/generator生成的代码虽然语法正确,但缩进和换行可能不够“漂亮”。格式化工具嘛,最终还是要给人看的。
好了,这一章的内容就到这里。AST遍历和节点操作是格式化工具的核心能力,掌握了这些,你已经能写出一个能用的格式化插件了。下一章我们会聊更高级的话题——如何处理注释和源码位置信息,这些才是让格式化工具“好用”的关键。
公众号:蓝海资料掘金营,微信deep3321