本节目标:理解编辑器里的类型提示、跳转与重命名究竟由谁提供,分清 TS Server 的私有协议与 LSP 的边界,掌握 Project 的加载策略与生命周期,并能用 Node 脚本直接向 TS Server 发请求做批量查询。读完本节,你能看懂编辑器日志里的协议报文,也为下一节编写 tsPlugin 铺好路。
6.1 TS Server 与 LSP
在上一章我们用 Compiler API 亲手构造了 Program,那是「一次性」的用法:给一批文件,算出类型与产物,结束。但你在编辑器里敲代码时,类型提示是毫秒级返回的,改一个字符后整屏的红色波浪线立刻重算——如果每次都重新 createProgram,一个中型仓库要几秒,编辑器早就卡死了。这背后是另一个进程:TS Server(可执行文件叫 tsserver)。
这一节要回答三个问题:编辑器与 TS Server 之间说什么协议?它凭什么能毫秒级响应?我们能像编辑器一样直接驱动它吗?如果你对编译器前后端的整体划分还不熟,可以先看 编译器架构与多趟设计 。
6.1.1 为什么是常驻进程而不是每次都跑 tsc
tsc 是一个批处理程序,生命周期是「解析配置 → 建 Program → 检查 → 产出 → 退出」。它把全部状态放在内存里,退出即丢弃。编辑器需要的是完全相反的东西:
- 状态要跨请求保留:源码、AST、符号表、类型缓存都得活着,否则每次按键都要重建。
- 请求要细粒度:只要「第 12 行第 8 列的符号是什么类型」,而不是整个项目的错误列表。
- 响应要可取消:用户继续打字时,上一次的查询结果已经过期,要能中止。
于是 TypeScript 把编译器内核拆出一个服务层,包成常驻进程 tsserver。编辑器通过子进程的 stdin/stdout 与它通信,进程崩了重启即可,不拖垮编辑器。
# tsserver 就在 typescript 包里,不需要额外安装
node node_modules/typescript/lib/tsserver.js --version
# Version 5.6.3
# 查看它支持的所有命令行开关
node node_modules/typescript/lib/tsserver.js --help
关键认知:你在编辑器里看到的一切类型信息,都来自这个进程,而不是 tsc。 这也解释了一个常见困惑——为什么 CI 里 tsc --noEmit 报的错和编辑器里的红线不完全一致。
6.1.2 TS Server 协议与 LSP 是两回事
很多人把「TS Server」和「LSP」当成同义词,其实它们是两套协议:
| 维度 | TS Server 私有协议 | LSP(Language Server Protocol) |
|---|---|---|
| 提出方 | TypeScript 团队,为自家编辑器体验服务 | 微软为跨语言编辑器统一接口 |
| 传输 | 子进程 stdin/stdout,Content-Length 分帧 | 同样 Content-Length 分帧,但可用 socket |
| 消息形态 | {seq, type, command, arguments} 请求 / {type:"response"} / {type:"event"} | JSON-RPC 2.0:request / response / notification |
| 能力范围 | 类型系统全暴露:quickinfo、navtree、semanticDiagnosticsSync、projectInfo | 通用编辑能力:textDocument/hover、definition、rename |
| 谁在用 | VS Code、Sublime、Vim 的 TS 插件(原生协议) | Neovim、Emacs 等通过 typescript-language-server 包装 |
| 稳定性 | 官方明确「不保证兼容」,命令随时增删 | 有版本化规范,向后兼容约束强 |
两者最本质的区别是信息密度:LSP 只规定「悬停要返回 markdown 文本」,而 TS Server 协议直接返回 displayString、kindModifiers、start/end 偏移量这些类型系统内部结构。VS Code 的 TS 插件走的是私有协议,因此能做出 LSP 表达不了的功能(比如「重构:提取类型」、内联提示)。
选择原则很简单:
- 你要写编辑器插件且只服务 TS → 用私有协议,能力最全。
- 你要写跨语言工具或让 TS 进 Neovim → 用 LSP,牺牲一点能力换通用性。
跨编辑器的具体形态是 typescript-language-server:它是一个薄包装,启动 tsserver 并把私有协议翻译成 LSP。这意味着 LSP 客户端的能力上限被这层翻译卡住——私有协议里有的 navtree、getCodeFixes 组合,LSP 侧不一定有对应方法,或者只能降级表达。
延伸阅读可以看 语言服务与编辑器插件 ,那篇从插件使用者视角展开;本节则聚焦协议本身。
6.1.3 协议报文长什么样
私有协议的消息都是 JSON,前面加一行 Content-Length: N 再空一行,与 LSP 的分帧规则一致。一次「悬停查询」的完整往返如下。
请求(编辑器 → 服务器):
{
"seq": 3,
"type": "request",
"command": "quickinfo",
"arguments": {
"file": "/work/src/index.ts",
"line": 3,
"offset": 7
}
}
响应(服务器 → 编辑器):
{
"seq": 0,
"type": "response",
"request_seq": 3,
"success": true,
"command": "quickinfo",
"body": {
"kind": "const",
"kindModifiers": "",
"start": { "line": 3, "offset": 7 },
"end": { "line": 3, "offset": 14 },
"displayString": "const greeting: string",
"documentation": ""
}
}
注意 line 和 offset 都是 1 起始,不是 0。这是新手最容易踩的坑:Compiler API 里 getLineAndCharacterOfPosition 返回的 line/character 是 0 起始,而协议层是 1 起始,转换时别忘了 +1。
除了 response,服务器还会主动推 event,最常见的是 projectLoadingFinish(项目加载完)、semanticDiag(语义诊断结果)、configFileDiag(tsconfig 自身有错):
{
"seq": 0,
"type": "event",
"event": "projectLoadingFinish",
"body": { "projectName": "/work/tsconfig.json" }
}
为什么诊断是异步推送的? 因为全量类型检查很贵。编辑器发出 open 后,服务器先返回一个「已受理」,然后在后台跑检查,完成后用事件把结果推回来。编辑器在此期间保持可交互——这正是 tsserver 与 tsc 设计哲学的分野。
编辑器在等待期间继续打字时,会发一条 change,只带增量区间而不是全文:
{
"seq": 4,
"type": "request",
"command": "change",
"arguments": {
"file": "/work/src/index.ts",
"line": 3,
"offset": 1,
"endLine": 3,
"endOffset": 5,
"insertText": "let "
}
}
change 的语义是「把 [start, end) 这段区间替换成 insertText」,insertText 为空串即删除。只有增量、没有全文,意味着服务器必须维护一份与编辑器一致的源码镜像——所以 open / change / close 的顺序不能乱,一旦客户端与服务器的文档状态漂移,后续所有位置都会错位。
请求还可能被取消。客户端把某条请求标为 cancellable: true 后,若结果已过期,可以再发一条 cancel 命令;服务层内部遍布 CancellationToken 检查点,遇到取消就提前返回。这也是「快速打字时不卡」的第三层保障:过期计算主动让路。
6.1.4 Project 的加载策略与生命周期
TS Server 内部把文件组织成 Project,每个 Project 对应一个 Program 加一层增量缓存。Project 分三类,理解它们的来源是排查「为什么这个文件没类型提示」的关键:
| 类型 | 触发条件 | 使用的编译选项 |
|---|---|---|
| configured project | 文件落在某个 tsconfig.json 的 include 范围内 | 该 tsconfig 的全部选项 |
| inferred project | 文件不在任何 tsconfig 覆盖范围内(比如单开的临时 .ts) | 由编辑器通过 compilerOptionsForInferredProjects 下发 |
| external project | 编辑器显式打开的、由别的项目引用的文件 | 由请求参数指定 |
生命周期由几个命令驱动:
- 编辑器启动 → 发
configure,告知日志级别、宿主信息。 - 打开文件 → 发
open,服务器为它找到(或创建)Project。 - 用户打字 → 发
change,只传增量区间(start、end、新文本),服务器在内存里更新源码并失效受影响的类型缓存。 - 关闭文件 → 发
close。
change 只传增量而非全文,是毫秒级响应的第一层保障;第二层是 Project 内部的结构共享:没被编辑的文件节点直接复用上一次的 AST,只重建受影响的子树。这与我们在 类型实例化开销与测量
里讨论的「实例化很贵」是同一枚硬币的两面——正因为贵,服务层才要做这么多缓存。
用 projectInfo 命令可以直接观察:
{ "seq": 9, "type": "request", "command": "projectInfo",
"arguments": { "file": "/work/src/index.ts", "needFileNameList": true } }
返回的 body.configFileName 为 undefined 时,就说明这个文件落进了 inferred project——十有八九是它不在任何 tsconfig 的 include 里。这是「明明有 tsconfig 却没有任何类型提示」的最常见原因。
6.1.5 用 Node 脚本直接驱动 TS Server
既然协议是公开的 JSON,我们就可以像编辑器一样驱动它,做批量查询(比如「列出全仓库所有导出的函数签名」)。下面是一个最小可跑的客户端:
import { spawn } from "node:child_process";
const server = spawn("node", ["node_modules/typescript/lib/tsserver.js"], {
stdio: ["pipe", "pipe", "pipe"],
});
let seq = 0;
const pending = new Map<number, (body: unknown) => void>();
function request(command: string, args: object): Promise<unknown> {
const id = ++seq;
const body = JSON.stringify({ seq: id, type: "request", command, arguments: args });
server.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
return new Promise((resolve) => pending.set(id, resolve));
}
server.stdout.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
const sep = text.indexOf("\r\n\r\n");
const msg = JSON.parse(text.slice(sep + 4));
if (msg.type === "response" && pending.has(msg.request_seq)) {
pending.get(msg.request_seq)!(msg.body);
pending.delete(msg.request_seq);
}
});
const file = "/work/src/index.ts";
await request("open", { file });
const info = await request("quickinfo", { file, line: 3, offset: 7 });
console.log(JSON.stringify(info, null, 2));
server.kill();
真实输出:
{
"kind": "const",
"kindModifiers": "",
"start": { "line": 3, "offset": 7 },
"end": { "line": 3, "offset": 14 },
"displayString": "const greeting: string",
"documentation": ""
}
这段代码有一个刻意留下的缺陷:data 事件不保证一条消息一次到达,管道会把消息切开或合并。生产实现必须按 Content-Length 累积缓冲区、切出完整帧再解析。上面为便于阅读假设了「一次一个完整帧」,直接拿去跑长文件会偶发 JSON.parse 报错。正确的读循环如下:
let buffer = Buffer.alloc(0);
function handle(msg: any): void {
if (msg.type === "response" && pending.has(msg.request_seq)) {
pending.get(msg.request_seq)!(msg.body);
pending.delete(msg.request_seq);
}
}
server.stdout.on("data", (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
const sep = buffer.indexOf("\r\n\r\n");
if (sep < 0) return;
const header = buffer.subarray(0, sep).toString("utf8");
const len = Number(/Content-Length:\s*(\d+)/i.exec(header)![1]);
if (buffer.length < sep + 4 + len) return; // 帧还没收全,等下一个 data
const json = buffer.subarray(sep + 4, sep + 4 + len).toString("utf8");
buffer = buffer.subarray(sep + 4 + len);
handle(JSON.parse(json));
}
});
判断条件「缓冲区剩余长度 ≥ 头部长度 + Content-Length」是整个协议层唯一的难点,写错就会在长输出(比如大项目的诊断列表)上偶发解析失败。
如果你不想自己处理分帧,可以用 typescript/lib/tsserverlibrary 暴露的 server.ProjectService 在进程内驱动,省去 IPC;代价是它同样没有兼容性承诺,升级 TypeScript 时要回归测试。
6.1.6 常见坑与错误信息
| 现象 | 根因 | 处理 |
|---|---|---|
日志里 Error: Debug Failure. | 协议字段缺 seq 或 type 拼错 | 用 --logVerbosity verbose 打开日志逐条比对 |
quickinfo 返回 success: false 且 message: "Could not find source file" | 没先发 open,或路径不是绝对路径 | 先 open,路径统一 path.resolve |
| 拿到的位置永远差 1 | 忘了协议层行列 1 起始 | 从 Compiler API 转过来时 +1 |
| 整个项目无提示,但文件能编译 | 文件落在 inferred project | 检查 include/files 是否覆盖该目录 |
| 服务器吃满 CPU 且不返回 | 客户端未处理 event,把事件当响应 | 按 type 分流,event 不进 pending |
| 改了 tsconfig 但行为没变 | 服务器缓存了旧配置 | 发 reloadProjects 或重启进程 |
打开 --logVerbosity verbose 后,日志形如:
Info 10:22:31.011 Project 'tsconfig.json' (Configured) 0 files
Info 10:22:31.012 Elapsed:: 0.11s Loading project
Info 10:22:31.240 Project '/work/src/index.ts' (Inferred) 1 file
Err 10:22:31.255 Exception on executing command quickinfo:
Debug Failure. False expression: Expected file to be in project.
看到 (Inferred) 基本就能定位问题;看到 Debug Failure 则是客户端报文不合规。
还有一个隐蔽问题:不要把 TS Server 当长期 CI 检查器用。它是为交互设计的,全量 semanticDiagnosticsSync 在超大仓库上会占住进程;CI 请用 tsc --noEmit 或 tsc --build,那才是为吞吐优化的路径。
6.1.7 什么时候该绕开 TS Server
TS Server 不是万能钥匙,选型可以按这张表判断:
- 需要交互式、增量、细粒度查询,且接受「无兼容承诺」→ TS Server。
- 需要一次性全量检查或产出,追求稳定与速度 →
tsc --noEmit/tsc --build(增量构建的细节可看 查询式增量编译 )。 - 需要跨编辑器复用 → 走 LSP,用
typescript-language-server做适配层。
下一节我们把视角从「客户端」切到「服务端」:既然 TS Server 暴露了语言服务对象,我们就能往它内部挂自己的逻辑,这就是 tsPlugin。
小结
- 编辑器里的类型能力来自常驻进程
tsserver,不是tsc;前者为交互优化,后者为吞吐优化。 - TS Server 私有协议与 LSP 分帧方式相同,但消息形态与信息密度完全不同:私有协议暴露类型系统内部结构,LSP 只保证通用编辑能力。
- 协议层
line/offset是 1 起始,与 Compiler API 的 0 起始不同,转换时必须对齐。 - 文件被组织成 configured / inferred / external 三类 Project;落到 inferred project 是「有 tsconfig 却没提示」的头号原因。
- 协议是公开 JSON,可以用 Node 脚本驱动做批量查询,但必须自己按
Content-Length分帧。 - 全量检查交给
tsc,交互式查询才交给 TS Server——这是本节最重要的一条选型原则。
下一节 自定义 tsPlugin ,我们将挂到语言服务内部,让它按我们的规则回答问题。
阅读导航:上一节:5.3 AST 与代码生成 · 下一节:6.2 自定义 tsPlugin 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。