TypeScript 编译器源码剖析:深入理解 SyntaxKind 枚举与 AST 节点遍历
【免费下载链接】typescript-book:books: The definitive guide to TypeScript and possibly the best TypeScript book :book:. Free and Open Source 🌹项目地址: https://gitcode.com/gh_mirrors/ty/typescript-book
本篇文章围绕 TypeScript 编译器中SyntaxKind这一核心数据结构展开,它用于标识抽象语法树(AST)中每个节点的类型,是阅读 编译器源码 的敲门砖。结合仓库中 parser.md、ast.md 等文档与可运行的示例代码,你将掌握SyntaxKind的设计原理(const enum与--preserveConstEnums的配合)、如何把枚举值转成可读字符串,以及如何借助ts.forEachChild和Node.getChildren遍历 AST 节点。
SyntaxKind 是什么
在 TypeScript 编译器中,SyntaxKind是描述 AST 节点类型的枚举。编译器的五个核心部件(Scanner、Parser、Binder、Checker、Emitter)中,Parser 输出的 AST 由一个个Node组成,而每个Node都通过kind字段标识自身类型,例如SourceFile、VariableStatement、Identifier、SemicolonToken等。
关于Node的核心信息,可以参考 ast.md:
Node是 AST 的基本构建块。一般来说,Node代表语言文法中的非终结符,但也有一些终结符保留在树中,如标识符(identifiers)和字面量(literals)。
一个 AST 节点由两样东西共同定义:用于标识其类型的SyntaxKind,以及该节点在 AST 中实例化后提供的interfaceAPI。Node接口中两个对遍历至关重要的成员是:
TextRange成员:标识节点在源文件中的start和end位置;parent?: Node:节点在 AST 中的父节点。
在编译器源码中,SyntaxKind等关键数据结构定义于types.ts(见 overview.md 中 "File: Key Data Structures" 一节)。
SyntaxKind 的定义形态
SyntaxKind被定义为一个const enum,其定义形式如下:
export const enum SyntaxKind { Unknown, EndOfFileToken, SingleLineCommentTrivia, // ... LOTS more }枚举成员自动从0开始递增赋值:Unknown是0,EndOfFileToken是1,SingleLineCommentTrivia是2,依此类推。
为什么使用 const enum:内联优化
为什么 TypeScript 编译器要选择const enum而非普通enum?这里需要回顾 enums.md 中关于 const enum 的说明:
普通枚举的成员访问(如Tristate.False)在编译后依然是Tristate.False,运行时需要先查找Tristate对象再访问False属性,存在一次解引用开销。而const enum会把所有使用处**内联(inline)**成字面量。例如:
const enum Tristate { False, True, Unknown } var lie = Tristate.False;会编译成:
var lie = 0;也就是说,编译器做了两件事:
- 将枚举的所有使用处内联(
Tristate.False变成0); - 不为枚举定义生成任何 JavaScript(因为使用处已被内联,运行时不存在
Tristate变量)。
对于编译器内部的高频代码路径而言,这意味着ts.SyntaxKind.EndOfFileToken会被直接内联为1,避免了 AST 遍历过程中反复的属性解引用开销。
--preserveConstEnums:运行时仍保留枚举对象
内联优化有一个副作用:枚举定义本身不会生成 JavaScript,运行时也就没有可用的枚举对象,无法做"数字 ↔ 字符串"的互相转换。TypeScript 提供了--preserveConstEnums编译选项来解决这个问题。如 enums.md 中 "Const enum preserveConstEnums" 一节所述:
使用编译选项
--preserveConstEnums后,编译器仍然会生成var Tristate定义,这样你就可以在运行时手动使用Tristate["False"]或Tristate[0]。这不会以任何方式影响内联。
也就是说:
- 内联行为不受影响:使用处仍然会被替换成字面量数字;
- 但枚举的运行时对象也会被生成,供需要反向查找的场景使用。
TypeScript 编译器本身在编译时开启了--preserveConstEnums,因此在 JavaScript 中你依然可以使用ts.SyntaxKind.EndOfFileToken这样的写法来访问枚举对象。这正好兼顾了性能(内联)与可调试性/运行时反射能力(保留对象)。
把 SyntaxKind 转成可读字符串
虽然SyntaxKind枚举值在运行时是数字,但我们可以利用普通数字枚举"数字 ↔ 字符串"双向映射的特性(参考 enums.md 中的 Number Enums and Strings 一节),把数字转回成员名。原文档给出了如下工具函数:
export function syntaxKindToName(kind: ts.SyntaxKind) { return (<any>ts).SyntaxKind[kind]; }原理很简单:SyntaxKind[kind]利用枚举的反向映射,把数字kind转回成员名字符串,例如ts.SyntaxKind.EndOfFileToken(数字1)会返回"EndOfFileToken"。这里的<any>类型断言是为了绕过 TypeScript 对 const enum 访问的限制,因为默认情况下编译器不允许以"动态属性访问"的方式使用 const enum。
注意:文档中的syntaxKindToName与示例代码里出现的ts.syntaxKindToName是同一功能的两种写法——ts.syntaxKindToName是 ntypescript 对外暴露的 API,而syntaxKindToName演示了其内部实现原理。
在真实示例代码中观察 SyntaxKind
仓库 code/compiler 目录下提供了可直接运行的最小示例,其 package.json 依赖ntypescript1.201507141013.1 版本,入口为runScanner.js。
扫描器示例:token 的 SyntaxKind
runScanner.ts 演示了如何用扫描器逐个产生 token,并打印每个 token 对应的SyntaxKind名称:
import * as ts from "ntypescript"; // TypeScript 有一个单例扫描器 const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true); // 通过 initializeState 类似的函数完成初始化 function initializeState(text: string) { scanner.setText(text); scanner.setOnError((message: ts.DiagnosticMessage, length: number) => { console.error(message); }); scanner.setScriptTarget(ts.ScriptTarget.ES5); scanner.setLanguageVariant(ts.LanguageVariant.Standard); } // 示例使用 initializeState(` var foo = 123; `.trim()); // 开始扫描 var token = scanner.scan(); while (token != ts.SyntaxKind.EndOfFileToken) { console.log(ts.syntaxKindToName(token)); token = scanner.scan(); }对var foo = 123;这段代码,输出为:
VarKeyword Identifier FirstAssignment FirstLiteralToken SemicolonToken这里ts.SyntaxKind.EndOfFileToken正是const enum的一个直接使用处——在编译产物中它会被内联为数字1。
解析器示例:打印整棵 AST
runParser.ts 演示了如何用ts.createSourceFile解析源码并递归打印整棵 AST(关于 Parser 的调用链Program -> CompilerHost.getSourceFile -> createSourceFile -> Parser.parseSourceFile,可参考 parser.md):
import * as ts from "ntypescript"; function printAllChildren(node: ts.Node, depth = 0) { console.log(new Array(depth + 1).join('----'), ts.syntaxKindToName(node.kind), node.pos, node.end); depth++; node.getChildren().forEach(c=> printAllChildren(c, depth)); } var sourceCode = ` var foo = 123; `.trim(); var sourceFile = ts.createSourceFile('foo.ts', sourceCode, ts.ScriptTarget.ES5, true); printAllChildren(sourceFile);这段代码对每个节点调用ts.syntaxKindToName(node.kind)打印节点类型名,并打印node.pos与node.end(即节点在源文件中的起始与结束位置,来自TextRange成员)。输出是一棵非常靠右的树:
SourceFile 0 14 ---- SyntaxList 0 14 -------- VariableStatement 0 14 ------------ VariableDeclarationList 0 13 ---------------- VarKeyword 0 3 ---------------- SyntaxList 3 13 -------------------- VariableDeclaration 3 13 ------------------------ Identifier 3 7 ------------------------ FirstAssignment 7 9 ------------------------ FirstLiteralToken 9 13 ------------ SemicolonToken 13 14 ---- EndOfFileToken 14 14可以看到,每个 AST 节点都由SyntaxKind名称 +pos+end唯一刻画,EndOfFileToken位于文件末尾且宽度为零(14 14)。原文档 parser.md 还提到,我们会在进一步讨论解析器时再次用到这个打印函数。
遍历 AST 的两种方式
理解了SyntaxKind之后,下一步自然是遍历 AST。原文档 ast-tip-children.md 介绍了两种遍历方式,它们对SyntaxKind的利用方式截然不同。
方式一:ts.forEachChild——按 kind 定向访问子节点
编译器内部提供了一个工具函数ts.forEachChild,它基于node.kind做出类型判断,从而"知道"该节点有哪些子节点。以下是其源码的简化片段:
export function forEachChild<T>(node: Node, cbNode: (node: Node) => T, cbNodeArray?: (nodes: Node[]) => T): T { if (!node) { return; } switch (node.kind) { case SyntaxKind.BinaryExpression: return visitNode(cbNode, (<BinaryExpression>node).left) || visitNode(cbNode, (<BinaryExpression>node).operatorToken) || visitNode(cbNode, (<BinaryExpression>node).right); case SyntaxKind.IfStatement: return visitNode(cbNode, (<IfStatement>node).expression) || visitNode(cbNode, (<IfStatement>node).thenStatement) || visitNode(cbNode, (<IfStatement>node).elseStatement); // .... lots more } }其工作方式是:
- 检查
node.kind; - 根据
kind断言节点对应的具体接口(如BinaryExpression、IfStatement); - 对子节点逐个调用
cbNode回调(使用||短路:一旦某个回调返回非空结果立即终止遍历,这是编译器内部常见的提前退出模式)。
例如SyntaxKind.BinaryExpression会访问left、operatorToken、right三个子节点;SyntaxKind.IfStatement会访问expression、thenStatement、elseStatement。
注意:forEachChild并不会访问节点的全部子节点——比如SemicolonToken这样的分号 token 就不会被访问。它是编译器为"语法结构遍历"定制的精简化遍历,只关心语言文法上有意义的子节点。
方式二:Node.getChildren——获取全部子节点
如果你想拿到某个节点在 AST 中所有的子节点(包括分号、逗号等语法细节 token),可以调用Node的getChildren()成员函数。它返回的是基于节点文本范围切分出的完整子节点数组。
原文档给出了一个打印节点"详细 AST"的递归函数:
function printAllChildren(node: ts.Node, depth = 0) { console.log(new Array(depth+1).join('----'), ts.syntaxKindToName(node.kind), node.pos, node.end); depth++; node.getChildren().forEach(c=> printAllChildren(c, depth)); }这正是 runParser.ts 中使用的方式:用getChildren()获得全部子节点(因此输出里能看到SemicolonToken、SyntaxList这类仅靠forEachChild不会出现的节点),再配合syntaxKindToName打印每个节点的类型与位置。
两种方式的对比
| 维度 | ts.forEachChild | Node.getChildren() |
|---|---|---|
| 子节点范围 | 只访问文法上有意义的子节点(如操作数、语句体) | 返回所有子节点,包括SemicolonToken等细节 |
| 实现原理 | 根据node.kind分支判断具体接口并逐个访问 | 基于文本范围切分,通用无分支 |
| 典型用途 | 编译器内部语义分析(如 Binder、Checker)的定向遍历 | 打印/调试完整 AST、需要精确源码结构时 |
| 能否提前退出 | 可以(回调返回非空值即短路) | 不能,始终返回全部子节点 |
与 Trivia 的关系:getStart 与 getFullStart
在遍历 AST 时,你可能还想知道节点在源文件中的精确位置。由于SyntaxKind只是类型标识,位置信息由TextRange成员(pos/end)提供。而 ast-trivia.md 进一步解释了节点的两个起点概念:
- Token Start(token 起点):更自然的版本,即 token 文本开始的位置,通过
getStart()获取; - Full Start(完整起点):扫描器自上一个有意义的 token 之后开始扫描的位置,通过
getFullStart()获取。
例如对于下面这段代码中的function:
debugger;/*hello*/ //bye /*hi*/ functionfunction的 token start 在function处,而 full start 在/*hello*/处——full start 甚至包含了本该属于前一个节点的 trivia(注释)。这与SyntaxKind一起构成了完整描述一个 AST 节点的三要素:类型(kind)、位置(start/end)、子节点(children)。
Trivia(空白、注释等)出于轻量化的考虑不会存储在 AST 中,但可以通过ts.getLeadingCommentRanges、ts.getTrailingCommentRanges等 API 按需获取,这两个 API 配合Node.getFullStart/Node.getEnd使用效果最佳。
小结与延伸阅读
SyntaxKind是贯穿 TypeScript 编译器全流程的核心标识:扫描器用它标识 token(见 scanner.md),解析器用它构建并标识 AST 节点(见 parser.md),后续的 Binder 与 Checker 也依赖它进行语义分析(见 overview.md 的流水线SourceCode ~~ scanner ~~> Token Stream ~~ parser ~~> AST ~~ binder ~~> Symbols)。掌握它的设计(const enum+--preserveConstEnums)与两种遍历方式(forEachChild与getChildren),是进一步阅读编译器源码、编写自定义 AST 工具(如代码分析器、代码生成器、Lint 规则)的基础。
建议继续阅读:
- AST 基础:Node 与 SourceFile
- AST 遍历工具:ts.forEachChild 与 getChildren
- Trivia 与节点位置:getStart / getFullStart
- 解析器:从源码到 AST 的调用链
- 扫描器:token 流的产生
- 可运行示例代码 与 扫描器示例
- TypeScript 枚举基础(含 const enum 与 --preserveConstEnums)
【免费下载链接】typescript-book:books: The definitive guide to TypeScript and possibly the best TypeScript book :book:. Free and Open Source 🌹项目地址: https://gitcode.com/gh_mirrors/ty/typescript-book
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考