ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript 编译器源码剖析:深入理解 SyntaxKind 枚举与 AST 节点遍历

TypeScript 编译器源码剖析:深入理解 SyntaxKind 枚举与 AST 节点遍历 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.mdNode是 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是0EndOfFileToken是1SingleLineCommentTrivia是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 (anyts).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 的 SyntaxKindrunScanner.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。解析器示例打印整棵 ASTrunParser.ts 演示了如何用ts.createSourceFile解析源码并递归打印整棵 AST关于 Parser 的调用链Program - CompilerHost.getSourceFile - createSourceFile - Parser.parseSourceFile可参考 parser.mdimport * 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名称 posend唯一刻画EndOfFileToken位于文件末尾且宽度为零14 14。原文档 parser.md 还提到我们会在进一步讨论解析器时再次用到这个打印函数。遍历 AST 的两种方式理解了SyntaxKind之后下一步自然是遍历 AST。原文档 ast-tip-children.md 介绍了两种遍历方式它们对SyntaxKind的利用方式截然不同。方式一ts.forEachChild——按 kind 定向访问子节点编译器内部提供了一个工具函数ts.forEachChild它基于node.kind做出类型判断从而知道该节点有哪些子节点。以下是其源码的简化片段export function forEachChildT(node: Node, cbNode: (node: Node) T, cbNodeArray?: (nodes: Node[]) T): T { if (!node) { return; } switch (node.kind) { case SyntaxKind.BinaryExpression: return visitNode(cbNode, (BinaryExpressionnode).left) || visitNode(cbNode, (BinaryExpressionnode).operatorToken) || visitNode(cbNode, (BinaryExpressionnode).right); case SyntaxKind.IfStatement: return visitNode(cbNode, (IfStatementnode).expression) || visitNode(cbNode, (IfStatementnode).thenStatement) || visitNode(cbNode, (IfStatementnode).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(depth1).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.forEachChildNode.getChildren()子节点范围只访问文法上有意义的子节点如操作数、语句体返回所有子节点包括SemicolonToken等细节实现原理根据node.kind分支判断具体接口并逐个访问基于文本范围切分通用无分支典型用途编译器内部语义分析如 Binder、Checker的定向遍历打印/调试完整 AST、需要精确源码结构时能否提前退出可以回调返回非空值即短路不能始终返回全部子节点与 Trivia 的关系getStart 与 getFullStart在遍历 AST 时你可能还想知道节点在源文件中的精确位置。由于SyntaxKind只是类型标识位置信息由TextRange成员pos/end提供。而 ast-trivia.md 进一步解释了节点的两个起点概念Token Starttoken 起点更自然的版本即 token 文本开始的位置通过getStart()获取Full Start完整起点扫描器自上一个有意义的 token 之后开始扫描的位置通过getFullStart()获取。例如对于下面这段代码中的functiondebugger;/*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 与 SourceFileAST 遍历工具ts.forEachChild 与 getChildrenTrivia 与节点位置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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进