1. 先弄懂AST到底是什么
AST(Abstract Syntax Tree,抽象语法树)这个词,做前端、做编译器、做IDE插件的人基本天天见。但很多刚接触的同学第一次看到那棵“树”时是懵的:一堆对象、type、start、end、loc……它到底怎么来的?有什么用?怎么改?今天我打算从零开始,把AST的来龙去脉、底层原理、常用工具和实战案例一次讲透。这篇文章偏实操,看完之后你可以自己动手写一个代码转换器,也能在别人聊“Babel插件原理”“ESLint规则怎么写”时,心里有个全景图。
AST本质上就是一段源代码的“结构化表示”。它不是字符串,不是正则匹配出来的结果,而是一棵由节点组成的树。这棵树保留了代码的语法信息,去掉了代码里跟“语义”无关的细节,比如空格、换行、分号(在某些解析器里)等。正是因为这种结构化、可遍历、可修改的特性,AST成了编译、转译、静态分析、代码格式化、代码混淆、IDE智能提示等一切工具链绕不开的底层设施。
1.1 从一个最简单的例子看AST长什么样
先看一行再简单不过的代码:
const a = 1 + 2;这行代码经过解析器处理后,产生的AST大概是下面这个结构(真实结构会根据解析器有细微差别,但核心一致):
{ "type": "Program", "body": [ { "type": "VariableDeclaration", "kind": "const", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "a" }, "init": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Literal", "value": 1 }, "right": { "type": "Literal", "value": 2 } } } ] } ] }你可以看到,整行代码被拆成了节点:Program是根节点,表示“这是一个程序”;body数组里装了一个VariableDeclaration节点,表示“这是一个变量声明”;它又有kind字段,说明是const还是let、var;declarations里是VariableDeclarator,id是变量名a,init是初始值。初始值又是一个BinaryExpression,运算符是+,左右两边分别是Literal(字面量)1和2。
所以AST里的每个节点都回答了两个问题:这部分的“语法类型”是什么,以及它携带了什么信息。节点与节点之间是父子关系,组合起来就是一棵完整的树。
1.2 为什么说AST是“代码的骨架”
我们可以通俗地理解:源代码是一具完整的身体,AST是去掉血肉之后剩下的骨骼。骨骼告诉你“这里有一只手,手上有五根手指”,但不会告诉你皮肤颜色、血管怎么分布。AST同理,它告诉你“这里有一个if语句,条件是一个二元表达式,then分支里有一个函数调用”,但不会关心你写的是两个空格还是一个Tab,也不会关心你用的是单引号还是双引号。
这种抽象非常关键。因为无论你要对代码做什么分析或修改,最怕的就是被“人类书写习惯”干扰。如果直接拿字符串做正则匹配,本质上是“靠猜”,你很难断言这个match到的foo究竟是变量名、函数名、属性名还是注释里的一句话。但AST可以直接告诉你:这个节点类型就是Identifier,它是变量声明里的id,它是二元表达式里的left,它是函数调用里的callee。信息的准确度完全不在一个量级。
我们常说Babel能把ES6+代码转成ES5,原理就是把源代码解析成AST,然后遍历这棵树,把新语法节点替换成旧语法节点,最后再把树重新打印成代码。这个过程里,解析器、遍历器、生成器都不需要“读懂”你的业务逻辑,它们只对树的结构负责。你甚至可以开发一个Babel插件,在遍历到ArrowFunctionExpression时打印一条日志,就能统计出整个项目里有多少箭头函数,准确且快速。这就是AST作为骨架的价值。
1.3 AST和CST、解析树的区别
有时候你会看到“Parse Tree(解析树)”“CST(Concrete Syntax Tree,具体语法树)”这些概念。它们跟AST是近亲,但不一样。解析树/CST保留了源代码中所有语法细节,包括圆括号、逗号、分号、空格等,树的结构几乎和语法产生式一一对应。而AST会进行“抽象化”,丢掉那些只是为了满足语法规则、但对语义没有直接贡献的节点。
举个例子,表达式(a + b) * 2。CST里会有明确的括号节点,因为括号在语法产生式里是单独一层;而AST里通常只有一个BinaryExpression,left是另一个BinaryExpression,括号信息已经隐含在树的层级里了。等生成代码时,再根据运算符优先级决定要不要打印括号。这种抽象让AST更简洁,也更适合做语义分析。
不过“具体到抽象”的边界在不同解析器里并不完全一致。比如有的解析器还会保留注释节点、分号位置等,方便做格式化或代码生成。所以你在AST Explorer里看Babel和TypeScript的AST,会发现节点结构有差异。这本身不是问题,只要理解“AST是经过抽象后的语法树”就够了。
2. 为什么需要AST:它到底解决了什么问题
聊完定义,得说说为什么我们需要这棵树。很多人一开始不理解:明明源码是字符串,我直接对字符串做replace不也能改代码吗?为什么非要多出“解析成AST”这一步?
核心原因是:字符串层面没有结构信息。你想把项目里所有var改成let,用正则替换似乎可行,但遇到注释里的“var”、字符串里的“var”、变量名里的“varLikeThis”就会出现误伤。更不用说做“把parseInt(x)改成Number(x)”这种操作,正则几乎不可能稳健地识别出这是一个函数调用而不是一个字符串。AST把代码变成结构化的数据之后,所有分析都变成了“对树节点的查询和操作”,精准、可组合、可复用。
2.1 编译器和解释器的“分水岭”
在传统编译原理里,编译过程通常被分成前端、中端、后端。前端负责把源代码变成中间表示,中端做各种优化,后端生成目标代码。AST就是编译器前端交给后端的“接力棒”。
对于一门新语言来说,只要你能写出一个解析器,把源码变成AST,后续的很多工具都可以直接复用。ESLint可以检查它,Prettier可以格式化它,Babel可以转译它,IDE可以分析它。这就是为什么很多前端工具都在做“标准化AST格式”的尝试。因为一旦大家都基于同一种AST交流,工具链之间就可以自由组合。像ESTree、PostCSS的CSS AST、TypeScript的Syntax Tree,都是在各自领域承担这个“通用语言”的角色。
所以你可以把AST理解成“工具的巴别塔”。有了它,不同的工具不用各自发明一套代码表示法,而是围绕同一棵树做增量操作。
2.2 盘点AST最常见的应用场景
AST的应用场景比很多人想象中广得多。下面列几个最有代表性的:
- 语法转译:Babel把TS、JSX、ES2020+代码转成目标浏览器能运行的ES5代码。整个过程的核心就是AST节点的替换与生成。
- 静态代码检查:ESLint每一条规则都是一个visitor函数,比如
no-unused-vars会监听Identifier和作用域信息,eqeqeq会监听BinaryExpression判断是否用了==。 - 自动格式化:Prettier解析代码后,直接忽略原有排版,重新根据AST递归打印出新代码,所以它打印出来的格式高度统一。
- 代码压缩与混淆:Terser把AST里的局部变量名改成短名字、删除无用代码、合并重复逻辑,全部基于对树节点的遍历和重写。
- 代码高亮与智能编辑:IDE里的语法高亮、自动补全、跳转到定义,也依赖解析器对文件实时生成AST,再配合作用域分析实现。
- 代码统计和搜索:想统计项目里有多少个函数、多少层嵌套if、有没有直接调用某个“危险函数”,都能通过遍历AST快速实现。
- 自动化重构(Codemod):很多大型框架升级时会提供一个codemod脚本,自动把旧API替换成新API。核心同样是“解析AST → 修改节点 → 重新生成代码”。
可以说,只要是“把代码当作数据处理”的场景,AST都是绕不开的入口。理解了AST,你才能真正理解前端工具链的运行机制,而不是只会跑命令。
3. 从字符串到树再到字符串:AST的完整生命周期
前面讲了概念,接下来进入核心原理部分。一段代码从字符串变成AST,再被修改,再变回代码,一共分四步:词法分析(Tokenization)、语法分析(Parsing)、遍历与修改(Traversal & Transformation)、代码生成(Code Generation)。
这四步是任何基于AST的工具都逃不过的骨架。你不需要自己从零实现一个完整的解析器(除非你做的是研究或偏底层的工具),但了解每一步在做什么是必要的。否则你在写Babel插件时,连“为什么这里要用path.node”都搞不清楚。
3.1 词法分析:把字符串切成Token
词法分析负责把源代码拆成最小的、有意义的单词,也就是Token。Token通常包含类型和值,有时还记录起止位置、行号、列号等。
比如const a = 1;,词法分析后可能会得到这样的Token列表:
Keyword("const") Identifier("a") Punctuator("=") Numeric("1") Punctuator(";")这里const被识别成关键字,a被识别成标识符,=和;被识别成标点符号,1被识别成数字字面量。有些解析器还会把注释也作为Token输出,有些会直接跳过。
你可能会想,这有什么难度?其实难在边界情况。比如1 + +2里的前后两个+含义不同,一个是一元正号,一个是二元加法;字符串"a + b"里的+不应该被当作运算符;模板字符串${foo}里的foo又是一段可执行代码。优秀的Tokenizer需要按语言规范处理这些边界。
不过在实际使用中,你一般不用自己写Tokenizer,直接用现成解析器就好。理解这一步的主要意义在于:当解析报错说“Unexpected token (1:20)”时,你脑子里要知道这是词法/语法阶段发现了无法识别的字符。
3.2 语法分析:把Token流组装成树
语法分析就是把上一步产生的Token流,按照语言的语法规则组装成一棵AST。比如Token流里出现了const,语法分析器就知道这里应该进入“变量声明”的处理分支,接下来读取标识符作为变量名,再往下会期望一个=,然后是表达式,最后是分号。如果Token流不符合语法规则,就会抛出SyntaxError。
语法分析最常用的方法是递归下降(Recursive Descent),也就是为每一种语法结构写一个解析函数,函数之间互相调用。比如解析一个语句时可以这样想:
parseStatement: if 当前Token是 const/let/var: 进入parseVariableDeclaration if 当前Token是 function: 进入parseFunctionDeclaration if 当前Token是 {: 进入parseBlockStatement ...这跟你用手工方式写一个JSON解析器是同一个套路,只是编程语言的语法规则比JSON复杂得多。
对于大部分人来说,不需要深入实现语法分析器,只需要会使用@babel/parser或espree这类现成库。但理解递归下降能做到什么程度很重要:它决定了AST的节点层级,也决定了某些代码在AST里是什么形状。
3.3 遍历与修改:Visitor模式
得到AST之后,我们通常需要遍历所有节点。最直观的方法是写一个递归函数,遍历每个节点的属性,遇到子节点就继续递归。但工程上,我们更常用Visitor(访问者)模式:你只需要声明“我想在进入某种节点时做什么事”,遍历器会在合适的时机回调你。
用Babel举例:
const visitor = { Identifier(path) { console.log(path.node.name); }, BinaryExpression(path) { console.log(path.node.operator); }, };遍历器会从Program根节点开始,按深度优先的顺序一路走。每进入一个节点,如果visitor里有对应的type,就执行回调。path是“节点路径”,它不只是当前节点,还包含父节点、作用域、容器信息,以及一系列操作方法,比如path.replaceWith()、path.remove()、path.insertBefore()。
这里有个新手容易忽略的点:visitor回调函数还可以写成{ enter() {}, exit() {} }形式。enter是在进入节点时触发,exit是在子节点全部处理完之后触发。比如你要删除一个表达式语句,在enter阶段删除没问题;但如果你想判断一个BinaryExpression是否处于“被执行”位置,就需要在父节点层面检查,这时exit阶段会更合适。理解enter/exit的区别,是写复杂插件的基础。
3.4 重新生成代码:从树变回字符串
修改完AST之后,最后一步是把它打印成代码。Babel里用@babel/generator,它的核心逻辑是递归遍历AST,根据节点类型决定输出什么字符串。
生成阶段会做很多细节处理:是否需要加括号、是否需要加分号、属性名是否加引号、换行缩进怎么做、怎么处理注释等。这也是为什么我们不能在AST里随便删掉一个看起来“没用”的节点,因为生成器可能依赖它判断输出。
比如a + b * c这个表达式的AST里,a + b * c是顶层BinaryExpression,如果我们在AST里把它直接改成(a + b) * c的树结构,生成器检查到子表达式的优先级低于父表达式时,就会自动补上括号。这背后的规则比较复杂,所以大部分时候你应该借助工具库而不是自己写生成器。
到这里,你应该已经能串起整条链路:源码 → Token → AST → 修改后的AST → 代码。下面我会讲工具链选型,然后再用真实代码走一遍完整流程。
4. AST工具链选型:Babel全家桶、jscodeshift还是TS编译器API
现在市面上AST工具其实很多,同一个需求可能有多种实现路径。我自己的经验是,先看项目场景,再选工具,不要上来就抄一段别人的Babel插件代码。下面把最常用的三类方案讲清楚。
4.1 Babel全家桶:最通用、生态最好
Babel的AST工具链由几个包组成:
@babel/parser:解析器,负责把源码解析成AST。@babel/traverse:遍历器,负责按visitor模式遍历和修改AST。@babel/types:类型工具库,包含所有AST节点的构造器和判断器,比如t.identifier('foo')可以创建一个Identifier节点,t.isCallExpression(node)可以判断节点是否为函数调用。@babel/generator:生成器,把AST重新打印成代码。@babel/template:模板工具,允许你用占位符构建AST节点,避免手工拼接对象,比如template.statement('const ${name} = ${value};')。
Babel的优点是节点类型和ESTree兼容,文档多,社区案例多,几乎所有前端工具链都基于它。缺点是为了转译场景做了很多额外处理,如果你只想做一个轻量SQL解析器,用它可能不够轻。但如果是做JS/TS代码转换,Babel几乎是最稳妥的选择。
4.2 jscodeshift:专为“批量改写代码”而生
jscodeshift是Facebook开源的一个codemod工具,底层封装了recast和ast-types。recast最大的特点是“能够尽量保留原有代码格式”,它会把源代码和AST关联起来,生成代码时尽可能沿用原始排版,而不是像Babel那样统一格式化。这对于代码迁移、工龄改造、API替换这类场景非常友好,因为你希望diff尽量小,而不是全文件重排。
jscodeshift的API也比直接用Babel更贴近“找东西,改东西”的思维。比如你想把parseInt改成Number,核心代码可以写成:
module.exports = function (fileInfo, api) { const j = api.jscodeshift; const root = j(fileInfo.source); root.find(j.CallExpression, { callee: { type: 'Identifier', name: 'parseInt' }, }).replaceWith((path) => { const args = path.node.arguments; return j.callExpression(j.identifier('Number'), [args[0]]); }); return root.toSource(); };root.find(类型, 过滤器)相当于“查询”,replaceWith相当于“修改”。整个过程写起来很像一个链式查询,比手动递归舒服很多。缺点是它的API跟Babel不太一样,你需要单独熟悉,而且底层用的是recast,如果要改造成自定义生成逻辑会稍微绕一些。
4.3 TypeScript Compiler API:要处理TS类型信息时的首选
如果你要处理的代码本身是TypeScript,并且你需要利用类型信息(比如“这个变量是string类型”),Babel和jscodeshift的普通模式就不够用了,因为它们默认不进行类型检查。这时就该上TypeScript自带的编译器API。
示例:用TS API把一段TS源码解析成AST并打印节点类型。
import ts from 'typescript'; const source = 'const a: number = 1;'; const sourceFile = ts.createSourceFile( 'test.ts', source, ts.ScriptTarget.ES2015, true ); function visit(node: ts.Node) { console.log(ts.SyntaxKind[node.kind]); ts.forEachChild(node, visit); } visit(sourceFile);TS AST的节点类型非常多,比如VariableStatement、VariableDeclarationList、Identifier等等。如果你想做“根据类型信息删除未使用变量”,TS Compiler API能拿到TypeChecker,这是Babel做不到的。缺点是学习曲线陡,API风格偏底层,文档也不是很好读。
4.4 三大方案怎么选
下面这张表是我自己选型时反复用到的对比,直接贴出来供参考:
| 方案 | 生态 | 保留原格式 | 类型信息 | 学习成本 | 适用场景 |
|---|---|---|---|---|---|
| Babel全家桶 | 非常好 | 一般,会规范化格式 | 不支持(除非用babel-plugin-transform-typescript,但也不做类型检查) | 中 | 转译、自定义插件、常规代码转换 |
| jscodeshift + recast | 较好 | 很好 | 不支持 | 中低 | 批量重构、API迁移、codemod |
| TypeScript Compiler API | 好 | 一般 | 支持 | 高 | 需要类型信息的分析、重构、语言服务 |
如果只是写一次性脚本改一批文件,我推荐jscodeshift。如果你要写一个可维护的Babel插件,直接用Babel全家桶。如果你要做VS Code插件或者复杂的TS重构,考虑TS Compiler API。最差的方案是“看到一个需求,三种工具各写一段代码拼凑”,那样只会增加心智负担。
5. 实战:用AST实现一个自动化代码转换器
前面讲了那么多原理,现在进入动手环节。这一节我会带你把“源码 → AST → 修改 → 生成代码”完整跑一遍,目标是写一个非常实用的转换器:把形如parseInt("123", 10)的调用,统一替换成Number("123")。
为什么选这个例子?因为它足够生动,且能引出一个关键边界:parseInt支持进制参数(radix),如果调用时传了第二个参数,我们不能盲目转换,否则可能改变代码行为。这个边界处理会强迫我们学会读取AST节点的子节点并做判断。
5.1 先明确需求和边界
我们约定:
- 只替换
parseInt(expr)且只传一个参数的情况,改为Number(expr)。 - 如果
parseInt(expr, radix)传了两个参数,则原样保留,但打出一条警告日志。 - 只处理全局
parseInt调用,也就是callee是Identifier("parseInt")的情况。如果代码里有人写了window.parseInt或者本地自定义了一个foo.parseInt,我们都不动,避免误伤。 - 保留原有代码格式尽量不变。
这个需求如果不用AST,靠正则做非常容易出错。用AST之后,核心逻辑其实只有几步。
5.2 环境准备和依赖安装
我先用Babel全家桶做一遍。新建目录并初始化:
mkdir ast-codemod-demo cd ast-codemod-demo npm init -y npm install @babel/parser @babel/traverse @babel/generator @babel/types --save这里要注意,@babel/traverse在较新版本里同时支持CommonJS和ESM,但默认导出有所不同。我下面会用CommonJS的写法,如果你用的是ESM,注意import traverse from '@babel/traverse'可能会变成import traverseModule from '@babel/traverse'; const traverse = traverseModule.default的形式。这类默认导出问题本身也是新手常踩的坑,后面会提到。
5.3 读取文件并解析成AST
首先写一个入口脚本transform.js,读取我们准备的源码文件demo.js:
const fs = require('fs'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const t = require('@babel/types'); // 读取源代码 const code = fs.readFileSync('demo.js', 'utf-8'); // 解析成AST const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx'], // 如果代码里有JSX,记得加这个插件 });这里有个容易被忽略但很实用的点:parser.parse返回的根节点就是Program,它本身也是一个普通节点。sourceType必须根据源代码类型设置。代码里有import/export就设为'module',没有就设置成'script'或干脆不填,否则某些语法会被判定为非法。
5.4 遍历AST,找到parseInt调用并做替换
现在编写核心遍历逻辑:
traverse(ast, { CallExpression(path) { const node = path.node; const callee = node.callee; // 只处理 callee 是 Identifier 且 name 为 parseInt 的情况 if (!t.isIdentifier(callee, { name: 'parseInt' })) { return; } const args = node.arguments; // 如果参数数量不等于1,跳过(保留原代码) if (args.length !== 1) { if (args.length > 1) { console.warn(`跳过 ${callee.name},因为参数数量不匹配: ${path.toString()}`); } return; } // 构造 Number(expr) 调用 const replacement = t.callExpression( t.identifier('Number'), [args[0]] ); // 这里用 replaceWith 替换原来的调用节点 path.replaceWith(replacement); }, });这段代码有三个关键点:
第一,t.isIdentifier(callee, { name: 'parseInt' })是“带条件的类型判断”,它会先判断callee是否为Identifier,再判断name是不是parseInt。比先isIdentifier再单独判断name更简洁。
第二,args[0]是第一个参数对应的AST节点。我们在构造Number(expr)时,直接复用了这个节点对象。这其实是一个常见操作的“正常版”,因为一个AST节点在同一时间只应该有一个父节点,你把它挂到新节点上,旧位置就自然断开了。但如果同一个节点对象被挂到两个新位置,就会导致“同一个节点出现在两个父节点里”这种脏问题,后面我会在常见问题里展开。
第三,path.replaceWith是“安全替换”的推荐方式,而不是path.node = replacement。replaceWith会帮助处理父节点数组的更新、重新绑定、悬挂的注释等,比手动赋值靠谱得多。
5.5 重新生成代码并写回文件
遍历并修改完AST后,调用生成器得到新代码:
const output = generate(ast, { retainLines: false, comments: true, }, code); fs.writeFileSync('output.js', output.code, 'utf-8'); console.log('完成,输出文件:output.js');generate的第二个参数可以控制注释和格式。默认情况下它会按自己的规则重新缩进,如果你希望保留原始行号,可以设置retainLines: true,但这样生成的代码可能缩进比较怪。更多时候我们只用默认选项就好。
5.6 跑通一次完整转换
为了验证效果,准备demo.js:
const a = parseInt("42", 10); const b = parseInt("42"); const c = window.parseInt("42"); const d = myUtil.parseInt("42", 16);运行:
node transform.js你会得到output.js:
const a = parseInt("42", 10); const b = Number("42"); const c = window.parseInt("42"); const d = myUtil.parseInt("42", 16);同时命令行会输出一句警告,提示跳过了一个传了radix参数的调用:
跳过 parseInt,因为参数数量不匹配: parseInt("42", 10)这个结果符合预期:只有全局parseInt且只传一个参数时被替换,其他情况都安全跳过。这就是AST方案和正则替换最大的区别——我们是真正“看懂”了代码结构后做的决策。
6. 常见问题与排查技巧
写AST相关工具,最大的感受是“报错信息不够友好”。很多时候你知道AST结构应该是怎么样的,但工具就是行为异常,或者直接抛一个让人摸不着头脑的错。这一节我整理几个自己踩过多次的坑,希望你能提前避开。
6.1 解析失败:Unterminated string constant / Unexpected token
这类错误绝大多数是解析器的配置没跟代码匹配。最常见的有几种:
- 代码里用了
import/export,但你没有设置sourceType: 'module'。 - 代码里用了JSX,但
plugins里没加'jsx'。 - 代码里用了TypeScript语法,但没有引入
'typescript'插件(Babel里是plugins: ['typescript'])。 - 代码里用了较新的ECMAScript提案语法,需要额外插件,比如
decorators、classProperties。
排查思路很简单:从报错信息里拿到行列号,去看那个位置的语法是不是属于某个新特性,然后去@babel/parser的文档里查对应插件名。多数时候加上对应插件就好了。
6.2 修改节点后,生成结果没有变化
这个我也遇到过。很多人会直接在visitor里写成path.node = something,但这不会生效。原因很多,比如有的属性是数组,你必须用splice或专门的API去替换;有的父节点对子节点类型有约束,简单赋值可能不触发校验。
正确的做法是尽量使用path提供的方法:
- 替换当前节点:
path.replaceWith(newNode)。 - 删除当前节点:
path.remove()。 - 在当前节点前后插入:
path.insertBefore(newNode)、path.insertAfter(newNode)。 - 如果修改的是某个字段,比如
path.node.name = 'newName',那直接改字段通常可以,但要注意是否有其他节点共享同一个对象。
replaceWith在Babel内部会做很多清理工作,它不仅更新了父节点的body数组,还会处理作用域信息、绑定关系等。所以我在实战中基本不会手动splice数组去改节点,而是先看有没有对应API。
6.3 同一种子节点对象被挂到多个父节点,导致出现“幽灵节点”
这是许多新手最难排查的问题。举个例子,如果你想构造一个a + a的AST,可能会写成:
const a = t.identifier('a'); const expr = t.binaryExpression('+', a, a);在Babel里这样做往往不会立刻报错,但它让同一个标识符节点同时出现在了left和right两个位置。这不符合“树”的定义,后续修改其中一个a时,另一个可能也会被影响,因为你改的是同一个对象。解决方法是分别创建两份节点:
const expr = t.binaryExpression( '+', t.identifier('a'), t.identifier('a') );@babel/types的构造函数里很多地方会通过validate校验节点类型,但同对象复用不一定能被校验出来。遇到这种“玄学问题”时,优先检查自己是不是复用了同一个节点对象。
6.4 作用域问题:重命名变量时不能无脑替换
如果你想写工具把某个变量名从foo改成bar,做法看起来很简单:遍历所有Identifier,如果node.name === 'foo'就改成'bar'。但这个方案有一个严重Bug:它会把属性名里的foo也一起改了,比如obj.foo里的foo其实是一个Identifier节点,但它不是变量名。而且它还会把其他作用域里撞名的foo也改了,甚至改变代码语义。
正确的思路是借助作用域信息。在Babel里,每个Identifier节点都对应一个path.scope,你可以先判断这个标识符是不是一个绑定(binding)或者引用(reference),再决定是否重命名。更稳妥的做法是用path.scope.rename('foo', 'bar'),它会只重命名当前作用域内的绑定和引用。
作用域分析是AST工具链里最复杂的一块之一,涉及“声明如何建立绑定”“引用如何解析到绑定”“哪些变量会被遮蔽”等问题。如果你要做修改变量名的工具,强烈建议先读一读Babel Plugin Handbook里关于Scope的章节,不要急着写遍历逻辑。
6.5 调试AST:AST Explorer和临时打印
最后给大家安利一个几乎每天都在用的工具:AST Explorer(astexplorer.net)。它可以在线把JS、TS、CSS、HTML等代码解析成AST,左侧写代码,右侧看树,还能切换不同的解析器和配置。
我遇到陌生节点类型时,第一反应就是扔几行测试代码到AST Explorer里看看形状,再决定用Babel还是jscodeshift。这种方式比自己猜AST结构快太多,尤其适合新手建立直觉。
另外调试时也可以在visitor里临时加一句console.log(path.toString())。path.toString()会返回当前节点对应的源码片段。当你不知道当前path到底对应代码里的哪一部分时,打印它比打印整个AST对象直观得多。
6.6 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 解析报错Unexpected token | 缺少插件或sourceType不正确 | 按语法特性添加plugins或修改sourceType |
| 修改了节点但输出没变化 | 没有使用path的替换/删除API | 改用replaceWith、remove等方法 |
| 生成的代码被重新格式化,diff很大 | Babel生成器默认规范化输出 | 改用jscodeshift/recast,或接受格式变化 |
| 同一节点被多次修改产生异常 | 复用了同一个节点对象 | 每次创建新节点对象,不要共享 |
| 重命名变量时误伤属性或其它作用域 | 没有处理作用域关系 | 使用path.scope.rename或先判断binding/reference |
| 遍历无限递归或栈溢出 | visitor里没有及时停止递归 | 检查是否在enter/exit里反复创建相同节点触发了迭代 |
这些坑,我自己基本都踩过一遍。现在遇到问题,我会从“AST结构对不对”“API用没用对”“节点是否唯一”“作用域信息是否被考虑”这四个角度去检查,90%的问题都能定位。
7. 最后分享几个实战体会,不一定对,但都是真实踩坑换来的
AST刚上手那阵子,我总觉得只要记住节点结构就能写插件。后来发现,节点类型太多了,根本记不完,而且不同解析器还有差异。与其死记硬背,不如熟练掌握AST Explorer和类型判断工具,边查边写。
第二个体会是:能写Babel插件不代表能写好codemod。生产环境里的代码比示例复杂得多——有注释、有装饰器、有奇怪的逗号、有嵌套十几层的箭头函数、有通过对象属性调用的函数。你在AST Explorer里看的是“理想情况”,到了真实项目里要处理各种边界。所以写转换工具时,第一目标不是“转换全部”,而是“安全转换能识别的部分,其余尽量跳过并输出警告”。我们上面那个parseInt例子里的radix分支处理就是这种思路。
第三个体会是:AST是编译之旅的第一步,但远不是全部。真正复杂的工具往往还要做作用域分析、类型推断、控件流分析。这些都是在AST之上叠加更丰富的语义信息。但不管做得多深,“有一棵结实的AST”永远是地基。地基打好了,后面盖楼才有保证。
如果你现在正要开始写第一个Babel插件或codemod,我的建议是:从“替换API调用”这种小需求练手,先跑通“解析→遍历→替换→生成”全链路,再慢慢研究作用域和注释。等你能熟练处理节点替换和边界判断后,回头看编译原理基础概念,会轻松得多。AST这个领域,真的是“理解了就想用,用多了就离不开”。