用 acorn 解析真实 JS + 自己写 AST 遍历器
本节目标
- 用成熟的 acorn 把真实 JS 代码解析成 AST(不用再手写解析器)
- 理解“遍历 AST”就是在树上走一遍、对每个节点做点事
- 亲手写一个不依赖任何库的通用遍历器(带 enter / exit 两次回调)
从第 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 的“原理缩略版”——下一节用库,写法几乎一样,只是它还会额外把“路径”直接递到你手上。先吃透这个手写版,库用起来才不会是黑盒。