前言

如果你写过 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! 🚀*