前端 AST 抽象语法树实战:用 Babel/SWC 编写自定义代码转换器
你有没有过这样的经历:领导丢过来一段上古 jQuery 代码让你”按团队规范改一改”,你盯着 3000 行文件想死;或者产品要做一个”全自动埋点”,每个按钮点击都要加一行 track('xxx'),你面对 200+ 个页面无从下手;又或者在做国际化时,发现散落在代码各处的中文文案像个定时炸弹,每次都要靠 grep 大法 + 人工核对。
这些场景的共同答案是:AST(抽象语法树)。
AST 是现代前端工程化的”心脏”——Babel、SWC、ESLint、Prettier、Vite、Webpack、TypeScript 编译器、Vue/React 的模板编译器……几乎所有”读懂代码并改写代码”的工具,都是基于 AST 工作。掌握它,你就拥有了”反向拿捏”这些工具的能力。
本文会带你从零开始:
- 原理层:AST 到底是个什么东西?Babel 和 SWC 为什么能读懂 JS?
- API 层:Babel 的
@babel/parser、@babel/traverse、@babel/generator三大件怎么协作? - 实战层:4 个生产级代码转换插件——自动埋点、按需引入、国际化文案提取、装饰器降级。
- 对比层:Babel vs SWC 的架构差异、API 差异、性能差异与选型建议。
一、AST 是什么:把代码当作一棵树
1.1 字符串到树的转换
源代码本质上就是一段字符串。直接对字符串做正则替换是脆弱的——a.b.c、a["b"].c、模板字符串里的 . 都能轻易骗过你的正则。但 AST 不同:它先把代码”解析”成一棵树,每个语法结构(变量、函数、调用、字面量)都是树上的一个节点。
举个例子,下面这段代码:
const answer = add(1, 2 * 3);在 Babel 眼里,它长这样(简化版):
File └─ Program └─ VariableDeclaration (kind: "const") └─ VariableDeclarator ├─ id: Identifier (name: "answer") └─ init: CallExpression ├─ callee: Identifier (name: "add") └─ arguments: ├─ NumericLiteral (value: 1) └─ BinaryExpression (operator: "*") ├─ left: NumericLiteral (value: 2) └─ right: NumericLiteral (value: 3)关键认知:AST 是”结构化的”——它知道
.是属性访问、知道*是二元运算、知道字符串里出现的.不是属性访问。这就是为什么 AST 比正则可靠 100 倍。
1.2 ESTree 规范:AST 的”普通话”
Babel 使用的 AST 节点定义遵循 ESTree 规范,并做了一些扩展。常见的节点类型有:
| 节点类型 | 对应语法 | 关键字段 |
|---|---|---|
Identifier | 变量名、属性名 | name |
VariableDeclaration | let/const/var 声明 | kind, declarations |
FunctionDeclaration | function foo() {} | id, params, body |
ArrowFunctionExpression | () => {} | params, body |
CallExpression | foo(a, b) | callee, arguments |
MemberExpression | obj.prop、obj['prop'] | object, property, computed |
BinaryExpression | a + b、a === b | operator, left, right |
Literal | 字面量 | value, raw |
TemplateLiteral | 模板字符串 | quasis, expressions |
ImportDeclaration | import x from 'y' | source, specifiers |
你可以通过 AST Explorer 实时查看任何 JS 代码的 AST 结构——这是学习 AST 最快的工具,强烈建议收藏。
二、Babel 三件套:解析、遍历、生成
Babel 的核心工作流是三步走,每一步对应一个独立的包:
源代码 → [Parser] → AST → [Traverse + Plugin] → 新 AST → [Generator] → 新代码| 步骤 | 包名 | 作用 |
|---|---|---|
| 解析 | @babel/parser | 把 JS 字符串变成 AST 树 |
| 遍历 | @babel/traverse | 深度优先遍历 AST,并在特定节点触发回调 |
| 生成 | @babel/generator | 把 AST 树还原成 JS 字符串 |
2.1 一个最小可运行的 Babel 插件
我们先写一个最简单的 Babel 插件:把所有 var 声明改成 let。
const parser = require('@babel/parser');const traverse = require('@babel/traverse').default;const generate = require('@babel/generator').default;const t = require('@babel/types');
// 1. 解析源代码 → ASTconst code = `var a = 1; var b = 2;`;const ast = parser.parse(code, { sourceType: 'module' });
// 2. 遍历 AST,修改特定节点traverse(ast, { VariableDeclaration(path) { if (path.node.kind === 'var') { path.node.kind = 'let'; } },});
// 3. 把 AST → 源代码const output = generate(ast).code;console.log(output); // let a = 1; let b = 2;核心概念解释:
path是当前节点的”路径对象”,它不仅包含node(当前节点),还包含父节点、兄弟节点、作用域等元信息。path.node.kind = 'let'这种”直接修改节点”的方式,称为 Mutating AST——Babel 推荐这种做法,因为它保留了原有节点的位置信息(用于 sourcemap)。@babel/types提供了大量工具函数判断和构造节点,比如t.isIdentifier(node)、t.identifier('foo')。
2.2 遍历的入口:Visitor 模式
Babel 的 traverse 接收一个”访问者对象”,对象的方法名就是”要访问的节点类型”。这是一种典型的 Visitor 模式:
traverse(ast, { // 访问所有函数声明 FunctionDeclaration(path) { /* ... */ },
// 访问所有箭头函数 ArrowFunctionExpression(path) { /* ... */ },
// 访问所有二元运算 BinaryExpression(path) { /* ... */ },
// enter / exit:进入和离开节点时各触发一次 CallExpression: { enter(path) { /* 进入调用表达式时 */ }, exit(path) { /* 离开调用表达式时 */ }, },});每个回调都会接收一个 path 对象。path 上常用的方法:
| 方法 | 作用 |
|---|---|
path.node | 当前节点 |
path.parent | 父节点 |
path.scope | 当前作用域(用于变量重命名、查找引用) |
path.replaceWith(newNode) | 替换当前节点 |
path.insertBefore(newNode) / path.insertAfter(newNode) | 插入兄弟节点 |
path.remove() | 删除当前节点 |
path.skip() | 跳过子树遍历 |
掌握了这些,你就拥有了”按规则改写代码”的能力。接下来看 4 个真实可用的实战插件。
三、实战一:自动埋点插件
需求:给所有 <button> 元素的 onClick 事件自动加上 track('button-click', { label }) 埋点。
完整代码:
module.exports = function autoTrackPlugin({ types: t }) { return { visitor: { JSXOpeningElement(path, state) { const attrs = path.node.attributes; const tagName = path.node.name.name;
// 只处理 <button>,且已经有 onClick 的情况 if (tagName !== 'button') return; const onClick = attrs.find(a => t.isJSXAttribute(a) && a.name.name === 'onClick'); if (!onClick || !t.isJSXExpressionContainer(onClick.value)) return;
// 提取按钮的 label(如果存在 type="submit" 之类的,用 name 代替) const labelAttr = attrs.find(a => t.isJSXAttribute(a) && a.name.name === 'aria-label'); const label = labelAttr ? labelAttr.value.value : 'unknown';
// 构造 track() 调用 const trackCall = t.callExpression( t.identifier('track'), [ t.stringLiteral('button-click'), t.objectExpression([ t.objectProperty(t.identifier('label'), t.stringLiteral(label)), ]), ], );
// 把原始 onClick 包一层:原 onClick + track const originalHandler = onClick.value.expression; const wrapped = t.arrowFunctionExpression( [], t.blockStatement([ t.expressionStatement(originalHandler), t.expressionStatement(trackCall), ]), );
onClick.value = t.jsxExpressionContainer(wrapped); }, }, };};使用方式(babel.config.js):
module.exports = { plugins: ['./babel-plugin-auto-track.js'],};转换前:
<button aria-label="提交表单" onClick={handleSubmit}>提交</button>转换后:
<button aria-label="提交表单" onClick={() => { handleSubmit(); track("button-click", { label: "提交表单" }); }}>提交</button>注意:真实场景里你大概率会跳过已存在的 track 调用、跳过 disabled 按钮、跳过
<button type="submit">等。这里只展示核心 AST 改造逻辑。
四、实战二:按需引入插件(unplugin-style)
需求:把 import { Button, Input } from 'my-ui' 转换成 import Button from 'my-ui/Button',从而支持按需引入。
module.exports = function onDemandPlugin({ types: t }) { return { visitor: { ImportDeclaration(path, state) { const source = path.node.source.value; // 只处理来自 'my-ui' 的导入 if (source !== 'my-ui') return;
const specifiers = path.node.specifiers; const newImports = specifiers .filter(s => t.isImportSpecifier(s)) .map(s => { const importedName = s.imported.name; const localName = s.local.name; return t.importDeclaration( [t.importDefaultSpecifier(t.identifier(localName))], t.stringLiteral(`my-ui/${importedName}`), ); });
path.replaceWithMultiple(newImports); }, }, };};转换前:
import { Button, Input } from 'my-ui';转换后:
import Button from "my-ui/Button";import Input from "my-ui/Input";这种”按需引入”是 Element Plus、Ant Design 等组件库生态里的核心机制。Vite 生态里的 unplugin-vue-components 用的就是类似的 AST 思路,只不过它更进一步做了运行时解析和缓存。
五、实战三:国际化文案提取
需求:把代码里所有中文字符串字面量提取出来,替换成 i18n.t('xxx') 形式,并生成一个映射文件。
完整代码(约 80 行):
const fs = require('fs');const path = require('path');
module.exports = function i18nExtractPlugin({ types: t }) { const messages = {}; let counter = 0;
return { visitor: { StringLiteral(p, state) { // 跳过 import/export 中的模块路径 if (p.parent.type === 'ImportDeclaration' || p.parent.type === 'ExportNamedDeclaration') return; // 跳过 <div title="..."> 里的字符串(如果只想做 UI 文案) if (p.parent.type === 'JSXAttribute') return; // 跳过对象 key if (p.parent.type === 'ObjectProperty' && p.parent.key === p.node) return;
const value = p.node.value; // 只处理包含中文的字符串 if (!/[\u4e00-\u9fa5]/.test(value)) return;
// 生成 key(用文件路径 + 行号 + 序号) const filename = state.filename || 'unknown'; const key = `${path.basename(filename, path.extname(filename))}#${p.node.loc.start.line}#${counter++}`; messages[key] = value;
// 替换为 i18n.t(key) p.replaceWith(t.callExpression(t.identifier('i18n.t'), [t.stringLiteral(key)])); }, }, post() { // 所有文件处理完后输出映射 const outPath = path.join(process.cwd(), 'i18n-messages.json'); let existing = {}; if (fs.existsSync(outPath)) { try { existing = JSON.parse(fs.readFileSync(outPath, 'utf8')); } catch {} } fs.writeFileSync(outPath, JSON.stringify({ ...existing, ...messages }, null, 2)); }, };};转换前:
const greeting = '你好,世界';showToast('提交成功');转换后:
const greeting = i18n.t("app.js#1#0");showToast(i18n.t("app.js#2#1"));同时生成 i18n-messages.json:
{ "app.js#1#0": "你好,世界", "app.js#2#1": "提交成功"}进阶方向:真实工程里会把 key 设计成哈希、绑定翻译平台的 webhook、自动生成 TypeScript 类型等。
六、实战四:TypeScript 装饰器降级
需求:把 experimentalDecorators 编译成旧版装饰器(旧版语义)。这是 ts-loader 和 Babel 都不直接支持、但社区又需要的功能。
module.exports = function legacyDecoratorPlugin({ types: t }) { return { visitor: { ClassDeclaration(path) { const decorators = path.node.decorators; if (!decorators || decorators.length === 0) return;
// 装饰器降级的核心思路:把类包装成函数, // 然后用 Object.defineProperty 注入到原型上 const className = path.node.id.name; const classBody = path.node.body.body;
decorators.forEach((dec) => { if (!t.isDecorator(dec)) return; const decoratorExpr = dec.expression; if (!t.isCallExpression(decoratorExpr)) return;
const decoratorFn = decoratorExpr.callee; const decoratorArgs = decoratorExpr.arguments;
// 处理方法装饰器:@Log('info') foo() {} classBody.forEach((member) => { if (!t.isClassMethod(member) && !t.isClassProperty(member)) return; if (!member.decorators || member.decorators.length === 0) return;
const methodDecorator = member.decorators[0]; if (!t.isDecorator(methodDecorator)) return;
const methodDecExpr = methodDecorator.expression; if (!t.isCallExpression(methodDecExpr)) return;
const targetKey = t.stringLiteral(member.key.name); const descriptor = t.objectExpression([ t.objectProperty(t.identifier('value'), member.value || t.functionExpression(null, [], t.blockStatement([]))), t.objectProperty(t.identifier('writable'), t.booleanLiteral(true)), t.objectProperty(t.identifier('configurable'), t.booleanLiteral(true)), ]);
// 替换为:装饰器(类, 方法名, 属性描述符) methodDecExpr.arguments = [ t.identifier('target'), targetKey, descriptor, ]; member.decorators = null; member.value = t.callExpression(methodDecExpr.callee, methodDecExpr.arguments); }); });
path.node.decorators = null; }, }, };};装饰器降级是 TypeScript 5.0 之前 experimentalDecorators: true 编译的默认行为,原理是 Stage 1 装饰器规范。Babel 官方不直接支持这种降级,但通过 AST 完全可以自己实现——这就是 AST 能力的体现:只要你理解规则,就没有 Babel 做不到的事。
七、Babel vs SWC:架构与选型
到这里我们已经看过 4 个 Babel 插件。但你一定听过 SWC——它是 Next.js、TurboPack、Deno 都在用的”新一代 JS/TS 编译器”,速度比 Babel 快 20 倍以上。那 SWC 和 Babel 到底有什么不同?
7.1 架构差异
| 维度 | Babel | SWC |
|---|---|---|
| 实现语言 | JavaScript | Rust |
| 解析速度 | 中等(~50MB/s) | 极快(~500MB/s) |
| 插件系统 | 强大的 JS 插件生态 | Rust 原生插件 + WASM 扩展 |
| AST 规范 | 自有扩展 ESTree | 自有 AST(与 ESTree 接近) |
| 多线程 | 受限于 Node.js 单线程 | 原生并行 |
| TypeScript 类型 | 仅语法层面 | 仅语法层面 |
| 生态成熟度 | 极高,生态完整 | 快速发展中 |
7.2 API 差异:SWC 写插件也很快
SWC 写插件的代码量通常比 Babel 少一半(因为 Rust 表达力强)。一个等价的”var 转 let”:
use swc_core::{ecma::{ast::VarDecl, visit::VisitMut, visit::VisitMutWith}, plugin::{plugin_transform, proxies::TransformPluginProgramMetadata}};
pub struct VarToLet;
impl VisitMut for VarToLet { fn visit_mut_var_decl(&mut self, n: &mut VarDecl) { n.kind = swc_core::ecma::ast::VarDeclKind::Let; n.visit_mut_children_with(self); }}
#[plugin_transform]pub fn process_transform(program: &mut Program, _metadata: TransformPluginProgramMetadata) -> Program { program.visit_mut_with(&mut VarToLet); program}看起来 Rust 代码多,但 SWC 提供了完整的 Wasm 绑定——你也可以用 JS 通过 @swc/core 写插件:
const swc = require('@swc/core');
swc.transformSync('var a = 1;', { jsc: { parser: { syntax: 'ecmascript' }, transform: { plugins: [ // 这里用 Rust 写好的 wasm 插件 ['@swc/plugin-var-to-let', {}], ], }, },});7.3 选型建议
| 场景 | 推荐 | 理由 |
|---|---|---|
| 业务项目 / 工具库 | SWC | 速度极快,配置简单,Vite/Next 默认用它 |
| 需要大量自定义插件 | Babel | 生态成熟,JS 插件开发快、调试方便 |
| Monorepo / CI 编译 | SWC | 编译时间决定 CI 时长 |
| 编辑器集成(VSCode) | TypeScript Language Service | Babel/SWC 都不直接做 IDE 提示 |
| 极致定制 + 速度都要 | SWC + 自定义 Rust 插件 | 学习曲线陡,但收益最大 |
经验法则:先用 SWC 跑通 80% 的需求,剩下 20% 的边角情况用
unplugin体系或者回到 Babel。Next.js 15 已经全面转向 SWC,但通过babel-plugin-istanbul、babel-plugin-remove-graphql-query等”插件逃生舱”保留了扩展能力。
八、调试与避坑指南
8.1 打印 AST:最快的方法
console.log(JSON.stringify(path.node, null, 2));// 或者用 @babel/parser 的 errorRecovery 模式保留错误节点的 ASTparser.parse(code, { errorRecovery: true });8.2 常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
RangeError: Maximum call stack | 无限递归的 visitor | 用 path.skip() 或检查父节点类型 |
| 修改后 sourcemap 错乱 | 直接 path.node.x = ... 但没改位置 | 用 path.replaceWith(t.identifier(...)) 保留位置 |
| 类型断言报错 | AST 节点不完整 | 用 @babel/types 提供的构造器,别手写对象 |
| 插件没生效 | 插件没被加载 | 检查 babel.config.js 的 plugins 数组、确保没被 overrides 排除 |
8.3 单元测试插件
babel-plugin-tester 是测试 Babel 插件的事实标准:
import pluginTester from 'babel-plugin-tester';import myPlugin from '../src/babel-plugin-auto-track';
pluginTester({ plugin: myPlugin, tests: { 'should track button click': { code: `<button onClick={foo} aria-label="提交">x</button>`, output: `<button onClick={() => { foo(); track("button-click", { label: "提交" }); }} aria-label="提交">x</button>`, }, },});九、总结
| 层次 | 关键能力 | 工具 |
|---|---|---|
| 理解 AST | 看懂树结构、判断节点类型 | AST Explorer、ESTree 规范 |
| Babel 三件套 | 解析、遍历、生成 | @babel/parser + @babel/traverse + @babel/generator |
| 写生产插件 | 自动埋点、按需引入、i18n、装饰器降级 | @babel/types + babel-plugin-tester |
| SWC 替代 | 速度优先 + Rust 插件 | @swc/core + Rust 写 wasm 插件 |
| 选型 | 业务用 SWC,深度定制用 Babel | 性能 vs 生态的权衡 |
AST 不是前端工程师的”必修课”,但它是分水岭。普通工程师用现成插件,资深工程师能根据业务自己造工具。
延伸阅读
- Babel 官方手册 —— 永远的第一手资料
- AST Explorer —— 实时可视化 AST 结构
- ESTree 规范 —— 理解 JS AST 的源头
- SWC 官方文档 —— Rust 编译器的世界
- unplugin 体系 —— 写一次插件,Vite/Webpack/Rollup 通用
- jscodeshift —— Facebook 的代码重构工具
- ts-morph —— 带类型信息的 TypeScript AST 库
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!