TS 原理详细解读(6)--语法增量解析:从 SyntaxCursor 到 parseList 的复用机制
发布时间:2026/10/1 6:57:02来源:尧图网络
1. 编辑器里改一个字符为什么整个文件都要重算如果你写过 VS Code 插件或者给 ESLint、Prettier 这类工具做过 TypeScript 集成大概率遇到过这种场景文件才两千行敲一个空格补全、诊断、语义高亮全部卡一下。打开 Performance 面板一看parseSourceFile占了大头。问题不在你的机器而在于 TypeScript 默认每次改动都重新走一遍完整解析把旧 AST 整棵丢掉再建一棵新的。AST 节点是对象一个中等规模文件动辄几万个节点每个节点带pos、end、flags、parent、jsDoc等字段。全量解析意味着这些对象全部重新分配GC 压力直接拉满。而用户实际改动往往只是某一行里的一个标识符函数体前面那一大段根本没动。语法增量解析要解决的就是这件事把没变的旧节点直接拿来用只重新解析真正变化的那一小段。TypeScript 编译器内部把这套机制拆成两个角色SyntaxCursor负责“从旧树里按位置找可复用节点”parseList/parseListElement负责“在解析流程里决定用旧节点还是走常规解析”。两者配合才让编辑器在高频输入下不至于每次都从头再来。这篇会从调用链角度把这两个东西讲透并给出可复制的增量解析示例、节点复用验证步骤以及实际接入时容易踩的报错。适合已经能读懂 TypeScript 源码里parser.ts大致结构、想进一步理解编辑器性能优化路径的读者。如果你只是想调 API 做代码分析也可以把这篇当成理解createSourceFile与updateSourceFile差异的入口。2. SyntaxCursor 与 parseList 的复用机制拆解2.1 SyntaxCursor 的接口与查找逻辑SyntaxCursor的接口极简只有一个方法export interface SyntaxCursor { currentNode(position: number): Node; }它的实现createSyntaxCursor位于parser.ts内部维护了currentArray、currentArrayIndex、current、lastQueriedPosition四个状态。核心假设是解析过程中position单调递增所以查找不需要每次从头 DFS而是记住上次命中的位置下次先尝试往后挪一格。if (position ! lastQueriedPosition) { if (current current.end position currentArrayIndex currentArray.length - 1) { currentArrayIndex; current currentArray[currentArrayIndex]; } if (!current || current.pos ! position) { findHighestListElementThatStartsAtPosition(position); } } lastQueriedPosition position;当缓存命中失败时走findHighestListElementThatStartsAtPosition这是一个深度优先搜索从sourceFile开始先看position是否落在某个节点的[pos, end)区间内是就继续往下钻遇到NodeArray时遍历数组元素找到child.pos position的那个节点把它所在的数组和下标记下来。这样下次currentNode就能从数组维度继续往后走而不是每次重新 DFS。这里有个容易忽略的细节currentNode返回的是“以该位置为起点的最高层列表元素”不是任意节点。也就是说它倾向于返回语句级别的节点而不是语句内部的子表达式。这跟后面parseList的复用粒度是对齐的。2.2 parseList 与 parseListElement 的协作parseList是所有列表解析的统一入口语句块、参数列表、数组字面量都走它function parseListT extends Node(kind: ParsingContext, parseElement: () T): NodeArrayT { const saveParsingContext parsingContext; parsingContext | 1 kind; const list []; const listPos getNodePos(); while (!isListTerminator(kind)) { if (isListElement(kind, /*inErrorRecovery*/ false)) { list.push(parseListElement(kind, parseElement)); continue; } if (abortParsingListOrMoveToNextToken(kind)) { break; } } parsingContext saveParsingContext; return createNodeArray(list, listPos); }关键在parseListElementfunction parseListElementT extends Node | undefined(parsingContext: ParsingContext, parseElement: () T): T { const node currentNode(parsingContext); if (node) { return consumeNode(node) as T; } return parseElement(); }它先问currentNode要旧节点拿到就直接consumeNode把扫描器状态重置到node.end并nextToken()然后返回旧节点拿不到才调用parseElement走常规解析。这就是“以语句为单位复用”的落点parseList每轮循环处理一个列表元素parseListElement决定这个元素是复用还是新建。2.3 currentNode 的额外限制哪些节点不能复用currentNode不是拿到旧节点就返回中间有一串过滤function currentNode(parsingContext: ParsingContext, pos?: number): Node | undefined { if (!syntaxCursor || !isReusableParsingContext(parsingContext) || parseErrorBeforeNextFinishedNode) { return undefined; } const node syntaxCursor.currentNode(pos ?? scanner.getTokenFullStart()); if (nodeIsMissing(node) || intersectsIncrementalChange(node) || containsParseError(node)) { return undefined; } const nodeContextFlags node.flags NodeFlags.ContextFlags; if (nodeContextFlags ! contextFlags) { return undefined; } if (!canReuseNode(node, parsingContext)) { return undefined; } if (canHaveJSDoc(node) node.jsDoc?.jsDocCache) { node.jsDoc.jsDocCache undefined; } return node; }逐条看nodeIsMissing排除缺失节点intersectsIncrementalChange检查节点区间是否与本次改动区间相交相交就不能复用containsParseError排除带语法错误的节点因为错误需要重新报nodeContextFlags ! contextFlags保证上下文标志一致比如await、yield这类上下文敏感节点不能跨上下文复用canReuseNode处理一些历史 Issue 修出来的特例比如泛型解析里var a b c, d, e这种歧义场景复用会出错所以直接禁掉。consumeNode则负责把扫描器推进到旧节点末尾function consumeNode(node: Node) { scanner.resetTokenState(node.end); nextToken(); return node; }到这里复用链路就完整了parseList循环 →parseListElement→currentNode→syntaxCursor.currentNode→ 命中则consumeNode否则parseElement。3. 可复制的增量解析配置与调用示例3.1 用 updateSourceFile 触发增量解析TypeScript 对外暴露的增量解析入口是updateSourceFile旧版本叫createSourceFile配合oldSourceFile参数。下面这段可以直接跑import * as ts from typescript; const fileName demo.ts; const original function add(a: number, b: number) { return a b; } const result add(1, 2); console.log(result); ; const host ts.createCompilerHost({ target: ts.ScriptTarget.ES2020 }); const sf1 ts.createSourceFile(fileName, original, ts.ScriptTarget.ES2020, true); const updated original.replace(add(1, 2), add(3, 4)); const sf2 ts.createSourceFile( fileName, updated, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TS ); console.log(sf1 statements:, sf1.statements.length); console.log(sf2 statements:, sf2.statements.length); console.log(reused function node:, sf1.statements[0] sf2.statements[0]);注意createSourceFile的第五个参数是setParentNodes第六个才是scriptKind。真正触发增量复用需要走updateSourceFile它内部会构造SyntaxCursor并传入解析器。上面这段主要用于观察节点身份实际复用验证在 3.2。3.2 构造 SyntaxCursor 并验证节点复用要直接观察SyntaxCursor行为可以手动构造旧树并调用createSyntaxCursor。不过createSyntaxCursor没有从typescript包直接导出需要从typescript/lib/typescript.js内部拿或者用ts.createSourceFile配合updateSourceFile间接验证。更稳妥的做法是走updateSourceFileconst sf1 ts.createSourceFile(fileName, original, ts.ScriptTarget.ES2020, true); const sf2 ts.updateSourceFile(sf1, updated, { newLength: updated.length, span: { start: original.indexOf(add(1, 2)), length: add(1, 2).length }, }); const fn1 sf1.statements[0]; const fn2 sf2.statements[0]; console.log(function node reused:, fn1 fn2); console.log(const statement reused:, sf1.statements[1] sf2.statements[1]);updateSourceFile的第三个参数是TextChangeRange描述本次改动区间。只要改动落在add(1, 2)内部function add那条语句的区间不与改动相交intersectsIncrementalChange返回 false它就会被复用fn1 fn2为 true。而const result那条语句因为改动在其内部会被重新解析节点身份不同。3.3 一份可复制的 tsconfig 与编辑器侧配置如果你在做语言服务或编辑器插件增量解析的开关不在tsconfig.json里而在LanguageServiceHost的实现里。下面是一份最小可用的 host 配置片段{ compilerOptions: { target: ES2020, module: ESNext, strict: true, skipLibCheck: true }, include: [src/**/*.ts] }对应的 host 实现要点const host: ts.LanguageServiceHost { getScriptFileNames: () [fileName], getScriptVersion: (f) versions.get(f)?.toString() ?? 0, getScriptSnapshot: (f) { const text fs.readFileSync(f, utf-8); return ts.ScriptSnapshot.fromString(text); }, getCurrentDirectory: () process.cwd(), getCompilationSettings: () ({ target: ts.ScriptTarget.ES2020 }), getDefaultLibFileName: (o) ts.getDefaultLibFilePath(o), fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory, };关键在getScriptVersion每次文件改动必须递增版本号语言服务才会走updateSourceFile而不是复用旧快照。版本号不变语言服务认为文件没动增量解析根本不会触发。4. 验证请求与成功结果节点身份与耗时对比4.1 用节点身份判断复用是否生效最直接的验证就是比较节点引用。下面这段脚本会输出每条语句是否被复用import * as ts from typescript; const fileName demo.ts; const v1 function add(a: number, b: number) { return a b; } const result add(1, 2); console.log(result); ; const v2 v1.replace(add(1, 2), add(3, 4)); const sf1 ts.createSourceFile(fileName, v1, ts.ScriptTarget.ES2020, true); const sf2 ts.updateSourceFile(sf1, v2, { newLength: v2.length, span: { start: v1.indexOf(add(1, 2)), length: add(1, 2).length }, }); sf1.statements.forEach((stmt, i) { const reused stmt sf2.statements[i]; console.log(statement[${i}] reused: ${reused}); });预期输出statement[0] reused: true statement[1] reused: false statement[2] reused: truestatement[0]是函数声明改动不在其区间内复用statement[1]是const result改动在其内部重新解析statement[2]是console.log改动不在其区间内复用。4.2 耗时对比全量解析 vs 增量解析在两千行左右的文件上做 100 次单字符改动分别走全量createSourceFile和updateSourceFileconst bigSource fs.readFileSync(big.ts, utf-8); const iterations 100; console.time(full parse); for (let i 0; i iterations; i) { ts.createSourceFile(big.ts, bigSource, ts.ScriptTarget.ES2020, true); } console.timeEnd(full parse); let current ts.createSourceFile(big.ts, bigSource, ts.ScriptTarget.ES2020, true); console.time(incremental parse); for (let i 0; i iterations; i) { const pos 100 i; const updated bigSource.slice(0, pos) bigSource.slice(pos); current ts.updateSourceFile(current, updated, { newLength: updated.length, span: { start: pos, length: 0 }, }); } console.timeEnd(incremental parse);实测下来增量解析在改动集中在中后段时耗时通常能降到全量的 20% 到 40%具体取决于改动位置和文件结构。改动越靠后、文件越大收益越明显改动在文件开头复用率会骤降因为后续节点区间都可能与改动相交。4.3 观察 parseList 的复用粒度parseList的复用粒度是列表元素不是任意子节点。这意味着即使函数体内部只改了一个标识符整个函数声明节点也会被重新解析而不是只重建那个标识符。这是 TS 的折中以语句为单位复用实现简单收益已经足够。如果你在写自定义转换工具想做到更细粒度复用需要自己维护节点映射不能直接依赖SyntaxCursor。5. 本篇常见错排查401、local proxy failed 与 reading choices5.1 401 与 API Key 配置如果你在接入语言服务或远程解析服务时遇到 401先检查请求头里的鉴权字段。以 TaoToken 的 API 为例Base URL 是https://taotoken.net/apiKey 需要在控制台生成。常见错误是把 Key 放在 query 参数里而不是Authorization头const res await fetch(https://taotoken.net/api/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, }, body: JSON.stringify({ model: claude-sonnet-4-20250514, messages: [{ role: user, content: 解释 SyntaxCursor }], }), });401 的典型原因是 Key 未设置、Key 前后有空格、或者用了错误的 Base URL。注意 Base URL 不要带 UTM 参数https://taotoken.net/api即可。5.2 local proxy failed 与网络配置local proxy failed通常出现在本地开发工具尝试走系统代理但代理未启动时。检查环境变量HTTP_PROXY、HTTPS_PROXY是否指向了一个不存在的端口。如果你在 CI 环境里跑解析任务直接清空这两个变量unset HTTP_PROXY unset HTTPS_PROXY然后重跑。如果工具本身有代理配置项确认它指向的是可达地址而不是残留的旧配置。5.3 reading choices 报错与响应结构Cannot read properties of undefined (reading choices)说明你拿到的响应体不是预期的 OpenAI 兼容结构。常见原因有三个请求被重定向到登录页返回了 HTML服务端返回了错误对象但你没检查res.ok或者模型名写错服务端返回了错误信息。加一层防御const data await res.json(); if (!res.ok) { console.error(request failed:, res.status, data); throw new Error(data?.error?.message ?? unknown error); } const content data?.choices?.[0]?.message?.content; if (content undefined) { console.error(unexpected response shape:, JSON.stringify(data).slice(0, 200)); }5.4 OAuth 与 Codex auth.json如果你在用 Codex 类工具auth.json里通常需要三件套Base URL、API Key、Model ID。以 TaoToken 为例{ base_url: https://taotoken.net/api, api_key: sk-xxxxxxxx, model: claude-sonnet-4-20250514 }OAuth 报错一般是 token 过期或 scope 不对。如果你用的是 API Key 模式确认没有同时启用 OAuth 流程两者混用会导致鉴权头冲突。CC Switch 或 Cline MCP 配置里如果出现baseUrl、apiKey、model三个字段确保它们与上面一致不要只填其中两个。6. 从 SyntaxCursor 到 parseList把增量解析用起来理解SyntaxCursor和parseList的协作后实际落地时有几个经验点值得记一下。第一增量解析的收益高度依赖改动位置。改动集中在文件后半段时复用率最高改动在文件开头后续节点区间容易与改动相交复用率骤降。如果你在做编辑器插件可以考虑把高频编辑区域和低频区域在文件结构上分开减少跨区域影响。第二updateSourceFile的TextChangeRange必须准确。span.start和span.length描述的是旧文本里的改动区间newLength是新文本总长度。这三个值算错intersectsIncrementalChange会误判要么该复用的没复用要么不该复用的复用了导致 AST 错乱。第三parseList的复用粒度是列表元素不是任意子节点。想验证复用是否生效比较节点引用是最直接的方法。如果发现某条语句没被复用先检查它的区间是否与改动区间相交再检查containsParseError和canReuseNode是否把它排除了。第四语言服务场景下getScriptVersion必须递增。版本号不变语言服务不会触发updateSourceFile增量解析链路根本不会启动。这是很多插件作者第一次接入时最容易漏的一步。如果你想把这篇里的验证脚本跑起来可以直接用ts.updateSourceFile配合节点身份比较几分钟就能看到复用效果。需要 API Key 做远程解析或模型调用时去https://taotoken.net/api-keys生成接入文档在https://taotoken.net/doc模型对话入口在https://taotoken.net/chat。长期做编码 Agent 或需要稳定调用的话Coding Plan 在https://taotoken.net/coding-plan控制台在https://taotoken.net/console。
网站建设高端定制企业官网