Read JavaScript Source Code, Using an AST

引言
假设你手头有一个遗留下来的“上古”巨型 JavaScript 文件,足足有 70,000 行代码那么长。你迫切地需要用 webpack 或者类似的工具把它拆分开来,但前提是你得先搞清楚它到底向全局作用域暴露了哪些函数和常量。
这时候,不妨让计算机来帮你通读代码,并精准提取出你想要的信息。
这正是抽象语法树(AST)大显身手的好机会。
(这就好比面对一本几十万字的“天书”,与其自己一行行肉眼看,不如写个程序让电脑帮你快速划重点,而 AST 就是电脑读懂这本“天书”的语法字典!)
 
image
接下来的例子虽然规模不大,但你的任务是:如果你愿意接受挑战的话,请把所有暴露在全局作用域中的函数名都提取出来。
(这里其实是致敬了经典美剧《碟中谍》里的经典台词 "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 解析后得到的树状结构:

image

// 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)的导航
传递给 visitFunctionDeclarationpath 对象是一个 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

 

posted @ 2026-05-20 19:50  chenlight  阅读(6)  评论(0)    收藏  举报