前言
如果你写过 JavaScript,大概率接触过 Babel、ESLint、Prettier 这些工具。你有没有好奇过——它们是怎么理解你的代码的? 一个 .js 文件在它们眼里并不是一行行字符串,而是一棵结构清晰的树。这棵树,就是 **AST(Abstract Syntax Tree,抽象语法树)**。
这篇文章会从零开始,带你一步步理解 AST 是什么、JS 引擎如何生成 AST、AST 的节点长什么样,以及如何用代码亲手解析和遍历一棵 AST。读完你就能明白:那些「看懂你代码」的工具,底层到底在做什么。
一、什么是 AST?为什么需要 AST?
1.1 从字符串到结构
先看一行代码:
const name = 'lin';
对人类来说,这行字的意思很明确:声明一个常量 name,赋值为字符串 `'lin'`。
但对计算机来说,它只是一串字符:c、`o`、`n`、`s`、`t`、` `、`n`、`a`、`m`、`e`…… 计算机需要一种结构化的表示,才能真正「理解」这段代码的语义。
AST 就是这种结构化表示。 它把源代码转换成一棵树,树上的每个节点都代表代码中的一个语法结构。
上面那行代码的 AST 大致长这样(简化):
Program
└── VariableDeclaration (const)
├── VariableDeclarator
│ ├── Identifier (name)
│ └── StringLiteral ('lin')
1.2 为什么需要 AST?
直接操作字符串来分析代码极其脆弱。考虑这个场景——你想找出代码中所有 import 语句:
// 字符串匹配方式——极易误判
const isImport = line.trim().startsWith('import ');
这段代码会把注释里的 import 也匹配上,会漏掉换行的 `import`,会被字符串里的 `import` 干扰。字符串没有结构信息。
AST 则不同——每个节点都知道自己的类型和位置。遍历 AST 时,你只需要问「这个节点是不是 ImportDeclaration?」即可,稳、准、狠。
**一句话总结:AST 把「代码字符串」变成了「可编程的树形数据结构」。**
二、JavaScript 引擎的编译流程
我们天天用 V8(Chrome/Node.js)、SpiderMonkey(Firefox)这些引擎跑 JS,它们内部处理代码的过程大致分四步:
源代码 → 词法分析 → 语法分析 → AST → 字节码 → 执行
2.1 词法分析(Lexical Analysis)
词法分析器把源代码拆成一堆 Token(词法单元)。
const age = 18;
拆成 Token 后:
Token: KEYWORD (const)
Token: IDENTIFIER (age)
Token: OPERATOR (=)
Token: NUMBER (18)
Token: PUNCTUATOR (;)
每个 Token 还记录了位置信息(行号、列号),方便报错时定位。
2.2 语法分析(Syntactic Analysis)
语法分析器拿 Token 列表,根据 JavaScript 的语言规则(语法规范),把它们组装成 AST。
如果 Token 序列不符合语法规则——比如 const 18 = age——语法分析器就会抛出一个 SyntaxError:
SyntaxError: Unexpected number
这就是你写错了语法时看到的错误。
2.3 AST → 字节码 → 执行
生成 AST 之后,引擎(比如 V8)会把它编译成字节码(Bytecode),然后解释执行或进一步编译为机器码。对我们前端开发者来说,最值得关注的环节就是 AST 的生成——因为几乎所有代码分析工具都工作在这一层。
三、AST 节点类型详解
AST 的节点类型非常多,但可以归纳为几大类。我们以 @babel/parser 生成的 AST(遵循 Babel AST 规范)为例。
3.1 Program —— 根节点
每个 JS 文件对应的 AST 顶层都是 Program 节点。它有两个关键属性:
- `body`:文件中的语句列表(数组)
- `sourceType`:`"script"` 或 `"module"`
// 对应 AST
{
type: "Program",
sourceType: "module",
body: [ ... ]
}
3.2 Statement(语句)
语句是执行操作的独立单元,不产生值。常见类型:
| 节点类型 | 说明 | 示例 |
|---------|------|------|
| ExpressionStatement | 表达式语句 | `a + b;` |
| IfStatement | if 语句 | `if (x) {}` |
| ForStatement | for 循环 | `for (;;) {}` |
| WhileStatement | while 循环 | `while(x) {}` |
| ReturnStatement | return | `return x;` |
| TryStatement | try/catch | `try {} catch {}` |
3.3 Expression(表达式)
表达式会产生一个值,可以嵌套在其他表达式或语句中:
| 节点类型 | 说明 | 示例 |
|---------|------|------|
| Identifier | 标识符 | `name` |
| StringLiteral | 字符串字面量 | `'hello'` |
| NumericLiteral | 数字字面量 | `42` |
| BooleanLiteral | 布尔字面量 | `true` |
| ArrayExpression | 数组 | `[1, 2]` |
| ObjectExpression | 对象 | `{ a: 1 }` |
| BinaryExpression | 二元运算 | `a + b` |
| CallExpression | 函数调用 | `foo()` |
| MemberExpression | 成员访问 | `obj.prop` |
| ArrowFunctionExpression | 箭头函数 | `() => {}` |
3.4 Declaration(声明)
声明会引入变量、函数、类等:
| 节点类型 | 说明 | 示例 |
|---------|------|------|
| VariableDeclaration | 变量声明 | `const a = 1` |
| FunctionDeclaration | 函数声明 | `function foo() {}` |
| ClassDeclaration | 类声明 | `class Foo {}` |
| ImportDeclaration | import | `import x from 'y'` |
| ExportDefaultDeclaration | export default | `export default {}` |
3.5 一个完整的 AST 示例
看一段代码和它对应的 AST 结构:
function greet(name) {
return `Hello, ${name}!`;
}
简化后的 AST:
Program
├── body[0]: FunctionDeclaration
│ ├── id: Identifier (greet)
│ ├── params: [Identifier (name)]
│ └── body: BlockStatement
│ └── body[0]: ReturnStatement
│ └── argument: TemplateLiteral
│ ├── quasis: [TemplateElement ("Hello, "), TemplateElement ("!")]
│ └── expressions: [Identifier (name)]
每个节点都有 type 字段标识类型,再加上特定类型的专有属性(如 `FunctionDeclaration` 有 `id`、`params`、`body`)。
四、常用 AST 解析工具对比
JavaScript 生态中有多个 AST 解析器,它们的共同点是:输入代码字符串,输出 AST。
| 解析器 | 维护方 | AST 规范 | 特点 |
|--------|-------|---------|------|
| @babel/parser(原 Babylon)| Babel 团队 | Babel AST | 生态最广,支持 JSX/TS/Flow,持续更新 |
| acorn | 社区 | ESTree | 轻量快速,支持插件,ESLint 早期使用 |
| esprima | 社区 | ESTree | 老牌解析器,规范参考实现 |
| espree | ESLint 团队 | ESTree(ESLint 定制版)| ESLint 内置,兼容 esprima |
| typescript-estree | TypeScript 团队 | ESTree 扩展 | 专门解析 TypeScript |
如何选择?
- **日常学习和 Babel 插件开发** → `@babel/parser`
- **写 ESLint 规则** → `espree`(ESLint 已内置)
- **追求轻量和性能** → `acorn`
- **解析 TypeScript** → `@typescript-eslint/parser` 或 `@babel/parser`
本文全部使用 @babel/parser,理由很简单:生态最完善,文档最全,支持最新语法。
五、使用 @babel/parser 解析代码并遍历 AST
5.1 安装
mkdir ast-demo && cd ast-demo
npm init -y
npm install @babel/parser @babel/traverse @babel/types
三个包的分工:
| 包 | 作用 |
|----|------|
| @babel/parser | 把代码解析成 AST |
| @babel/traverse | 遍历 AST,访问每个节点 |
| @babel/types | 判断节点类型、构建新节点(在代码生成时会用到) |
5.2 第一个解析示例
// parse-demo.js
const parser = require('@babel/parser');
const code = `
const greeting = 'Hello, World!';
console.log(greeting);
`;
const ast = parser.parse(code, {
sourceType: 'module', // 或 'script'
});
console.log(JSON.stringify(ast, null, 2));
运行:
node parse-demo.js
你会看到一棵完整的 AST 被打印出来。Program 节点包含 body 数组,里面有两个节点:`VariableDeclaration` 和 `ExpressionStatement`。
5.3 使用 @babel/traverse 遍历 AST
直接看 JSON 是不够的,我们要编程式地访问每个节点。@babel/traverse 就是干这个的——它使用**访问者模式(Visitor Pattern)**。
// traverse-demo.js
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const code = `
function double(x) {
return x * 2;
}
const result = double(21);
console.log(result);
`;
const ast = parser.parse(code, { sourceType: 'module' });
traverse(ast, {
// 进入 FunctionDeclaration 节点时触发
FunctionDeclaration(path) {
console.log(`发现函数声明: ${path.node.id.name}`);
},
// 进入 CallExpression 节点时触发
CallExpression(path) {
console.log(`发现函数调用: ${path.node.callee.name}`);
},
// 进入 Identifier 节点时触发
Identifier(path) {
// 只打印变量名,忽略关键字
if (!path.node.name.startsWith('_')) {
console.log(` 标识符: ${path.node.name}`);
}
},
});
运行输出:
发现函数声明: double
发现函数调用: double
发现函数调用: console.log
标识符: double
标识符: x
标识符: x
标识符: 2
标识符: result
标识符: double
标识符: 21
标识符: result
标识符: console
标识符: log
标识符: result
每个访问器函数的参数是 path(NodePath 对象),它包含:
| 属性 | 说明 |
|------|------|
| path.node | 当前 AST 节点 |
| path.parent | 父节点 |
| path.parentPath | 父节点的 Path |
| path.scope | 作用域信息 |
| path.traverse() | 继续遍历子节点 |
| path.replaceWith() | 替换当前节点 |
| path.remove() | 移除当前节点 |
path 的存在让我们可以在遍历时修改 AST——这就是 Babel 插件工作的核心机制。
六、AST 在代码分析中的应用场景
理解了 AST 的生成和遍历,我们就能理解这些工具的本质了:
6.1 代码检查(Linting)—— ESLint
ESLint 将代码解析成 AST,然后遍历 AST 检查是否有违反规则的写法。
例如规则 no-unused-vars:
遍历 VariableDeclarator → 记录声明的变量名
遍历 Identifier → 记录变量引用
最后对比:有没有变量只声明没被引用?→ 报错
6.2 代码格式化(Formatting)—— Prettier
Prettier 将代码解析成 AST,丢弃原有的格式信息(缩进、换行等),然后根据统一的规则**重新生成代码**。
源代码 → AST → 漂亮代码
这保证了无论源码格式多乱,输出结果都一样。
6.3 代码压缩(Minification)—— Terser
Terser 解析 AST 后做一系列优化:
- 把 `const a = 1 + 2` 变成 `const a = 3`(常量折叠)
- 把 `function foo() {}; foo()` 变成 `!function(){}()`(代码体积优化)
- 缩短变量名:`const longVariableName` → `const a`
6.4 代码转译(Transpilation)—— Babel
这是 AST 最经典的应用。Babel 解析源码 → 遍历 AST → 应用插件转换 → 生成新代码。
// 输入:const fn = () => {}; (ES2015+)
// 输出:var fn = function() {}; (ES5)
// 这个过程本质上就是:
// 1. 把 ArrowFunctionExpression 节点替换为 FunctionExpression
// 2. 把 const 替换为 var
6.5 更多应用场景
| 场景 | 例子 |
|------|------|
| 代码统计 | 统计某个函数的调用次数 |
| 自动重构 | 自动把 var 改为 `let/const` |
| 国际化 | 提取代码中所有字符串,替换为翻译函数 |
| 依赖分析 | 找出所有 import 语句,构建依赖图 |
| 代码生成 | 根据 JSON Schema 生成类型定义或接口代码 |
七、完整示例:统计代码中的函数调用
下面我们来写一个完整的实战示例:解析一段代码,统计其中调用了哪些函数以及**调用了多少次**。
// analyze-calls.js
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// 待分析的代码
const code = `
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log('Clicked!', count);
}
return (
);
}
`;
// 1. 解析成 AST
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['jsx'], // 支持 JSX 语法
});
// 2. 遍历计数
const callCount = new Map();
traverse(ast, {
CallExpression(path) {
let functionName = '';
// 处理普通调用:foo()
if (path.node.callee.type === 'Identifier') {
functionName = path.node.callee.name;
}
// 处理成员调用:console.log()
else if (path.node.callee.type === 'MemberExpression') {
const object = path.node.callee.object.name;
const property = path.node.callee.property.name;
functionName = `${object}.${property}`;
}
if (functionName) {
callCount.set(functionName, (callCount.get(functionName) || 0) + 1);
}
},
});
// 3. 输出统计结果
console.log('\n📊 函数调用统计\n');
console.log('函数名'.padEnd(25) + '调用次数');
console.log('-'.repeat(35));
const sorted = [...callCount.entries()].sort((a, b) => b[1] - a[1]);
for (const [name, count] of sorted) {
console.log(`${name.padEnd(25)} ${count}`);
}
运行结果:
📊 函数调用统计
函数名 调用次数
-----------------------------------
useState 1
setCount 1
console.log 1
handleClick 1
注意:`
这个例子展示了 AST 分析的核心思路:遍历 → 匹配节点类型 → 提取信息 → 加工输出。几乎所有代码分析工具都遵循这个模式。
八、总结与下期预告
8.1 核心要点回顾
- **AST 是源代码的结构化树形表示**,让程序能够理解代码的语义而不仅是文本
- JS 引擎编译流程:**词法分析 → 语法分析 → AST → 字节码 → 执行**
- AST 节点有固定的类型体系:Program、各种 Statement、Expression、Declaration
- `@babel/parser` 是最常用的 JS 解析器,把代码变成 AST
- `@babel/traverse` 用访问者模式遍历 AST,让我们可以**发现、统计、修改**代码
- ESLint、Prettier、Terser、Babel 等工具的核心都在 AST 层面工作
8.2 思考题
1. 如果给你一个 BinaryExpression 节点,`left` 是 NumericLiteral(1),`operator` 是 `+`,`right` 是 NumericLiteral(2),你能算出它的值吗?
2. 怎么判断一个 CallExpression 是不是 `console.log()` 调用?(提示:看 callee)
3. 如果想把代码中所有的 var 声明改成 `let`,AST 操作应该怎么做?
8.3 下期预告
下一篇我们会深入 Babel 插件开发,手写一个真正的 Babel 插件来自动给 console.log 添加行号信息,敬请期待!
*如果你觉得这篇文章有帮助,欢迎分享给你的朋友。也欢迎在评论区留言讨论你在 AST 学习中遇到的问题。*
*Happy Hacking! 🚀*
评论