从源代码到 AST:Scanner、Parser 与节点系统
前置知识
- ›第 1 篇:架构与代码库全景图
- ›了解词法分析器/tokenizer 的基本概念,以及递归下降解析原理
- ›熟悉 TypeScript 语法(泛型、JSX、装饰器、模板字面量类型)
从源代码到 AST:Scanner、Parser 与节点系统
TypeScript 的每一次编译都从一段源代码字符串开始,至少在前端阶段,最终会产出一个 SourceFile——一棵完整的抽象语法树。这个过程由两个相互协作的模块共同完成:Scanner 负责将文本切分为 token,Parser 则将这些 token 组装成语法树。两者合计约 15,000 行代码,处理的是编程语言领域最复杂的语法之一——TypeScript 是 JavaScript 的超集,在此之上叠加了泛型、JSX、装饰器、模板字面量类型和 satisfies 表达式等特性。
本文将深入剖析这两个模块,梳理为每个节点打标签的 SyntaxKind 分类体系,并描绘组织 AST 的 Node 类型层次结构。
SyntaxKind:统一的节点分类机制
TypeScript AST 中的所有内容都通过一个 const enum 来分类:SyntaxKind。其定义从 src/compiler/types.ts#L40 开始,从 trivia 和 token 起步,经过关键字,最终覆盖所有可能的表达式、语句、声明和 JSDoc 节点。
flowchart TD
SK["SyntaxKind (const enum)"]
SK --> Trivia["Trivia (0-8)<br/>Comments, Whitespace, Shebang"]
SK --> Literals["Literals (9-15)<br/>NumericLiteral, StringLiteral, RegExp"]
SK --> Punctuation["Punctuation (16-80)<br/>Braces, Operators, Arrows"]
SK --> Keywords["Keywords (81-165)<br/>if, class, const, type, ..."]
SK --> TypeNodes["Type Nodes<br/>TypeReference, UnionType, ConditionalType"]
SK --> Expressions["Expressions<br/>CallExpression, BinaryExpression, ..."]
SK --> Statements["Statements<br/>IfStatement, ForStatement, ReturnStatement"]
SK --> Declarations["Declarations<br/>ClassDeclaration, FunctionDeclaration, ..."]
SK --> JSDoc["JSDoc Nodes<br/>JSDocComment, JSDocTag, JSDocTypeExpression"]
有一条关键的设计规则:token > SyntaxKind.Identifier 意味着该 token 是一个关键字。这个简单的比较在 parser 和 scanner 中被广泛用于快速检测关键字。
SyntaxKind 枚举还配有一组联合类型别名,用于将相关的 kind 归组。TypeNodeSyntaxKind 收录了所有出现在类型位置的语法 kind;TokenSyntaxKind 涵盖 scanner 所能产出的全部内容;JsxTokenSyntaxKind 和 JSDocSyntaxKind 则对应各自的专用扫描模式。这些类型起到约束作用,限定了 scanner 在不同上下文中的返回值范围。
与 SyntaxKind 并列的还有 NodeFlags 枚举,用于存储每个节点的元数据:变量是 let/const/using、节点是否在转换过程中被合成、是否在 await 或 yield 上下文中解析,以及追踪错误和 import 存在情况的标志位。
Scanner:有状态的 tokenization
Scanner 定义于 src/compiler/scanner.ts,约 4,100 行代码,全部封装在一个闭包工厂函数中。
Scanner 接口
Scanner 接口直观地揭示了其设计思路:它是一个在文本上移动的有状态游标。调用 scan() 向前推进到下一个 token,然后通过 getToken()、getTokenStart()、getTokenEnd()、getTokenValue() 等方法查询当前位置的状态。这里没有 token 数组——parser 每次只拉取一个 token。
接口还提供了一系列 reScan* 方法:reScanGreaterToken()、reScanSlashToken()、reScanTemplateToken()、reScanJsxToken() 等。这些方法的存在是因为 TypeScript 的语法是上下文相关的——> 可能是大于运算符,也可能是类型参数列表的结束;/ 可能是除法,也可能是正则表达式的开始;} 可能关闭一个代码块,也可能开启一段模板字面量。
createScanner:闭包模式
flowchart LR
CS["createScanner()"] --> Closure["Closure with var locals"]
Closure --> pos["var pos: number"]
Closure --> endVar["var end: number"]
Closure --> token["var token: SyntaxKind"]
Closure --> tokenValue["var tokenValue: string"]
Closure --> scan["scan() → SyntaxKind"]
Closure --> reScan["reScan*() methods"]
createScanner() 通过闭包捕获 var 局部变量来维护位置、token 状态和错误处理信息。函数开头有一段我们已经熟悉的 TDZ 规避注释:
// Why var? It avoids TDZ checks in the runtime which can be costly.
// See: https://github.com/microsoft/TypeScript/issues/52924
在闭包内使用 var 而非 let,是为了规避运行时的暂时性死区(TDZ)检查开销。对于每次编译都会被调用数百万次的热函数来说,这一改动能带来可量化的性能提升。整个编译器都沿用了这一模式。
核心的 scan() 函数是一个对当前字符编码进行分支的大型 switch 语句,处理范围涵盖简单的单字符 token({、}、(、))、多字符运算符(===、>>>=)、带转义序列的字符串字面量、模板字面量片段、数字字面量(包括 0x、0o、0b 和 BigInt 的 n 后缀),以及正则表达式。
扫描模式
Scanner 支持多种模式以适应不同的语法上下文:
- 普通模式:标准的 TypeScript/JavaScript tokenization
- JSX 模式:
scanJsxToken()和reScanJsxToken()处理 JSX 文本内容与元素边界 - 模板字面量模式:
reScanTemplateToken()在模板片段与嵌入表达式之间切换 - 正则表达式模式:
reScanSlashToken()将/重新解释为正则表达式分隔符,并对正则语法(包括命名分组和 Unicode 属性转义)进行完整验证 - JSDoc 模式:
scanJsDocToken()处理 JSDoc 注释所需的简化 tokenization
提示: 当 parser 遇到有歧义的 token 时,它不会回退 scanner,而是调用对应的
reScan*方法在当前上下文中重新解释该 token。这比维护 scanner 快照的开销要低得多。
Parser:递归下降构建 AST
Parser 位于 src/compiler/parser.ts,约 10,800 行递归下降解析代码,主要的公开入口为 createSourceFile()。
createSourceFile:入口函数
createSourceFile() 接收文件名、源代码文本、语言版本和可选的 script kind,然后委托给 Parser.parseSourceFile() 处理。该函数负责区分 JSON 文件(以 ScriptKind.JSON 解析)和普通 TypeScript/JavaScript 文件,并为 Node.js 模块解析(ESM vs CJS)设置 impliedNodeFormat。
整个解析过程被性能打点(beforeParse/afterParse)和可选的 tracing 所包裹——这与编译器中用于性能分析的统一插桩模式一致。
递归下降结构
Parser 遵循标准的递归下降模式:每条语法规则对应一个函数。parseStatement() 根据当前 token 分发到 parseIfStatement()、parseReturnStatement()、parseVariableStatement() 等。表达式解析通过 parseBinaryExpressionOrHigher() 实现优先级爬升。类型解析也有自己的并行层次:parseType()、parseUnionTypeOrHigher()、parseIntersectionTypeOrHigher() 等。
sequenceDiagram
participant E as External Caller
participant P as Parser
participant S as Scanner
participant F as NodeFactory
E->>P: createSourceFile(fileName, text)
P->>S: createScanner(...)
P->>S: scan() → first token
loop For each statement
P->>S: getToken()
P->>P: parseStatement()
P->>F: factory.createIfStatement(...)
P->>S: scan() → next token
end
P->>F: factory.createSourceFile(statements)
P-->>E: SourceFile
错误恢复与推测解析
Parser 必须具备足够的健壮性——即使面对不完整的代码,也要能产出可用的 AST,因为语言服务依赖它来提供编辑器功能。为此,Parser 采用了几种策略:
缺失 token 处理:当期望的 token 未出现时,Parser 会在当前位置创建一个"缺失"节点——一个宽度为零的合成节点——并上报错误,然后继续解析。
前瞻与推测解析:对于有歧义的语法(<T> 是类型参数列表还是 JSX 元素?),Parser 使用 lookAhead() 和 tryParse()。lookAhead() 尝试向前解析,若推测失败则回滚 scanner 状态;tryParse() 逻辑相同,但成功时会提交结果。
列表解析中的错误恢复:Parser 的列表解析函数(用于语句列表、参数列表等)能够跳过不属于当前上下文的 token,通过一组预期的 token kind 来判断何时停止或重新同步。
Node 层次结构与 SourceFile
SyntaxKind 为每个节点分类,Parser 负责创建它们,剩下的问题是类型系统如何组织这些 AST 节点。
横切关注点的子接口
并非每个 Node 都具备相同的能力。TypeScript AST 没有使用一个带大量可选属性的单一接口,而是采用了子接口的方式:
classDiagram
class Node {
+kind: SyntaxKind
+flags: NodeFlags
+parent: Node
+pos: number
+end: number
}
class JSDocContainer {
+jsDoc: JSDoc[]
}
class LocalsContainer {
+locals: SymbolTable
+nextContainer: HasLocals
}
class FlowContainer {
+flowNode: FlowNode
}
class Declaration {
+symbol: Symbol
+localSymbol: Symbol
}
Node <|-- JSDocContainer
Node <|-- LocalsContainer
Node <|-- FlowContainer
Node <|-- Declaration
LocalsContainer 节点拥有 locals 符号表——这类节点会引入新的作用域(函数、代码块、模块)。FlowContainer 节点参与控制流分析。Declaration 节点关联一个 symbol。
这一拆分源于一个专项 PR,目的是将 symbol、locals 和 flowNode 从基础 Node 接口移到真正使用它们的子接口上,从而减少那些永远不需要这些字段的节点类型的内存浪费。
SourceFile 接口
SourceFile 同时继承了 Declaration 和 LocalsContainer,既是 AST 的根节点,也是文件级符号的容器。它持有以下信息:
statements——顶层语句列表fileName和path——文件标识text——原始源代码文本(用于错误报告和语言服务)languageVersion和languageVariant——影响解析行为isDeclarationFile——.d.ts文件会受到特殊处理impliedNodeFormat——Node.js 模块解析的 ESM vs CJS- 模块标识符:
externalModuleIndicator、commonJsModuleIndicator
SourceFile 是整个编译器的基本工作单元。Program 管理一组 SourceFile 对象;类型检查器逐个处理 SourceFile;代码生成器也独立地对每个 SourceFile 进行转换和输出。
NodeFactory:类型安全的 AST 构建
Parser 不通过 new 直接创建节点,而是使用 NodeFactory——一套完整的类型化工厂函数。factory.createIfStatement(expression, thenStatement, elseStatement) 创建一个 IfStatement 节点,自动设置正确的 kind、类型化的子节点,并预先计算好 TransformFlags。
NodeFactory 不仅服务于 Parser,也是转换器 pipeline(第 5 篇)的核心,后者在降级过程中会创建新的合成节点。统一使用工厂函数确保了节点的形状和标志位始终一致,无论来自解析还是转换。
提示: 在阅读转换代码时,留意
factory.create*和factory.update*的调用。update系列函数只在子节点确实发生变化时才创建新节点,从而实现高效的结构共享。
下一步
至此,源代码已被转换为一棵完整的 AST——由 SyntaxKind 分类的 Node 对象所构成的树形结构。但 AST 本身并不能告诉你名称的含义或作用域的嵌套关系。在第 3 篇中,我们将跟随 Binder 遍历这棵 AST,看它如何创建 Symbol、填充符号表,并构建让类型收窄成为可能的控制流图。