)
教程【免费下载链接】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/parser.md 为主线完整讲解 TypeScript 编译器中Parser解析器的工作原理从源码 → Scanner 词法扫描 → Token 流 → Parser 语法解析 → AST的流水线到 Parser 的单例实现形态、被 Program 间接驱动的调用链再到createSourceFile的实战用法与createNode/parseExpected/finishNode等核心函数的底层机制。读完本文你将能够独立使用 TypeScript 的解析 API 把任意.ts源码转换为可遍历的 AST并理解其内部状态管理与错误上报的设计思路。一、从源码到 ASTParser 在编译流水线中的位置TypeScript 编译器src/compiler目录被拆分为 Scanner、Parser、Binder、Checker、Emitter 五大核心部分每个部分都有独立的源文件。它们按照固定的流水线依次协作docs/compiler/overview.md 给出了最直观的概括SourceCode ~~ scanner ~~ Token Stream ~~ parser ~~ AST也就是说Parser 的输入是 Token 流输出是抽象语法树AST而 Token 流由更底层的 Scanner 负责从原始源码中切分出来。在整条流水线中Parser 处于承上启下的位置AST ~~ binder ~~ Symbols // 语义符号表 AST Symbols ~~ checker ~~ Type Validation // 类型检查 AST Checker ~~ emitter ~~ JS // 代码生成本文聚焦于流水线的第二段Token Stream 是如何被 Parser 组织成 AST 的。Binder、Checker、Emitter 只是作为下游引用出现不展开赘述。顺带一提语法正确不等于语义正确。例如var foo: number not a number;在语法上完全合法却通不过类型检查——这正是 Parser只管语法与 Checker负责语义分工的意义所在也是理解编译器分层时值得先建立的心智模型。二、Parser 的实现形态一个带状态的单例 namespace根据 docs/compiler/parser.md 的描述TypeScript 解析器的全部源码都位于parser.ts中它内部控制着 Scanner完成源码 → AST的转换。Parser 的实现有一个非常鲜明的特点它是作为单例实现的。具体来说Parser 是一个namespace Parser里面既存放解析器自身的状态变量也持有一个 Scanner 单例namespace Parser { // 状态变量 let sourceFile: SourceFile; let token: SyntaxKind; // ... // 单例 scannerconst 声明全程复用 const scanner createScanner(ScriptTarget.Latest, /*skipTrivia*/ true); // ... }采用单例的原因与 Scanner 一致避免反复创建/销毁对象的开销能复用就复用需要时通过重初始化reinit恢复到可用状态。这一点在 docs/compiler/scanner.md 中也有呼应——parser.ts中创建了单例scanner并在需要时用initializeState函数预热它。所有解析函数都围绕这个共享的 scanner 实例工作从 scanner 读取当前 token、利用 scanner 的位置信息记录 AST 节点的pos/end。从源码结构看这种单例 状态变量 复用 scanner的设计让 Parser 的入口parseSourceFile可以一次性把所有状态文件内容、token、诊断信息等初始化到位随后解析函数之间通过共享状态高效协作而不是每次调用都重新分配对象。三、调用链Parser 如何被 Program 间接驱动Parser 并不是被外部直接调用的而是由Program间接驱动的。中间隔着一层CompilerHost。docs/compiler/program.md 说明了 Program 与外界交互的机制Program *-uses-* CompilerHost *-uses-* SystemCompilerHost作为间接层把接口粒度精调到恰好满足 Program 的需求例如 Program 不关心System提供的fileExists。而 docs/compiler/parser.md 给出了从 Program 一路下沉到 Parser 的简化调用栈Program - CompilerHost.getSourceFile - (global function parser.ts).createSourceFile - Parser.parseSourceFile这段调用链的关键信息如下CompilerHost.getSourceFile是编译上下文的入口Program 通过它按文件名获取SourceFile真正干活的是parser.ts中暴露的全局函数createSourceFile即ts.createSourceFileNode.js / 浏览器环境下可直接调用它最终委托给Parser.parseSourceFile完成解析。parseSourceFile承担了两件大事预热 Parser 自身状态当前 token、源文件引用、诊断列表等调用initializeState预热单例 scannersetText设置源码文本、setOnError挂载错误回调、setScriptTarget指定脚本目标、setLanguageVariant指定语言变体等。状态就绪后parseSourceFile再交给parseSourceFileWorker真正开始逐段解析详见下文第六节。Program 拿到返回的SourceFile后将其作为 AST 的根节点纳入自己的getSourceFiles(): SourceFile[]集合。四、实战用ts.createSourceFile获取并打印 AST在深入 Parser 内部之前先用一段可运行的代码直观感受 AST 长什么样。仓库提供了完整示例 code/compiler/parser/runParser.tsimport * 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);4.1 运行方式示例代码依赖 npm 包ntypescript一个早期 TypeScript 编译器发布版本见 code/compiler/package.json 中的ntypescript: 1.201507141013.1。在 code/compiler 目录下执行npm install # 安装 ntypescript 依赖 node parser/runParser.js # 直接运行仓库中已编译好的 JS 版本仓库的 code/compiler/tsconfig.jsonversion: 1.5.0-betatarget 为 es5、module 为 commonjs已经把parser/runParser.ts、scanner/runScanner.ts、scanner/runScannerWithPositions.ts列入编译文件清单因此也可以用tsc重新编译.ts源文件后再运行。4.2 输出一棵向右倾斜的树运行后将打印出如下结构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把脑袋向左歪一下这就是一棵标准的树层级由----缩进表示。每一个节点行都包含三类信息节点类型由ts.syntaxKindToName(node.kind)打印的SyntaxKind名称如VariableStatement、Identifier起始位置pos该节点在源码中的起始偏移从 0 开始结束位置end该节点在源码中的结束偏移左闭右开。对照源码var foo 123;逐字符数一下var占据[0,3)foo占据[3,7)占据[7,9)123占据[9,13);占据[13,14)——与输出完全吻合。这正是 AST 节点都实现TextRange含start/end的结果位置信息让每个节点都能精确回溯到原始代码片段。版本说明原文档 docs/compiler/parser.md 示例中打印函数写的是ts.formatSyntaxKind而仓库中实际可运行的 code/compiler/parser/runParser.ts 使用ts.syntaxKindToName。两者作用相同把SyntaxKind枚举值转成可读名称编写自己的工具时以实际可运行的 API 为准。五、AST 的基本构件Node 与 SourceFile要读懂上面的树需要先了解 AST 的两种基本构件相关定义详见 docs/compiler/ast.md。5.1 NodeAST 的积木块Node是 AST 最基本的构建单元对应语言文法中的非终结符如语句、表达式但个别终结符如标识符、字面量也会保留在树中var、123出现在输出里正是这个原因。每个节点由两样东西共同定义SyntaxKind标识节点在 AST 中的类型如VariableStatement、Identifierinterface节点实例化后对外提供的 API如VariableDeclaration接口暴露name、initializer等字段。interface Node中有几个对遍历至关重要的成员TextRange成员pos与end标识节点在源文件中的起止位置第四节的输出已经验证过parent?: Node节点在 AST 中的父节点引用是自底向上遍历的基础。此外 Node 还有标志位flags、修饰符modifiers等成员可在源码中搜索interface Node查看全貌。5.2 SourceFile整棵树的根SyntaxKind.SourceFileinterface SourceFile每个SourceFile都是 AST 的顶层节点对应一个源文件并被包含在Program中Program 的getSourceFiles(): SourceFile[]返回全部源文件。在输出中SourceFile正是树的根其末尾总是挂着一个EndOfFileToken位置14 14即文件结尾的空区间 token。六、核心解析函数从parseSourceFileWorker到parseStatements前文提到parseSourceFile完成状态初始化后把真正的工作交给parseSourceFileWorker。这部分的函数分解在 docs/compiler/parser-functions.md 中有详尽描述。6.1parseSourceFileWorker它的执行流程分三步创建SourceFileAST 节点作为整棵树的根从parseStatements开始递归解析源码主体解析结束后回填SourceFile的补充信息例如nodeCount节点总数、identifierCount标识符数量等统计字段。这些统计信息是增量编译与诊断系统的重要依据节点数越多后续增量解析需要校验的工作量越大。6.2parseStatements按当前 token 分发parseStatements是众多parseFoo风格函数中最具代表性的一员。它的工作方式是根据 scanner 返回的当前token进行 switch 分发决定接下来解析哪种语句。例如当前 token 是SemicolonToken→ 调用parseEmptyStatement为;创建空语句节点当前 token 是VarKeyword→ 调用parseVariableStatement解析变量声明依此类推直到遇到EndOfFileToken或}等终止符才停止。这种看 token 决定语法分支的写法是手写递归下降解析器的典型风格。6.3 节点创建的黄金三函数以parseEmptyStatement为例Parser 中有一大批parseFoo函数函数体负责创建Foo节点一般在其他解析函数期望某个Foo节点的地方被调用。parseEmptyStatement是理解整套流程的最佳范本它专门解析;;;;;;这类空语句函数完整代码如下function parseEmptyStatement(): Statement { let node StatementcreateNode(SyntaxKind.EmptyStatement); parseExpected(SyntaxKind.SemicolonToken); return finishNode(node); }短短四行浓缩了三个关键函数createNode、parseExpected、finishNode。三者职责如下createNode创建并初始化节点function createNode(kind: SyntaxKind, pos?: number): Node职责创建 Node把传入的SyntaxKind设置到节点上并设置起始位置——若显式传入pos则用之否则取当前 scanner 状态中的位置。这解释了为什么 AST 节点的pos能精确对应源码偏移节点在扫描到哪、就记到哪的位置上诞生。parseExpected校验 token 并报告错误function parseExpected(kind: SyntaxKind, diagnosticMessage?: DiagnosticMessage): boolean职责检查当前 parser 状态中的 token是否等于期望的SyntaxKind。若不匹配若调用方传入了diagnosticMessage则报告该消息否则生成一个通用的foo expected形式的消息例如; expected。它在内部使用parseErrorAtPosition函数而后者依赖scanner 的扫描位置来给出精确的错误定位。这就是为什么 TypeScript 语法错误能准确指向第几行第几列缺了什么符号——错误位置信息在解析阶段就由 scanner 提供了。finishNode收尾并记录元信息function finishNodeT extends Node(node: T, end?: number): T职责设置节点的end位置不传则取当前 scanner 位置并记录额外的有用信息parserContextFlags节点在哪种解析上下文如类型上下文、装饰器上下文下被解析出来错误标记解析该节点之前是否已经出现错误——如果前面有错误则该 AST 节点不能用于增量解析时复用因为有错误的树无法保证可增量复用。七、与 Scanner 的协同单例 scanner、状态初始化与位置追踪Parser 与 Scanner 是密不可分的搭档。docs/compiler/scanner.md 从 Scanner 一侧详细记录了两者的协作方式这是理解 Parser 状态管理的关键补充。7.1 单例 scanner 与initializeStateparser.ts中维护着一个单例 scannercreateScanner(ts.ScriptTarget.Latest, true)创建第二个参数skipTrivia表示跳过注释、空白等琐碎内容目的是避免反复创建 scanner 的开销。Parser 按需用initializeState函数对 scanner 进行预热。仓库示例 code/compiler/scanner/runScanner.ts 用一段可运行代码演示了这个概念import * as ts from ntypescript; // TypeScript has a singleton scanner const scanner ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true); // 与 Parser 内部 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); } // Sample usage initializeState( var foo 123; .trim()); // Start the scanning var token scanner.scan(); while (token ! ts.SyntaxKind.EndOfFileToken) { console.log(ts.formatSyntaxKind(token)); token scanner.scan(); }预热过程包含四个关键调用方法作用setText(text)设置待扫描的源码文本setOnError(cb)挂载扫描错误回调收到DiagnosticMessage与错误长度setScriptTarget(target)设置脚本目标版本如 ES5影响 token 识别规则setLanguageVariant(variant)设置语言变体Standard/JSX影响 JSX 等语法元素的切分对var foo 123;依次调用scan()会得到与 Parser 输入完全一致的 Token 序列VarKeyword Identifier FirstAssignment FirstLiteralToken SemicolonToken可以看到Token 流恰好就是 Parser 的食材——parseStatements看到VarKeyword就分发到变量语句解析看到SemicolonToken就分发到空语句解析。7.2 Scanner 状态与位置追踪每次scan()之后scanner 都会更新内部状态当前扫描位置、当前 token 详情等并对外提供一组工具函数读取当前状态。利用getStartPos()可以在两次扫描之间取出 token 的起止偏移code/compiler/scanner/runScannerWithPositions.ts 演示了这一点// 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); }输出VarKeyword 0 3 Identifier 3 7 FirstAssignment 7 9 FirstLiteralToken 9 13 SemicolonToken 13 14这段输出与第四节中 AST 叶子节点的pos/end完全一致——AST 节点的位置信息正是从 scanner 的扫描位置继承下来的。这也再次印证了createNode中不传 pos 就取当前 scanner 位置的设计Scanner 是位置信息的唯一来源Parser 只是忠实的搬运工。7.3 独立使用 scanner虽然 Parser 内部使用单例 scanner但createScanner本身是公开 API你可以创建独立 scanner结合setText/setTextPos在文件的任意位置开始扫描。这在编写语法高亮、代码补全、统计工具等需要局部扫描的场景中非常实用不必走完整的 Parser 流程。八、遍历 AST 的两种方式getChildren与forEachChild前文示例用到了node.getChildren()递归打印全部子节点。除此之外TypeScript 还提供了ts.forEachChild工具函数docs/compiler/ast-tip-children.md 对两者做了清晰的区分getChildren()返回节点的所有子节点包括SemicolonToken等甚至EndOfFileToken是全量遍历ts.forEachChild(node, cb)根据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据此假定节点实现了对应 interface如BinaryExpression再对left/operatorToken/right等字段逐一调用回调。需要注意的是forEachChild不会访问所有子节点例如它跳过SemicolonToken如果确实需要 AST 中某个节点的全部子节点请直接调用.getChildren()。理解了这两种遍历方式你就可以编写自己的 AST 分析工具需要细节全量用getChildren如打印完整树需要按语法语义高效遍历用forEachChild如实现代码转换器。九、总结一张图串起 Parser 的全部要点把全文内容浓缩成一条主线流水线SourceCode ~~ scanner ~~ Token Stream ~~ parser ~~ ASTParser 输入 Token 流、输出 AST实现形态Parser 是带状态变量的namespace Parser单例内部持有单例 scanner用initializeState按需预热驱动方式Program - CompilerHost.getSourceFile - createSourceFile - Parser.parseSourceFile - parseSourceFileWorker由编译上下文间接驱动实战入口ts.createSourceFile(fileName, sourceCode, ScriptTarget, setParentNodes)一行代码即可拿到SourceFile根节点节点生产createNode创建 定位→parseExpected校验 token 报错→finishNode收尾 end 记录上下文/错误标记位置真相所有pos/end都来自 scanner 的扫描位置Parser 与 Scanner 共享同一套坐标遍历手段getChildren()全量子节点、ts.forEachChild语义化遍历按需选择。如果你希望继续深挖仓库还提供了与之配套的资料docs/compiler/scanner.mdScanner 词法扫描详解、docs/compiler/parser-functions.mdParser 函数逐一定义、docs/compiler/ast.mdNode 与 SourceFile 定义、docs/compiler/program.mdProgram 与 CompilerHost、以及 docs/compiler/overview.md整个编译器全景。动手运行仓库 code/compiler 下的三个示例parser/runParser.ts、scanner/runScanner.ts、scanner/runScannerWithPositions.ts是理解这条流水线最直观的方式。赞分享教程【免费下载链接】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 编译器源码解析Parser Functions 与 AST 节点创建机制typescript-book 解读TypeScript 编译器源码解析Parser Functions 与 AST 节点创建机制typescript book 解读 导读 本文聚焦于 T教程Mermaid Live Editor零代码门槛的可视化图表创作平台Mermaid Live Editor零代码门槛的可视化图表创作平台 在当今数字化协作的时代图表和可视化工具已成为技术沟通、项目管理和知识传递的核心要素。传教程Sim 项目 React Query 最佳实践审计指南Key Factory、staleTime、Mutation 与服务器状态所有权Sim 项目 React Query 最佳实践审计指南Key Factory、staleTime、Mutation 与服务器状态所有权 本指南基于 Sim 仓教程上一篇MiroFish群体智能引擎企业级预测分析解决方案与部署架构实践下一篇PrimeVue FocusTrap 指令深度解析Vue 焦点陷阱的原理、配置与源码实现创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考