Read JavaScript Source Code, Using an AST
引言
假设你手头有一个遗留下来的“上古”巨型 JavaScript 文件,足足有 70,000 行代码那么长。你迫切地需要用 webpack 或者类似的工具把它拆分开来,但前提是你得先搞清楚它到底向全局作用域暴露了哪些函数和常量。
这时候,不妨让计算机来帮你通读代码,并精准提取出你想要的信息。
这正是抽象语法树(AST)大显身手的好机会。
(这就好比面对一本几十万字的“天书”,与其自己一行行肉眼看,不如写个程序让电脑帮你快速划重点,而 AST 就是电脑读懂这本“天书”的语法字典!)

接下来的例子虽然规模不大,但你的任务是:如果你愿意接受挑战的话,请把所有暴露在全局作用域中的函数名都提取出来。
(这里其实是致敬了经典美剧《碟中谍》里的经典台词 "Your mission, should you choose to accept it...",给枯燥的代码任务增添了一点史诗感和趣味性!)
// test the code
function decrementAndAdd(a, b) {
function add(c, d) {
return c + d;
}
a--;
b = b - 1;
return add(a,b)
}
// test the code
function incrementAndMultiply(a, b) {
function multiply(c, d) {
return c * d;
}
a++;
b = b + 1;
return multiply(a, b)
}
结果应该是: ["decrementAndAdd", "incrementAndMultiply"].
解析代码
AST 是解析代码后产生的产物。对于 JavaScript 而言,AST 本质上就是一个包含了源代码树状结构的 JavaScript 对象。在使用它之前,我们首先得把它创建出来。而根据我们要解析的代码类型,需要选择合适的解析器(parser)。
在这个例子里,由于代码是兼容 ES5 的,我们可以选用 acorn 这个解析器。
以下是一些最知名的开源 ECMAScript 解析器:
| Parser | Supported Languages | GitHub |
|---|---|---|
| acorn | esnext & JSX (using acorn-jsx) | https://github.com/acornjs/acorn |
| esprima | esnext & older | https://github.com/jquery/esprima |
| cherow | esnext & older | https://github.com/cherow/cherow |
| espree | esnext & older | https://github.com/eslint/espree |
| shift | esnext & older | https://github.com/shapesecurity/shift-parser-js |
| babel | esnext, JSX & typescript | https://github.com/babel/babel |
| TypeScript | esnext & typescript | https://github.com/Microsoft/TypeScript |
所有的解析器原理基本都是一样的,就是给它一段代码,它就返回AST:
// 1. 导入 acorn 库的 Parser 解析器
// acorn 是一个 JavaScript 语法解析器,能把 JS 代码转换成 AST(抽象语法树)
const { Parser } = require('acorn')
// 导入 Node.js 内置的文件读取模块,用于读取本地 JS 文件
const { readFileSync } = require('fs')
// 定义要解析的 JS 文件路径(你可以替换成自己的文件地址)
const fileName = 'test.js'
/**
* 2. 解析 JS 代码生成 AST(抽象语法树)
* readFileSync(fileName):同步读取指定文件的二进制内容
* .toString():把二进制内容转换成字符串格式的 JS 代码
* Parser.parse():调用 acorn 解析器,将 JS 代码解析成标准 AST 对象
* 最终结果赋值给 ast 变量,就是解析后的抽象语法树
*/
const ast = Parser.parse(readFileSync(fileName).toString())
// 可选:打印 AST 查看结果
console.log(ast)
TypeScript 解析器的语法稍微有点不一样。不过,相关的详细说明在这里都能找到。
(言下之意就是,虽然它的写法和前面提到的 acorn 等解析器不太一样,有点小门槛,但别担心,官方文档已经写得明明白白啦,点进去看看就懂!)
这是使用 @babel/parser 解析后得到的树状结构:

// test the code
function decrementAndAdd(a, b) {
return add(a, b)
}
遍历(Traversing)
为了找到我们想要提取的内容,通常最好不要一次性处理整棵 AST。因为哪怕是一小段代码,生成的也是一个包含成千上万个节点的庞大对象。所以,在提取所需信息之前,我们需要先缩小搜索范围。
最好的方法就是只筛选出我们关心的那些节点(原文中用的 tokens,在这里指 AST 中的各个节点)。
同样,市面上有很多工具可以帮我们完成这部分遍历工作。在我们的例子中,将使用 recast。它不仅速度非常快,还有一个巨大的优势:能够保留代码的原始样貌。这样一来,它就能在提取出你想要的那部分代码时,依然保持其原有的格式。
在遍历的过程中,我们会找出所有的函数节点。这就是为什么我们使用了
visitFunctionDeclaration 这个方法。// 引入 recast 库:用于AST代码遍历、修改、重新生成源码,兼容多种解析器
const recast = require('recast');
// 引入 acorn 解析器:用于将JS源码解析生成标准AST抽象语法树
const { Parser } = require('acorn');
// 引入Node.js内置文件系统模块,用于同步读取本地文件
const { readFileSync } = require('fs');
// 读取指定JS文件,转为字符串源码,再通过acorn解析生成AST抽象语法树
const ast = Parser.parse(readFileSync(fileName).toString());
// recast 遍历AST语法树
recast.visit(
// 要遍历的根AST节点
ast,
{
// 专门监听 函数声明节点(function 函数名(){} 这种写法)
visitFunctionDeclaration: (path) => {
// path:当前遍历到的函数声明节点路径对象
// 可在这里编写逻辑:修改函数名、修改函数体、删除节点、替换节点等操作
// return false:停止往当前节点的子层级继续递归遍历
// 只处理当前函数声明节点,不进入函数内部遍历子节点
return false;
}
}
)
AST 节点类型
通常来说,各种节点类型(token types)的名字并不是那么显而易见。这时候,我们可以使用 ast-explorer 来查找我们需要的类型。只需要把你的代码粘贴到左侧面板,选择你正在使用的解析器,然后“锵锵!”(voilà!)。接着在右侧浏览已经解析好的代码,就能轻松找到你正在寻找的节点类型啦。
浅层遍历还是深层遍历
我们并不总是需要查看树的每一层。有时候我们想要进行深度搜索,而有时候只想看看最顶层。根据不同的框架,其语法也会有所不同。不过幸运的是,这些通常都有非常完善的文档说明。
在使用 recast 时,如果我们想在当前深度停止搜索,只需要在处理完后返回
false 即可(这就是我们之前做过的)。如果我们想要继续向下遍历(深入进去),就可以使用 this.traverse(path),就像你接下来会看到的那样。而在使用 @babel/traverse 时,则不需要告诉 babel 该从哪里继续。我们只需要通过
return false 语句来指定在哪里停止就可以了。
// 遍历 AST 抽象语法树
recast.visit(
// 传入根 AST 树节点,作为遍历入口
ast,
{
// 匹配并监听所有【函数声明】节点:function 名称() {}
visitFunctionDeclaration: (path) => {
// path:当前函数声明节点的路径对象,包含节点本身、父节点、增删改API等
// 可在此处对当前函数节点做自定义处理:修改函数名、参数、函数体等
// 手动递归遍历当前函数节点内部的所有子节点
// 等价于继续深入遍历函数体内的语句、变量、表达式等子AST节点
this.traverse(path);
}
}
)
经过这一番筛选,我们成功把搜索范围从大海捞针缩小到了精准的样本。现在,终于可以提取我们需要的数据啦。
(这里的 "broad search" 指的是之前面对整个庞大的 AST 树,而 "smaller sample" 则是经过遍历和过滤后留下的目标节点。意思就是:范围缩小了,接下来就是见证收获的时刻!)
从路径(Path)到节点(Node),再到属性(Property)的导航
传递给
visitFunctionDeclaration 的 path 对象是一个 NodePath。这个对象代表了父节点与子 AST 节点之间的连接关系。这个路径本身对我们来说并没有直接的用处,因为它仅仅代表了函数声明与函数体之间的链接。我们可以借助 ast-explorer 来查找我们真正需要的路径内容。
接下来是常规操作:
path.node。它能获取到父子关系中的那个子节点(Node)。如果你选择搜索的是函数,那么 path.node 中的节点就会是 Function 类型的:(简单来说,
path 就像是一条路或者一个指针,它指向了 AST 树上的某个位置;而 path.node 才是那个位置上真正存放着具体信息的“节点”本身。所以我们要拿到数据,通常都得从 path 走到 path.node。)// 定义数组,用来存放收集到的所有函数声明名称
const functionNames = [];
// 使用 recast 遍历整个 AST 抽象语法树
recast.visit(
// 遍历的入口:整个代码生成的AST根节点
ast,
{
// 专门匹配所有 普通函数声明节点:function 函数名() {}
visitFunctionDeclaration: (path) => {
// 打印当前AST节点的类型
// 函数声明节点固定类型为:FunctionDeclaration
console.log(path.node.type);
// 获取函数声明的函数名,并存入数组
// path.node.id.name 就是定义的函数名称
functionNames.push(path.node.id.name);
// return false:不再递归遍历函数体内部的子节点
// 只收集顶层函数,不进入函数内部再查找嵌套函数
return false;
}
}
)
你可以试着把遍历函数互相嵌套起来,这样就能专门去查看特定的子树了。比如下面的这段代码,它只会返回那些刚好在第二层的函数。如果遇到了“函数里套函数,函数里再套函数”(也就是层级更深)的情况,它可是不会识别出来的哦:
(这里其实是在教大家一个控制遍历深度的小技巧,通过嵌套遍历来精准打击特定层级的代码节点,而不会“杀敌一千自损八百”地把所有深层嵌套的函数都翻出来。)
// 定义数组,用于存放收集到的【嵌套内层函数】名称
const functionNames = [];
// 第一层:遍历整个 AST 抽象语法树
recast.visit(
ast,
{
// 匹配顶层普通函数声明:function 外层函数() {}
visitFunctionDeclaration: (path) => {
// path.get('body') 获取当前函数的函数体 AST 节点
let newPath = path.get('body');
// ========== 子层遍历:只在当前函数体内部再做一次AST遍历 ==========
recast.visit(
// 遍历范围限定为:当前外层函数的函数体内部
newPath,
{
// 在函数体内部,继续匹配嵌套的函数声明
visitFunctionDeclaration: (path) => {
// 将内层嵌套函数的函数名存入数组
functionNames.push(path.node.id.name);
// return false:不再继续往内层函数的函数体深处遍历
return false;
}
}
)
// 外层 return false:停止顶层遍历继续深入当前函数
// 不让外层遍历自动进入函数体内部查找函数,全部交给上面的「子遍历」处理
return false;
}
}
)
我们已经通过代码自动找出了所有的函数名。同样的,如果我们想找出参数名或者那些被暴露出来的变量名,也是易如反掌的事情。
(这下整个流程就跑通啦,不仅搞定了函数名,其他你想提取的代码信息也都能轻松拿捏!)
术语表
- AST Node(AST 节点):树状结构中的一个对象。例如:函数声明、变量赋值、对象表达式。
- NodePath(节点路径):树中父节点与子节点之间的连接(或链接)。
- NodeProperty(节点属性):构成节点定义的具体部分。根据节点类型的不同,它可能仅仅是一个名字,也可能包含更多的详细信息。
(把这几个概念搞清楚,AST 对你来说就完全没有秘密啦!)
https://www.digitalocean.com/community/tutorials/js-traversing-ast

浙公网安备 33010602011771号