TypeScript 编译器架构全景:从源码到 JavaScript 的五段流水线(scanner → parser → binder → checker → emitter)
教程【免费下载链接】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-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 流组装成 ASTBinder绑定器binder.ts把 AST 中的声明连接成 SymbolsChecker检查器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单例扫描器与initializeStatescanner 的全部实现位于scanner.ts它由 parser 内部控制parser 调用 scanner 把源码转成 Token 流再用 Token 流构建 AST。为了避免反复创建 scanner 的开销parser 中维护了一个单例scanner按需通过initializeState函数预热primed它。仓库 code/compiler/scanner/runScanner.ts 提供了一个可直接运行的精简示例依赖ntypescript包声明见 code/compiler/package.jsonimport * 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表示跳过空白、注释等琐碎信息triviainitializeState依次设置文本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解析源码并递归打印整棵 ASTimport * 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、endSourceFile 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/finishNodeparseSourceFileWorker首先创建SourceFile这个 AST 根节点然后从parseStatements开始解析语句解析完成后回填nodeCount、identifierCount等统计信息。parseStatements是典型的parseFoo风格函数它根据 scanner 当前返回的 token 分派例如遇到SemicolonToken就调用parseEmptyStatement生成空语句节点完整讲解见 docs/compiler/parser-functions.md。以parseEmptyStatement为例它负责解析;;;;;;这类空语句该函数完整展示了三个关键函数function parseEmptyStatement(): Statement { let node StatementcreateNode(SyntaxKind.EmptyStatement); parseExpected(SyntaxKind.SemicolonToken); return finishNode(node); }createNodefunction createNode(kind: SyntaxKind, pos?: number): Node负责创建节点、设置传入的SyntaxKind并在传入pos时设置初始位置否则取当前 scanner 状态的位置parseExpectedfunction parseExpected(kind: SyntaxKind, diagnosticMessage?: DiagnosticMessage): boolean检查 parser 当前 token 是否与期望的SyntaxKind匹配不匹配则报告传入的diagnosticMessage或生成形如foo expected的通用错误其内部使用parseErrorAtPosition基于扫描位置实现高质量的错误定位finishNodefunction finishNodeT 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 能做类型检查binderbinder.ts负责把源码的各部分连接成一个连贯的类型系统。binder 的核心职责就是创建 Symbol。Symbol 与 SymbolFlagsSymbol 是语义系统的基本构件它把 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内部一组局部函数的集合此过程中会触发诊断。调用栈一直延伸到checkSourceFilecreateTypeChecker内部的局部函数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——产出 JavaScriptTypeScript 编译器提供两个发射器emitter.tsTS → 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定义为一个单例全局变量提供以下构造器的定义getNodeConstructorNode 构造器Node 将在讲解 parser / AST 时展开getSymbolConstructorSymbol 构造器binder 一节已见getTypeConstructorType 构造器checker 一节涉及getSignatureConstructorSignature 构造器签名即索引签名 index、调用签名 call 与构造签名 construct。所有 AST 节点、Symbol、Type、Signature 的实例都经由这个分配器创建是理解对象生命周期的关键入口。types.ts贯穿全编译器的关键数据结构types.ts定义了编译器各处使用的关键数据结构与接口以下是几个代表SyntaxKindAST 节点的类型由SyntaxKind枚举标识。它是一个const enum见 docs/compiler/ast-tip-syntaxkind.md编译时会被内联例如ts.SyntaxKind.EndOfFileToken变成数字1从而避免访问 AST 时的解引用开销同时编译器以--preserveConstEnums编译保证枚举在运行时依然可用并可通过syntaxKindToName之类的函数转换成显示字符串TypeCheckerTypeChecker 对外提供的接口CompilerHost供Program与System交互的中间层接口NodeAST 节点。关于Node需要补充的是它是抽象语法树AST的基本构件一般代表语言文法中的非终结符但部分终结符如标识符 identifier、字面量 literal也会保留在树中。每个 AST 节点都有两个关键文档要素——标识其类型的SyntaxKind以及其实例化后的 APIinterface。interface Node中有两个对遍历至关重要的成员详见 docs/compiler/ast.mdTextRange成员标识节点在源文件中的start与endparent?: Node节点在 AST 中的父节点。此外每个SourceFile都是位于Program中的顶层 AST 节点SyntaxKind.SourceFile对应interface SourceFile。system.ts编译器与操作系统之间的运行环境system.ts中定义了System接口TypeScript 编译器与操作系统的所有交互都经由该接口完成。接口与其两个实现WScript与Node都定义在system.ts中。你可以把它视为编译器的Operating EnvironmentOE运行环境抽象——通过这一层编译器可以运行在 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解析并打印 ASTcode/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 流变成 ASTbinder 为 AST 建立 Symbols 从而搭建语义系统checker 基于 AST Symbols 完成类型校验最终 emitter 结合 checker 提供的EmitResolver产出 JavaScript。core.ts、types.ts、system.ts提供对象分配、核心数据结构与操作系统交互等公共能力Program与CompilerHost则把编译上下文与运行环境连接起来。理解这五段式架构无论是阅读编译器源码、编写自定义工具还是诊断类型错误都能事半功倍。进一步阅读docs/compiler/scanner.md / docs/compiler/parser.mdscanner 与 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.mdAST 与 SyntaxKind 细节docs/compiler/parser-functions.mdparseFoo 风格函数与节点创建三件套赞分享教程【免费下载链接】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编译器架构揭秘从源码到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上一篇告别jQuery依赖5个现代JavaScript原生方法完全指南下一篇Rust 中的精美表格库 - prettytable-rs创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考