JavaScript AST 学习笔记
几乎所有编程语言都有 AST,这是编译器/解释器的基础。 Babel 的 visitor 模式有几个让人别扭的地方: Facebook 出品,用于批量自动化修改代码,典型场景是库升级迁移。 先用 AST Explorer 验证节点类型:在写任何遍历逻辑之前,先把目标代码粘进 https://astexplorer.net,选对解析器(babel/parser 或 acorn),点击你关心的代码位置查看节点类型和字段名。手写遍历时节点名打错是最常见的低级错误。 用 path.node 读,用 path.replaceWith 写:path.node 是当前
一、AST 解析工具生态
JS 专用解析器
| 工具 | 说明 |
|---|---|
| Babel | 最常用,支持最新语法和 JSX,生态丰富 |
| acorn | 轻量、标准兼容,Babel/ESLint 底层都用过 |
| espree | ESLint 官方解析器,基于 acorn |
| @typescript-eslint/parser | 专门处理 TypeScript AST |
| oxc-parser | Rust 实现,极速,新兴选择 |
| swc | Rust 实现,比 Babel 快几十倍 |
遍历/操作工具
| 工具 | 说明 |
|---|---|
| recast | 修改 AST 后能保留原始格式,做 codemod 常用 |
| jscodeshift | Facebook 出品,基于 recast,批量 codemod 工具 |
| esquery | 类 CSS 选择器语法查询 AST 节点 |
| ast-types | recast 底层,提供路径遍历和节点类型系统 |
选型参考
| 场景 | 推荐 |
|---|---|
| 通用代码分析/lint | acorn / espree |
| 批量 codemod | jscodeshift + recast |
| TypeScript 项目 | ts-morph |
| 追求极致性能 | swc 或 oxc |
| 需要保留原始格式输出 | recast |
| 类选择器查询 | esquery |
二、多语言 AST
几乎所有编程语言都有 AST,这是编译器/解释器的基础。
| 语言 | 工具 |
|---|---|
| Python | 标准库 ast、libcst(保留格式)、astroid |
| Rust | syn(proc-macro 必备) |
| Go | 标准库 go/ast + go/parser |
| Java/Kotlin | JavaParser、IntelliJ PSI |
| C/C++ | libclang(LLVM 官方) |
| Ruby | parser gem(RuboCop 底层) |
| PHP | nikic/PHP-Parser |
| 通用多语言 | tree-sitter(50+ 种语言) |
tree-sitter 特点
- 核心用 C 编写,极速
- 增量解析,只重新解析变化部分,适合编辑器实时场景
- 生成 CST(具体语法树),保留所有原始信息含空白和注释
- 有错误恢复能力,代码不完整也能解析
- Neovim 语法高亮底层就是 tree-sitter
三、tree-sitter 使用
安装
pip install tree-sitter tree-sitter-languages
基本解析
from tree_sitter_languages import get_parser
parser = get_parser("python") # 支持 javascript、rust、go 等
code = b"""
def hello(name: str) -> str:
return f"hello {name}"
"""
tree = parser.parse(code)
root = tree.root_node
print(root.type) # module
print(root.child_count)
Query 语法(S 表达式,类 CSS 选择器)
query = get_language("python").query("""
(function_definition
name: (identifier) @func_name)
""")
captures = query.captures(root)
for node, name in captures:
print(node.text.decode())
tree-sitter vs Babel 对比
| tree-sitter | Babel | |
|---|---|---|
| 语言支持 | 50+ 种 | 主要 JS/TS |
| 速度 | 极快(C 实现) | 较慢 |
| 树类型 | CST(保留所有) | AST(语义化) |
| 代码修改 | 不方便 | 方便 |
| 查询能力 | 强(Query DSL) | 需手动遍历 |
| 主要用途 | 分析、高亮、搜索 | 转译、修改代码 |
tree-sitter 更适合读,Babel 更适合改。
四、Babel 插件的痛点
Babel 的 visitor 模式有几个让人别扭的地方:
// 结构臃肿,注入对象来源不直观
export default function({ types: t }) {
return {
visitor: {
CallExpression(path, state) {
// path 和 node 傻傻分不清
// t、path、state、scope 关系不直观
}
}
}
}
更简洁的写法(不用插件形式)
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import generate from "@babel/generator"
import * as t from "@babel/types"
const ast = parse(`console.log("hello")`)
traverse(ast, {
CallExpression(path) {
console.log(path.node.callee)
}
})
const { code } = generate(ast)
五、esquery —— 类 CSS 选择器查询
安装
npm install esquery @babel/parser
基本用法
import { parse } from "@babel/parser"
import esquery from "esquery"
const ast = parse(`
foo(1, 2)
console.log("hello")
`)
esquery.query(ast, "CallExpression")
选择器语法速查
// 节点类型
"CallExpression"
// 属性等于某值
"Identifier[name='foo']"
// 嵌套属性访问
"CallExpression[callee.name='foo']"
"CallExpression[callee.object.name='console']"
// 后代(任意层级)
"FunctionDeclaration Identifier"
// 直接子节点
"FunctionDeclaration > Identifier"
// :has 包含
"CallExpression:has([name='foo'])"
// :not 排除
"Identifier:not([name='undefined'])"
// 多个选择器
"FunctionDeclaration, ArrowFunctionExpression"
// 通配
"*[name='foo']"
配合修改
import generate from "@babel/generator"
const nodes = esquery.query(ast, "CallExpression[callee.name='foo']")
nodes.forEach(node => {
node.callee.name = "bar" // 直接改节点属性
})
const { code } = generate(ast)
六、jscodeshift —— 批量 codemod
Facebook 出品,用于批量自动化修改代码,典型场景是库升级迁移。
// transform.js
export default function(fileInfo, api) {
const j = api.jscodeshift
const root = j(fileInfo.source)
root.find(j.CallExpression, {
callee: { name: 'foo' }
})
.forEach(path => {
path.node.callee.name = 'bar'
})
return root.toSource()
}
jscodeshift -t transform.js src/**/*.js
| jscodeshift | Babel transform | |
|---|---|---|
| 定位 | 批量改文件 | 编译时转换 |
| 格式保留 | ✅ recast 保留原始风格 | ❌ 重新生成 |
| 运行方式 | 命令行跑一次 | 每次构建都跑 |
七、ESTree 节点类型参考
ESTree 是 JS AST 的通用规范,Babel/ESLint/acorn 等工具都遵循此格式。
基础节点
Identifier 标识符(变量名、函数名等)
.name "foo"
Literal 字面量(字符串、数字、布尔、null、正则)
.value 实际值
.raw 原始文本 '"hello"'
表达式 Expression
CallExpression 函数调用 foo(a, b)
.callee 被调用对象(Identifier 或 MemberExpression)
.arguments[] 参数列表
MemberExpression 成员访问 obj.prop 或 obj[key]
.object / .property / .computed
AssignmentExpression 赋值 a = b
.operator .left .right
BinaryExpression 二元运算 a + b
.operator .left .right
UnaryExpression 一元运算 !x typeof x
.operator .argument
LogicalExpression 逻辑运算 a && b
.operator .left .right
ConditionalExpression 三元 a ? b : c
.test .consequent .alternate
ArrowFunctionExpression 箭头函数 (x) => x + 1
.params[] .body .async
FunctionExpression 函数表达式 function() {}
.id .params[] .body
NewExpression new 调用 new Foo()
.callee .arguments[]
TemplateLiteral 模板字符串 `hello ${name}`
.quasis[](静态部分) .expressions[](动态插值)
ObjectExpression 对象字面量 { a: 1 }
.properties[]
ArrayExpression 数组字面量 [1, 2, 3]
.elements[]
ThisExpression this
语句 Statement
ExpressionStatement 表达式语句 foo()
.expression
BlockStatement 块 { ... }
.body[]
ReturnStatement return x
.argument
IfStatement if/else
.test .consequent .alternate
ForStatement for (init; test; update) {}
ForInStatement for (x in obj) {}
ForOfStatement for (x of iter) {}
WhileStatement while (test) {}
SwitchStatement switch
.discriminant .cases[]
ThrowStatement throw err
TryStatement try/catch/finally
声明 Declaration
VariableDeclaration var/let/const
.kind .declarations[]
VariableDeclarator
.id(Identifier 或解构) .init(初始值)
FunctionDeclaration function foo() {}
.id .params[] .body .async .generator
ClassDeclaration class Foo {}
.id .superClass .body
模块 Module
ImportDeclaration import { a } from 'b'
.specifiers[] .source
ExportNamedDeclaration export { a }
ExportDefaultDeclaration export default foo
ExportAllDeclaration export * from 'b'
常用 esquery 选择器示例
"CallExpression" // 所有函数调用
"CallExpression[callee.name='foo']" // foo()
"CallExpression[callee.type='MemberExpression']" // obj.method()
"CallExpression[callee.object.name='console'][callee.property.name='log']" // console.log()
"VariableDeclaration[kind='const']" // const 声明
"Literal[typeof value='string']" // 字符串字面量
"ArrowFunctionExpression" // 箭头函数
"FunctionDeclaration[async=true]" // async 函数
"FunctionDeclaration ReturnStatement" // 函数内的 return
八、工具资源
| 资源 | 地址 |
|---|---|
| AST Explorer(Felix Kling 作品) | https://astexplorer.net |
| ESTree 规范 | https://github.com/estree/estree |
| esquery | https://github.com/estools/esquery |
| jscodeshift | https://github.com/facebook/jscodeshift |
| tree-sitter | https://tree-sitter.github.io |
最佳实践
先用 AST Explorer 验证节点类型:在写任何遍历逻辑之前,先把目标代码粘进 https://astexplorer.net,选对解析器(babel/parser 或 acorn),点击你关心的代码位置查看节点类型和字段名。手写遍历时节点名打错是最常见的低级错误。
用 path.node 读,用 path.replaceWith 写:path.node 是当前节点引用,修改它的属性会直接改 AST;path.replaceWith(newNode) 整体替换节点,会触发访问器重新执行。不要混用。
// 正确:替换整个节点
path.replaceWith(t.stringLiteral('replaced'));
// 错误:直接赋值给 path 无效,path 是包装对象不是节点本身
path = t.stringLiteral('replaced');
遍历顺序:先 enter 后 exit:enter(默认)在进入节点时触发,exit 在离开时触发。处理嵌套结构时(如函数内的函数),用 exit 可以保证内层先处理完。
// 处理嵌套函数时,exit 保证由内而外
traverse(ast, {
FunctionExpression: {
exit(path) { /* 先处理内层,再处理外层 */ }
}
});
使用 path.scope 避免变量名冲突:生成新标识符前调用 path.scope.generateUid('name') 而不是直接写死名字,Babel 会自动在当前作用域内保证唯一性。
const uid = path.scope.generateUid('temp'); // 返回 "_temp", "_temp2" 等
批量替换用 path.replaceWithMultiple,不要手动操作 body:直接 push 到 body 数组会跳过路径追踪,导致后续 visitor 看不到新节点。
// 正确:让 Babel 接管插入
path.replaceWithMultiple([stmt1, stmt2, stmt3]);
// 错误:直接修改数组
path.node.body.push(stmt3);
常见陷阱
陷阱:visitor 里修改节点导致无限循环
现象: 遍历卡死,CPU 100%,或报 "Maximum call stack size exceeded"。
原因: 在 enter 中替换节点后,Babel 会重新访问新节点,而新节点满足同一条件,触发再次替换,陷入死循环。
解决: 替换后立即调用 path.skip() 跳过对新节点的访问,或者在处理前加标记检查。
traverse(ast, {
StringLiteral(path) {
if (path.node._processed) return; // 已处理则跳过
const newNode = t.stringLiteral(decode(path.node.value));
newNode._processed = true;
path.replaceWith(newNode);
}
});
陷阱:t.identifier 和字符串混用
现象: 生成的代码出现 "undefined" 字符串,或变量名被加上引号变成字符串字面量。
原因: t.memberExpression(obj, t.identifier('key')) 和 t.memberExpression(obj, t.stringLiteral('key')) 生成的代码不同:前者是 obj.key,后者是 obj["key"]。误用 StringLiteral 做属性名就会出现 obj["undefined"] 这类错误。
解决: 点号属性用 t.identifier,方括号属性用 t.stringLiteral 并设 computed: true。
t.memberExpression(obj, t.identifier('name')) // → obj.name
t.memberExpression(obj, t.stringLiteral('name'), true) // → obj["name"]
陷阱:path.remove() 后继续访问同一 path
现象: 报错 NodePath has been removed,或删除节点后周边逻辑出错。
原因: path.remove() 从 AST 中移除节点,但不会停止当前 visitor 的执行,后续对 path.node 的访问会报错。
解决: path.remove() 之后立刻 return,不要再访问该 path 的任何属性。
traverse(ast, {
EmptyStatement(path) {
path.remove();
return; // 必须立刻 return
}
});