用 acorn 解析真实 JS + 自己写 AST 遍历器

本节目标

从第 3、4 节我们亲手写了解析器,但真实项目里你应该用现成工具。最轻量的是 acorn(webpack、ESLint 底层也用它):

const { parse } = require('acorn')
const ast = parse('const double = (n) => n * 2', { ecmaVersion: 2022 })

acorn 产出的 AST 遵循 ESTree 规范(和 AST Explorer 默认一致),所以你在 Explorer 里看到的节点类型,和代码里拿到的是同一套。

遍历(Traverse) 就是“从根节点出发,按某种顺序访问每一个节点”。最常见的是深度优先:先往下钻到叶子,再往回退。遍历时我们常给每个节点两次机会——进入(enter) 和 离开(exit),像走进一棵树再走出来。

动手 A:用 acorn 解析(需要安装)

// 运行环境:Node.js 18+,需要先安装依赖: npm install acorn
// 运行方式:把整段保存为 app-l5a.cjs,终端执行  node app-l5a.cjs
const { parse } = require('acorn')

const code = 'const double = (n) => n * 2'
const ast = parse(code, { ecmaVersion: 2022, locations: true })
console.log('根节点类型:', ast.type)            // Program
console.log('第 1 条语句:', ast.body[0].type)   // VariableDeclaration
console.log('函数体是箭头函数:', ast.body[0].declarations[0].init.type) // ArrowFunctionExpression

// === 调用示例说明 ===
// parse(code, opts) -> 返回 ESTree 格式的 AST
//   ecmaVersion: 支持到哪个 JS 版本(2022 支持箭头函数等现代语法)
//   locations: true -> 每个节点带 loc(行/列),报错定位用

动手 B:自己写一个通用遍历器(零依赖,重点!)

这个 walk 函数不依赖任何库,能遍历任意 AST(包括 acorn 产出的)。它体现了 Babel 的 visitor 思想雏形:进入节点时调 enter,离开时调 exit。

// 运行环境:Node.js 18+(无需任何依赖)
// 运行方式:把整段保存为 app-l5b.cjs,终端执行  node app-l5b.cjs
// (为省事这里用一颗手写 AST 演示;换成 acorn 的 AST 同样能跑)

// 一颗示例 AST
const ast = {
  type: 'Program',
  body: [{
    type: 'VariableDeclaration', kind: 'const',
    declarations: [{
      type: 'VariableDeclarator',
      id: { type: 'Identifier', name: 'double' },
      init: {
        type: 'ArrowFunctionExpression',
        params: [{ type: 'Identifier', name: 'n' }],
        body: {
          type: 'BinaryExpression', operator: '*',
          left: { type: 'Identifier', name: 'n' },
          right: { type: 'Literal', value: 2 }
        }
      }
    }]
  }]
}

// 通用遍历器:visitor 可包含 enter / exit 两个回调
// 约定:enter(node, parent) 在“进入节点前”调用;exit(node, parent) 在“处理完子节点后”调用
function walk(node, visitor, parent) {
  if (!node || typeof node !== 'object' || !node.type) return
  if (visitor.enter) visitor.enter(node, parent)
  for (const key of Object.keys(node)) {
    if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue
    const val = node[key]
    if (Array.isArray(val)) val.forEach((child) => walk(child, visitor, node))
    else if (val && typeof val === 'object' && val.type) walk(val, visitor, node)
  }
  if (visitor.exit) visitor.exit(node, parent)
}

// === 调用示例 1:统计每种节点出现次数 ===
const counter = {}
walk(ast, {
  enter(node) { counter[node.type] = (counter[node.type] || 0) + 1 }
})
console.log('节点计数:', counter)
// { Program:1, VariableDeclaration:1, VariableDeclarator:1,
//   Identifier:3, ArrowFunctionExpression:1, BinaryExpression:1, Literal:1 }

// === 调用示例 2:用 enter/exit 看遍历顺序(先入后出,像走树)===
walk(ast, {
  enter(node) { console.log('↓ enter', node.type) },
  exit(node) { console.log('↑ exit ', node.type) }
})
// 输出顺序:Program → VariableDeclaration → VariableDeclarator → Identifier(double)
//          → ArrowFunctionExpression → Identifier(n) → BinaryExpression → Identifier(n) → Literal(2)
//          ……然后逐层 exit 退回来

名词解释

遍历(Traverse):从根节点出发,按某种顺序访问树上的每一个节点。最常用的是“深度优先”——先一路往下钻到最深的叶子,再逐层往回退。你可以把它想象成“按照固定路线把一棵树的每个叉点都踩一遍”。

enter / exit(进入 / 离开):遍历时给每个节点两次机会。进入节点之前调用 enter,把它所有子节点都处理完之后调用 exit。就像你走进一棵树、再走出来,所以叫 enter(进)和 exit(出)。很多修改逻辑要“进去时记一笔、出来时再处理”,靠的就是这两阶段。

课后练习

练习 1:用本节手写的 walk 遍历 app-l5 的示例 AST(含 double、n、n),Identifier 节点会被统计到几次?

答案:3 次。分别是变量名 double、箭头函数参数 n、以及二元表达式里的 n。注意:即便两个 n 语义不同(一个是参数、一个是引用),在 AST 层面它们都是 Identifier 节点,各计一次。

练习 2:如果把 walk 里的递归“只处理第一层子节点就返回”(不再对子节点继续 walk),会发生什么?

答案:遍历会不完整——只有根节点的直接子节点被访问,BinaryExpression 内部的 Literal(2)、箭头函数体内的节点等深层节点永远碰不到。这正是递归(持续往深处走)对树遍历必不可少的原因。

本节小结(观点与完整描述)

本节完成了从“手写解析”到“用工具 + 理解遍历”的关键转折。你学到的第一点是:真实项目里别再手写解析器,acorn / @babel/parser 已经把你想不到的边界情况全处理了,直接 parse(code) 拿到 ESTree 规范的 AST 即可。第二点更重要:遍历就是在树上走一遍,而 enter/exit 两次回调让你能“进去时改、出来时再改”,这是几乎所有 AST 操作的基础动作。你亲手写的那 30 行 walk,其实就是下一节 babel-traverse 的“原理缩略版”——下一节用库,写法几乎一样,只是它还会额外把“路径”直接递到你手上。先吃透这个手写版,库用起来才不会是黑盒。