- 教程
【免费下载链接】typescript-book
:books: The definitive guide to TypeScript and possibly the best TypeScript book :book:. Free and Open Source 🌹
导读
本文以 typescript-book 仓库中 docs/compiler/overview.md 为主线,系统拆解 TypeScript 编译器的整体架构:源码如何依次经过 scanner(扫描器)、parser(解析器)、binder(绑定器)、checker(类型检查器)与 emitter(发射器)五个核心阶段,最终产出 JavaScript。读完本文,你将理解"语法正确不等于语义正确"这一核心观念、每一条编译流水线的输入输出形态,以及Program、CompilerHost、System等关键抽象之间的协作关系,并能在本仓库的 code/compiler 目录中亲手运行 scanner 与 parser 的可执行示例,观察真实的 Token 流与 AST 结构。
编译器源码的五大关键部分
TypeScript 编译器的全部源码集中在src/compiler目录下(对应官方 TypeScript 仓库目录,本仓库未包含该源码,只提供讲解文档与可运行示例),它被划分为五个关键部分,每部分都对应一个独立源文件:
| 部分 | 源文件 | 职责 |
|---|---|---|
| Scanner(扫描器) | scanner.ts | 把源码字符串切分成 Token 流 |
| Parser(解析器) | parser.ts | 把 Token 流组装成 AST |
| Binder(绑定器) | binder.ts | 把 AST 中的声明连接成 Symbols |
| Checker(检查器) | checker.ts | 基于 Symbols + AST 做类型校验 |
| Emitter(发射器) | emitter.ts | 把 AST 发射为 JavaScript |
这五者环环相扣,共同构成一条完整的编译流水线。后续各节将先交代"语法 vs 语义"这一贯穿始终的思维前提,再逐层展开每条流水线,最后补充core.ts、types.ts、system.ts等支撑设施与Program的协作模型。
语法 vs 语义:为什么需要五层流水线
理解编译器架构前,必须先区分两个概念:语法(Syntax)与语义(Semantics)。仅仅"语法上正确"并不代表"语义上正确"。下面这段 TypeScript 代码在语法上完全合法,但语义上却是错误的:
var foo: number = "not a number";扫描、解析阶段只能保证这段代码的语法成立(Token 合法、AST 结构正确);而"把字符串赋给 number 类型的变量是错误"这件事,必须由语义层面的检查器来判定。Semantic在英文中就是 "meaning"(含义)的意思,把这两个概念装进脑子里,后面理解 binder 与 checker 的分工就顺理成章了。
处理流水线总览:五段式数据流
overview.md 用五条简洁的流水线公式概括了编译器各阶段的输入输出,这是整篇文章的骨架:
SourceCode ~~ scanner ~~> Token StreamToken Stream ~~ parser ~~> ASTAST ~~ binder ~~> SymbolsSymbol 是 TypeScript 语义系统的基本构件(primary building block)。如上所示,Symbol 是绑定(binding)的结果:它把 AST 中的声明节点与其他指向同一实体的声明连接起来。例如,同一个变量foo的声明与使用会通过同一个 Symbol 关联,这正是后续类型检查能"追根溯源"的基础。
Symbol 与 AST 合在一起,供 checker 对源码做语义校验:
AST + Symbols ~~ checker ~~> Type Validation最后,当需要输出 JavaScript 时:
AST + Checker ~~ emitter ~~> JS注意 emitter 的输入不只是 AST,还包含 checker——因为发射 JS 时需要EmitResolver(由 checker 提供)来解析标识符、模块引用等信息(详见后文 emitter 一节)。
第一站:Scanner——把源码切分成 Token
单例扫描器与initializeState
scanner 的全部实现位于scanner.ts,它由 parser 内部控制:parser 调用 scanner 把源码转成 Token 流,再用 Token 流构建 AST。为了避免反复创建 scanner 的开销,parser 中维护了一个单例scanner,按需通过initializeState函数"预热"(primed)它。
仓库 code/compiler/scanner/runScanner.ts 提供了一个可直接运行的精简示例(依赖ntypescript包,声明见 code/compiler/package.json):
import * as ts from "ntypescript"; // TypeScript has a singelton scanner const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true); // That is initialized using a function `initializeState` similar to 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); } // Sample usage initializeState(` var foo = 123; `.trim()); // Start the scanning var token = scanner.scan(); while (token != ts.SyntaxKind.EndOfFileToken) { console.log(ts.syntaxKindToName(token)); token = scanner.scan(); }要点说明:
ts.createScanner(ts.ScriptTarget.Latest, true)创建扫描器,第二个参数skipTrivia为true表示跳过空白、注释等琐碎信息(trivia);initializeState依次设置文本(setText)、错误回调(setOnError)、脚本目标(setScriptTarget)与语言变体(setLanguageVariant),这与 parser 内部预热 scanner 的做法一致;- 循环调用
scanner.scan(),直到返回ts.SyntaxKind.EndOfFileToken为止。
对var foo = 123;扫描会输出如下 Token 序列:
VarKeyword Identifier FirstAssignment FirstLiteralToken SemicolonToken说明:文档中示例写作
ts.formatSyntaxKind,而仓库中实际可运行代码使用的是ts.syntaxKindToName(见 runScanner.ts),二者作用相同——把SyntaxKind枚举值转换为可读的字符串名称。SyntaxKind是一个const enum(详情见 docs/compiler/ast-tip-syntaxkind.md),编译器用--preserveConstEnums编译,因此运行时仍可通过ts.SyntaxKind.EndOfFileToken访问。
读取 Scanner 状态:Token 的位置信息
每次调用scan后,scanner 都会更新内部状态(当前扫描位置、当前 Token 详情等),并提供一批工具函数读取当前状态。仓库 code/compiler/scanner/runScannerWithPositions.ts 演示了如何借助getStartPos()获取每个 Token 的起止位置:
// Sample usage initializeState(` var foo = 123; `.trim()); // Start the scanning var token = scanner.scan(); while (token != ts.SyntaxKind.EndOfFileToken) { let currentToken = ts.syntaxKindToName(token); let tokenStart = scanner.getStartPos(); token = scanner.scan(); let tokenEnd = scanner.getStartPos(); console.log(currentToken, tokenStart, tokenEnd); }输出结果(每列分别为 Token 名、起始位置、结束位置):
VarKeyword 0 3 Identifier 3 7 FirstAssignment 7 9 FirstLiteralToken 9 13 SemicolonToken 13 14这些位置信息极其重要:它正是 parser 构建 AST 节点pos/end区间、以及后续错误报告(如parseErrorAtPosition)的数据来源。
独立使用 scanner
虽然 parser 内部持有单例 scanner,但你完全可以通过createScanner创建独立的 scanner,并使用setText/setTextPos在文件的不同位置分别扫描,用于实验或调试目的。更多细节见 docs/compiler/scanner.md。
第二站:Parser——把 Token 流组装成 AST
单例 Parser 与调用链
parser 的源码全部位于parser.ts。与 scanner 类似,它也是单例实现,实际是namespace Parser命名空间,内部既保存 parser 自身的状态变量,也持有那个单例const scanner,由各 parser 函数管理 scanner。
parser 由Program间接驱动(真正发起调用的其实是CompilerHost)。简化后的调用栈如下:
Program -> CompilerHost.getSourceFile -> (global function parser.ts).createSourceFile -> Parser.parseSourceFileparseSourceFile不仅初始化 parser 的状态,还会通过调用initializeState预热 scanner,随后把实际工作交给parseSourceFileWorker。
演示:打印一棵 AST
仓库 code/compiler/parser/runParser.ts 使用ts.createSourceFile解析源码并递归打印整棵 AST:
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);输出(每行依次为缩进、节点种类、pos、end):
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把脑袋向左歪一下看,这棵树呈现为明显的"右侧偏重"结构:SourceFile是根,其下是SyntaxList→VariableStatement→VariableDeclarationList→VariableDeclaration这样的非终结符层级,叶子节点(VarKeyword、Identifier等)正是 scanner 吐出的终结符 Token。
关键解析函数:createNode/parseExpected/finishNode
parseSourceFileWorker首先创建SourceFile这个 AST 根节点,然后从parseStatements开始解析语句;解析完成后回填nodeCount、identifierCount等统计信息。parseStatements是典型的parseFoo风格函数:它根据 scanner 当前返回的 token 分派,例如遇到SemicolonToken就调用parseEmptyStatement生成空语句节点(完整讲解见 docs/compiler/parser-functions.md)。
以parseEmptyStatement为例(它负责解析;;;;;;这类空语句),该函数完整展示了三个关键函数:
function parseEmptyStatement(): Statement { let node = <Statement>createNode(SyntaxKind.EmptyStatement); parseExpected(SyntaxKind.SemicolonToken); return finishNode(node); }createNode:function createNode(kind: SyntaxKind, pos?: number): Node,负责创建节点、设置传入的SyntaxKind,并在传入pos时设置初始位置(否则取当前 scanner 状态的位置);parseExpected:function parseExpected(kind: SyntaxKind, diagnosticMessage?: DiagnosticMessage): boolean,检查 parser 当前 token 是否与期望的SyntaxKind匹配,不匹配则报告传入的diagnosticMessage或生成形如foo expected的通用错误;其内部使用parseErrorAtPosition(基于扫描位置)实现高质量的错误定位;finishNode:function finishNode<T extends Node>(node: T, end?: number): T,设置节点的end位置,并记录其解析时的parserContextFlags,以及解析前是否已存在错误(若存在错误则此 AST 节点不能用于增量解析复用)。
第三站:Binder——把 AST 连接成 Symbols
大多数 JavaScript 转译器的流水线都很简单:
SourceCode ~~Scanner~~> Tokens ~~Parser~~> AST ~~Emitter~~> JavaScript但这只是对 TypeScript 生成 JS 的简化理解。TypeScript 的杀手锏是语义系统:为了让 checker 能做类型检查,binder(binder.ts)负责把源码的各部分连接成一个连贯的类型系统。binder 的核心职责就是创建 Symbol。
Symbol 与 SymbolFlags
Symbol 是语义系统的基本构件,它把 AST 中的声明节点与其他指向同一实体的声明连接起来。Symbol 构造函数定义在core.ts中,binder 通过objectAllocator.getSymbolConstructor获取它:
function Symbol(flags: SymbolFlags, name: string) { this.flags = flags; this.name = name; this.declarations = undefined; }SymbolFlags是一个标志枚举(flag enum),用来标识 Symbol 的附加分类,例如变量作用域标志FunctionScopedVariable(函数作用域变量)或BlockScopedVariable(块作用域变量)等。
Binder 如何被驱动
binder 实际由类型 checker 内部调用,而 checker 由 program 驱动。简化调用栈如下(在查看 checker 一节时会再次见到它):
program.getTypeChecker -> ts.createTypeChecker (in checker)-> initializeTypeChecker (in checker) -> for each SourceFile `ts.bindSourceFile` (in binder) // followed by for each SourceFile `ts.mergeSymbolTable` (in checker)binder 的工作单元是一个SourceFile:先对每个源文件执行ts.bindSourceFile完成绑定,随后 checker 再对每个源文件执行ts.mergeSymbolTable合并符号表。更多细节见 docs/compiler/binder.md。
第四站:Checker——真正让 TypeScript 与众不同的地方
checker 就是让 TypeScript 比"又一个 JavaScript 转译器"更强大的核心所在。它位于checker.ts,是编译器中最庞大的部分(文档写作时已达 2.3 万行以上的 TypeScript)。
初始化与调用栈
checker 由program初始化,调用栈如下(与 binder 一节展示的一致):
program.getTypeChecker -> ts.createTypeChecker (in checker)-> initializeTypeChecker (in checker) -> for each SourceFile `ts.bindSourceFile` (in binder) // followed by for each SourceFile `ts.mergeSymbolTable` (in checker)与 Emitter 的关联:真正的类型检查发生在哪一刻
真正意义上的类型检查发生在调用getDiagnostics时。例如当请求Program.emit时,program 会调用 checker 的getEmitResolver获取EmitResolver(它是createTypeChecker内部一组局部函数的集合),此过程中会触发诊断。调用栈一直延伸到checkSourceFile(createTypeChecker内部的局部函数):
program.emit -> emitWorker (program local) -> createTypeChecker.getEmitResolver -> // First call the following functions local to createTypeChecker call getDiagnostics -> getDiagnosticsWorker -> checkSourceFile // then return resolver (already initialized in createTypeChecker using a call to local createResolver())这里的EmitResolver会在 emitter 一节再次出现——它是 emitter 能够正确生成 JS 的关键输入之一。完整讲解见 docs/compiler/checker.md。
第五站:Emitter——产出 JavaScript
TypeScript 编译器提供两个发射器:
emitter.ts:TS → JavaScript 的发射器,也是日常开发最常接触的一个;declarationEmitter.ts:为.ts源文件生成声明文件(.d.ts)的发射器。
本节讨论前者。
Program.emit 的调用链
Program提供emit函数,它主要委托给emitter.ts中的emitFiles函数:
Program.emit -> `emitWorker` (local in program.ts createProgram) -> `emitFiles` (function in emitter.ts)emitWorker通过参数把EmitResolver传给emitFiles。EmitResolver由 program 的 TypeChecker 提供,本质上是createChecker中一批局部函数组成的子集——它负责在发射阶段回答"这个标识符指向什么、这个模块如何解析"之类的语义问题。这正是流水线公式中"AST + Checker ~~ emitter ~~> JS"的由来:发射 JS 不只是遍历 AST,还要借助 checker 的语义信息。见 docs/compiler/emitter.md。
支撑设施:Utilities、Key Data Structures 与 System
除五大核心文件外,编译器还有若干为各阶段提供公共能力的辅助文件。
core.ts:核心工具与对象分配器
core.ts提供编译器使用的核心工具,其中最重要的一个:
let objectAllocator: ObjectAllocator:定义为一个单例全局变量,提供以下构造器的定义:getNodeConstructor:Node 构造器(Node 将在讲解 parser / AST 时展开);getSymbolConstructor:Symbol 构造器(binder 一节已见);getTypeConstructor:Type 构造器(checker 一节涉及);getSignatureConstructor:Signature 构造器(签名即索引签名 index、调用签名 call 与构造签名 construct)。
所有 AST 节点、Symbol、Type、Signature 的实例都经由这个分配器创建,是理解对象生命周期的关键入口。
types.ts:贯穿全编译器的关键数据结构
types.ts定义了编译器各处使用的关键数据结构与接口,以下是几个代表:
SyntaxKind:AST 节点的类型由SyntaxKind枚举标识。它是一个const enum(见 docs/compiler/ast-tip-syntaxkind.md),编译时会被内联(例如ts.SyntaxKind.EndOfFileToken变成数字1),从而避免访问 AST 时的解引用开销;同时编译器以--preserveConstEnums编译,保证枚举在运行时依然可用,并可通过syntaxKindToName之类的函数转换成显示字符串;TypeChecker:TypeChecker 对外提供的接口;CompilerHost:供Program与System交互的中间层接口;Node:AST 节点。
关于Node,需要补充的是:它是抽象语法树(AST)的基本构件,一般代表语言文法中的非终结符,但部分终结符(如标识符 identifier、字面量 literal)也会保留在树中。每个 AST 节点都有两个关键文档要素——标识其类型的SyntaxKind,以及其实例化后的 API(interface)。interface Node中有两个对遍历至关重要的成员(详见 docs/compiler/ast.md):
TextRange成员:标识节点在源文件中的start与end;parent?: Node:节点在 AST 中的父节点。
此外,每个SourceFile都是位于Program中的顶层 AST 节点(SyntaxKind.SourceFile,对应interface SourceFile)。
system.ts:编译器与操作系统之间的"运行环境"
system.ts中定义了System接口:TypeScript 编译器与操作系统的所有交互都经由该接口完成。接口与其两个实现(WScript与Node)都定义在system.ts中。你可以把它视为编译器的Operating Environment(OE,运行环境)抽象——通过这一层,编译器可以运行在 Node、浏览器(WScript)等不同宿主上。
Program 与 CompilerHost:把一切串起来
看完五大阶段与支撑设施后,最后一块拼图是Program。它定义在program.ts中,编译上下文(这个概念在 docs/project/compilation-context.md 中详细讲过)在编译器内部就是以Program表示的:它由SourceFile列表与编译器选项组成。
Program 与运行环境的交互机制是一条间接链:
Program *-uses->* CompilerHost *-uses->* System为什么要引入CompilerHost这一层间接?因为它允许接口针对Program的需求做更精细的裁剪,而不必纠缠于 OE 的琐碎细节。例如,Program并不关心System提供的fileExists函数,通过CompilerHost隔离后,两者各取所需;而System也有其他使用者(比如测试)。Program通过getSourceFiles(): SourceFile[]API 对外暴露其持有的源文件,其中每个SourceFile都是一棵 AST 的根节点。详见 docs/compiler/program.md。
动手实践:在本仓库运行 scanner 与 parser 示例
理论讲完,可以在本仓库中亲手验证上述流水线。可运行示例位于 code/compiler 目录,包含:
- code/compiler/scanner/runScanner.ts:演示单例 scanner 的 Token 流输出;
- code/compiler/scanner/runScannerWithPositions.ts:演示 Token 起止位置读取;
- code/compiler/parser/runParser.ts:演示
createSourceFile解析并打印 AST; - code/compiler/package.json:声明唯一依赖
ntypescript(版本1.201507141013.1,一个可供独立实验的 TypeScript 编译器包),并配套 tsconfig.json。
运行方式(在该目录下):
cd code/compiler npm install npm run build # 按 tsconfig.json 编译出对应的 .js 文件 node runScanner.js node runParser.js编译后可直接对比同名.js文件(如 runScanner.js、runParser.js),观察var foo = 123;最终生成的 Token 序列与 AST 树是否与本文展示一致。
如果想在更大范围内自由探索编译器内部,docs/compiler/make-global.md 还给出了一个技巧:TypeScript 编译器以namespace ts编写并整体编译成单个typescript.js文件,你可以把想研究的源码片段复制出来,暴露到全局变量ts上,直接在全局作用域里交互式调用。这是"解剖"编译器的绝佳方式。
总结
回顾整条流水线:scanner 把源码变成 Token 流,parser 把 Token 流变成 AST,binder 为 AST 建立 Symbols 从而搭建语义系统,checker 基于 AST + Symbols 完成类型校验,最终 emitter 结合 checker 提供的EmitResolver产出 JavaScript。core.ts、types.ts、system.ts提供对象分配、核心数据结构与操作系统交互等公共能力,Program与CompilerHost则把编译上下文与运行环境连接起来。理解这五段式架构,无论是阅读编译器源码、编写自定义工具,还是诊断类型错误,都能事半功倍。
进一步阅读:
- docs/compiler/scanner.md / docs/compiler/parser.md:scanner 与 parser 的完整讲解
- docs/compiler/binder.md / docs/compiler/checker.md:语义系统与类型检查
- docs/compiler/emitter.md / docs/compiler/program.md:发射与 Program 协作
- docs/compiler/ast.md / docs/compiler/ast-tip-syntaxkind.md:AST 与 SyntaxKind 细节
- docs/compiler/parser-functions.md:parseFoo 风格函数与节点创建三件套
- 教程
【免费下载链接】typescript-book
:books: The definitive guide to TypeScript and possibly the best TypeScript book :book:. Free and Open Source 🌹
相关推荐
TypeScript编译器架构揭秘:从源码到JavaScript的完整流程
TypeScript编译器架构揭秘:从源码到JavaScript的完整流程 TypeScript作为JavaScript的超集,其核心优势在于静态类型检查和更强
编程语言编译器开发工具V 编译器模块架构深度解析:以 vlib/v 为核心的 parser → checker → generator 编译流水线
V 编译器模块架构深度解析:以 vlib/v 为核心的 parser → checker → generator 编译流水线 本文以 V 语言官方仓库中的编译器
编程语言编译器语言运行时标准库easy-vibe 编译器原理指南:从源代码到机器码的六阶段编译流水线
easy vibe 编译器原理指南:从源代码到机器码的六阶段编译流水线 本指南脱胎于 easy vibe 仓库 docs/en/appendix/1 compu
教程文档人工智能Vibe Coding
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考