前端 AST 抽象语法树实战:用 Babel/SWC 编写自定义代码转换器

3280 字
16 分钟
前端 AST 抽象语法树实战:用 Babel/SWC 编写自定义代码转换器

你有没有过这样的经历:领导丢过来一段上古 jQuery 代码让你”按团队规范改一改”,你盯着 3000 行文件想死;或者产品要做一个”全自动埋点”,每个按钮点击都要加一行 track('xxx'),你面对 200+ 个页面无从下手;又或者在做国际化时,发现散落在代码各处的中文文案像个定时炸弹,每次都要靠 grep 大法 + 人工核对。

这些场景的共同答案是:AST(抽象语法树)

AST 是现代前端工程化的”心脏”——Babel、SWC、ESLint、Prettier、Vite、Webpack、TypeScript 编译器、Vue/React 的模板编译器……几乎所有”读懂代码并改写代码”的工具,都是基于 AST 工作。掌握它,你就拥有了”反向拿捏”这些工具的能力。

本文会带你从零开始:

  1. 原理层:AST 到底是个什么东西?Babel 和 SWC 为什么能读懂 JS?
  2. API 层:Babel 的 @babel/parser@babel/traverse@babel/generator 三大件怎么协作?
  3. 实战层:4 个生产级代码转换插件——自动埋点、按需引入、国际化文案提取、装饰器降级。
  4. 对比层:Babel vs SWC 的架构差异、API 差异、性能差异与选型建议。

一、AST 是什么:把代码当作一棵树#

1.1 字符串到树的转换#

源代码本质上就是一段字符串。直接对字符串做正则替换是脆弱的——a.b.ca["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
VariableDeclarationlet/const/var 声明kind, declarations
FunctionDeclarationfunction foo() {}id, params, body
ArrowFunctionExpression() => {}params, body
CallExpressionfoo(a, b)callee, arguments
MemberExpressionobj.propobj['prop']object, property, computed
BinaryExpressiona + ba === boperator, left, right
Literal字面量value, raw
TemplateLiteral模板字符串quasis, expressions
ImportDeclarationimport 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

transform-var-to-let.js
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
// 1. 解析源代码 → AST
const 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 }) 埋点。

完整代码:

babel-plugin-auto-track.js
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',从而支持按需引入。

babel-plugin-on-demand.js
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 行):

babel-plugin-i18n-extract.js
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 都不直接支持、但社区又需要的功能。

babel-plugin-legacy-decorator.js
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 架构差异#

维度BabelSWC
实现语言JavaScriptRust
解析速度中等(~50MB/s)极快(~500MB/s)
插件系统强大的 JS 插件生态Rust 原生插件 + WASM 扩展
AST 规范自有扩展 ESTree自有 AST(与 ESTree 接近)
多线程受限于 Node.js 单线程原生并行
TypeScript 类型仅语法层面仅语法层面
生态成熟度极高,生态完整快速发展中

7.2 API 差异:SWC 写插件也很快#

SWC 写插件的代码量通常比 Babel 少一半(因为 Rust 表达力强)。一个等价的”var 转 let”:

swc-plugin-var-to-let/src/lib.rs
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 写插件:

swc-plugin-var-to-let.js
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 ServiceBabel/SWC 都不直接做 IDE 提示
极致定制 + 速度都要SWC + 自定义 Rust 插件学习曲线陡,但收益最大

经验法则:先用 SWC 跑通 80% 的需求,剩下 20% 的边角情况用 unplugin 体系或者回到 Babel。Next.js 15 已经全面转向 SWC,但通过 babel-plugin-istanbulbabel-plugin-remove-graphql-query 等”插件逃生舱”保留了扩展能力。

八、调试与避坑指南#

8.1 打印 AST:最快的方法#

console.log(JSON.stringify(path.node, null, 2));
// 或者用 @babel/parser 的 errorRecovery 模式保留错误节点的 AST
parser.parse(code, { errorRecovery: true });

8.2 常见错误#

错误原因解决
RangeError: Maximum call stack无限递归的 visitorpath.skip() 或检查父节点类型
修改后 sourcemap 错乱直接 path.node.x = ... 但没改位置path.replaceWith(t.identifier(...)) 保留位置
类型断言报错AST 节点不完整@babel/types 提供的构造器,别手写对象
插件没生效插件没被加载检查 babel.config.jsplugins 数组、确保没被 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 不是前端工程师的”必修课”,但它是分水岭。普通工程师用现成插件,资深工程师能根据业务自己造工具。

延伸阅读#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

前端 AST 抽象语法树实战:用 Babel/SWC 编写自定义代码转换器
https://boke.hackerdream.xyz/posts/frontend-ast-babel-swc/
作者
晴天
发布于
2026-06-12
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
155
分类
24
标签
387
总字数
345,424
运行时长
0
最后活动
0 天前

目录