AST:TypeScript 编译器 API——从源码到语法树与简单分析

这里直接用 TypeScript 自带的编译器 API 动手试一把:
把一段 .ts 源码丢给编译器,拿回 AST,然后做几件对日常开发有用的小事。

假设你在某个目录里新建了一个小工具工程,可以很简单地装一份 TypeScript:

1npm init -y
2npm install typescript

然后新建一个 ast-playground.ts

 1import ts from 'typescript';
 2
 3const sourceCode = `
 4  function greet(name: string) {
 5    console.log('Hello ' + name);
 6  }
 7
 8  greet('world');
 9`;
10
11// 1. 创建 SourceFile(语法树的根节点)
12const sourceFile = ts.createSourceFile(
13  'demo.ts',          // 文件名(虚拟的也可以)
14  sourceCode,         // 源码字符串
15  ts.ScriptTarget.Latest,
16  /*setParentNodes*/ true
17);
18
19console.log(sourceFile.statements.length);

ts-node 或者 ts-node-esm 跑一下这段代码(或者先 tsc 编译再用 Node 跑),你就拿到了一个 SourceFile,它是整棵 AST 的根。

真正有趣的是接下来对树做的事情。

TypeScript 提供了一个 forEachChild 辅助函数,用来递归遍历节点的“语法学意义上的child”。
可以写一个很轻量的 DFS,把我们关心的节点挑出来。

1function visit(node: ts.Node) {
2  if (ts.isFunctionDeclaration(node) && node.name) {
3    console.log('Found function:', node.name.getText());
4  }
5
6  ts.forEachChild(node, visit);
7}
8
9visit(sourceFile);

几个点可以顺手记一下:

  • 判断节点类型时,用 ts.isFunctionDeclaration(node) 这类 Type Guard 方法;
  • node.getText() 可以拿到“源码里对应的那一段字符串”;
  • 不用自己手写 node.children 的遍历顺序,交给 ts.forEachChild 即可。

同样的写法,稍微改一改,就可以查别的结构。

比如想找出项目里所有 console.log 调用,大致模式是:

  • 找到所有 CallExpression
  • 要求调用的对象是 console.log
  • 打印出它所在的文件和代码位置。

在单文件的 playground 版本,可以先专注在 AST 结构上:

 1function findConsoleLog(node: ts.Node) {
 2  if (
 3    ts.isCallExpression(node) &&
 4    ts.isPropertyAccessExpression(node.expression) &&
 5    node.expression.expression.getText() === 'console' &&
 6    node.expression.name.getText() === 'log'
 7  ) {
 8    const { line, character } = sourceFile.getLineAndCharacterOfPosition(
 9      node.getStart()
10    );
11
12    console.log(
13      `[console.log] at ${line + 1}:${character + 1} →`,
14      node.getText()
15    );
16  }
17
18  ts.forEachChild(node, findConsoleLog);
19}
20
21findConsoleLog(sourceFile);

这里用到了几个常见操作:

  • ts.isCallExpression / ts.isPropertyAccessExpression:一步步收紧模式;
  • getLineAndCharacterOfPosition:把 AST 节点的起始位置反查回“第几行第几列”;
  • 通过 AST 判断“是不是 console.log 调用”,而不是在字符串里找 'console.log('

这一类小脚本很适合用来做“项目级的结构化搜索”,比普通 grep 更安全、可控。

前面这些都是“纯语法级”的分析:只看树形结构,不看类型。
如果想判断“这个调用的返回类型是什么”“这个变量最后被赋了哪些值”,就需要把编译器的类型系统请出来。

最常见的套路是先创建一个 Program,再拿到 TypeChecker

1const host = ts.createCompilerHost({});
2const program = ts.createProgram({
3  rootNames: ['demo.ts'],
4  options: { strict: true },
5  host,
6});
7
8const checker = program.getTypeChecker();

在 playground 里,你也可以把源码写到真实的 demo.ts 文件里,再通过 program.getSourceFile('demo.ts')SourceFile

有了 checker 之后,可以在遍历 AST 时做一些更“语义化”的事情,比如:

 1function inspectIdentifiers(node: ts.Node) {
 2  if (ts.isIdentifier(node)) {
 3    const symbol = checker.getSymbolAtLocation(node);
 4    if (symbol) {
 5      const type = checker.getTypeOfSymbolAtLocation(symbol, node);
 6      const typeStr = checker.typeToString(type);
 7
 8      console.log(`Identifier "${node.getText()}" has type: ${typeStr}`);
 9    }
10  }
11
12  ts.forEachChild(node, inspectIdentifiers);
13}

在一个真实项目里,把这套逻辑绑到命令行或编辑器扩展上,就可以做很多自动化检查和重构辅助:
例如只在某些特定类型的变量上报 warning,或者根据类型信息生成更具体的重构建议。

在 Language Server、特别是 TypeScript 这类服务器里,内部做的事情和上面这些非常接近,只是规模大得多:

  • 维护一个 Program,追踪整个工程的文件依赖和编译选项;
  • 监听文件变更,增量更新 AST 和类型信息;
  • 收到“补全 / 跳转 / 重命名”等请求时,在 AST + TypeChecker 上定位节点、算出结果,再通过 LSP 返回。

你在 Theia 里写 TS 代码时,Monaco 展示的是文本和光标,
而补全列表、诊断信息、跳转位置这些“智能”部分,很大一块就是靠 TypeScript 编译器 API 在 AST 上跑出来的。