AST:TypeScript 编译器 API——从源码到语法树与简单分析
这里直接用 TypeScript 自带的编译器 API 动手试一把:
把一段.ts源码丢给编译器,拿回 AST,然后做几件对日常开发有用的小事。
准备一个最小的 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 的根。
真正有趣的是接下来对树做的事情。
遍历 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
比如想找出项目里所有 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 更安全、可控。
再往前一步:借助 TypeChecker 做简单类型分析
前面这些都是“纯语法级”的分析:只看树形结构,不看类型。
如果想判断“这个调用的返回类型是什么”“这个变量最后被赋了哪些值”,就需要把编译器的类型系统请出来。
最常见的套路是先创建一个 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,或者根据类型信息生成更具体的重构建议。
和 LSP / Theia 里的那套有什么关系?
在 Language Server、特别是 TypeScript 这类服务器里,内部做的事情和上面这些非常接近,只是规模大得多:
- 维护一个
Program,追踪整个工程的文件依赖和编译选项; - 监听文件变更,增量更新 AST 和类型信息;
- 收到“补全 / 跳转 / 重命名”等请求时,在 AST + TypeChecker 上定位节点、算出结果,再通过 LSP 返回。
你在 Theia 里写 TS 代码时,Monaco 展示的是文本和光标,
而补全列表、诊断信息、跳转位置这些“智能”部分,很大一块就是靠 TypeScript 编译器 API 在 AST 上跑出来的。