核心原理(一):抽象语法树(AST)基础

聊到代码格式化工具,很多人第一反应是「不就是加加空格、换换行吗?」

嗯,表面上看确实是这样。但如果你真这么想,那就错过了最核心的东西。

格式化工具之所以能精准地调整每一行代码,靠的不是正则表达式,也不是字符串替换——而是抽象语法树(AST)

什么是抽象语法树?

说白了,AST 就是把代码「拆碎」之后,再按照语法结构重新组装成的一棵树。

我刚开始接触 AST 时,觉得这概念挺玄乎。后来自己动手解析了一段代码,才恍然大悟:原来编译器眼里,代码长这样。

举个例子,下面这段 JavaScript:

const name = '张三'

在 AST 里,它会被拆成这样:

  • 一个 VariableDeclaration(变量声明)节点
  • 里面包含一个 VariableDeclarator(变量声明器)
  • 再往下是 Identifier(标识符)和 StringLiteral(字符串字面量)

你看,代码本身是扁平的文本,但 AST 把它变成了有层次的结构。每个节点都带着自己的类型、位置、值等信息。

核心要点:AST 保留了代码的「语法含义」,去掉了「格式细节」。比如空格、换行、注释(默认情况下)都不会出现在 AST 里。

AST 在格式化中的作用

格式化工具的核心逻辑其实就三步:

  1. 解析:把源码变成 AST
  2. 转换:遍历 AST,调整节点属性(比如缩进、换行位置)
  3. 生成:把修改后的 AST 重新输出为代码字符串

为什么非要绕这么大一圈?

我举个例子你就明白了。假设你要把这段代码格式化:

if(true){console.log('hello')}

如果用正则,你得考虑各种边界情况:花括号位置、空格数量、换行符……写出来的正则又长又容易出错。

但用 AST 就简单了:

  • 找到 IfStatement 节点
  • testconsequent 之间加空格
  • consequentBlockStatement 里加换行和缩进

你看,操作的是「语法结构」,而不是「文本字符」。这就是 AST 的威力。

我的经验:曾经有个项目,需要批量给所有函数加 try-catch。用正则搞了三天,各种 bug。后来换成 AST 方案,半天搞定。从此我写工具必用 AST。

使用 @babel/parser 解析 JavaScript

说到解析 JavaScript 的 AST,Babel 的解析器是绕不开的选择。它基于 @babel/parser(以前叫 Babylon),稳定、兼容性好、社区活跃。

安装很简单:

npm install @babel/parser

基本用法:

const parser = require('@babel/parser')

const code = `const name = '张三'`

const ast = parser.parse(code, {
  sourceType: 'module',
  plugins: ['jsx']
})

console.log(JSON.stringify(ast, null, 2))

跑一下这段代码,你会看到一棵巨大的树。别慌,我刚开始也被吓到了。其实核心就几个字段:

字段 说明
type 节点类型,比如 VariableDeclaration
start / end 节点在源码中的起止位置(字符索引)
loc 行号和列号信息
body 子节点列表(比如函数体里的语句)

这里有个坑,我曾经踩过:

注意:默认情况下,@babel/parser 会丢弃注释。如果你需要保留注释(格式化工具通常需要),记得加 attachComment: true 选项。

正确的做法:

const ast = parser.parse(code, {
  sourceType: 'module',
  attachComment: true,  // 保留注释
  plugins: ['jsx']
})

注释会被挂到相邻的节点上,通过 leadingCommentstrailingComments 访问。

AST 结构图

下面这张图,是我用 SVG 画的,展示了 const name = '张三' 的 AST 结构:

Program body[0] VariableDeclaration declarations[0] kind: "const" VariableDeclarator id: Identifier name: "name" init: StringLiteral value: "张三"

你看,从 Program 根节点往下,一层层展开,每个节点都清晰标注了类型和关键属性。这就是 AST 的「骨架」。

实际应用:遍历 AST

光能解析还不够,我们得能遍历这棵树。最常用的方式是深度优先遍历。

Babel 提供了 @babel/traverse 来帮我们做这件事:

const traverse = require('@babel/traverse').default

traverse(ast, {
  // 进入 VariableDeclaration 节点时触发
  VariableDeclaration(path) {
    console.log('发现变量声明,类型:', path.node.kind)
  },
  // 进入 StringLiteral 节点时触发
  StringLiteral(path) {
    console.log('发现字符串:', path.node.value)
  }
})

这里有个细节:path 对象不仅包含当前节点,还包含父节点、兄弟节点等信息。你可以通过 path.parent 访问父节点,通过 path.getSibling() 访问兄弟节点。

避坑指南:我曾经在遍历时直接修改了 path.node 的属性,结果导致后续遍历出错。正确的做法是用 path.replaceWith()path.insertBefore() 这类 API 来操作节点。

小结

AST 是格式化工具的基石。它把代码从「文本」变成了「结构」,让我们可以精准地操作每一段代码。

@babel/parser 解析 JavaScript,用 @babel/traverse 遍历节点,这就是我们接下来要反复使用的核心套路。

下一节,我会带你亲手写一个最简单的格式化函数——只做一件事:给 if 语句加空格。你会发现,理解了 AST,这件事其实很简单。


专注资料整理