《TypeScript高级编程》6.1 TS Server 与 LSP

本节从编辑器里那次「跳转到定义」讲起,说明 IDE 的类型提示并非由 tsc 提供,而是由一个常驻的 TS Server 进程回答。你会弄清 TS Server 私有协议与 LSP 的分工、Project 的加载策略与生命周期,以及如何在 Node 中直接向它发请求做批量查询。读完本节,你能看懂编辑器日志里的协议报文,也为下一节编写 tsPlugin 打好基础。

本节目标:理解编辑器里的类型提示、跳转与重命名究竟由谁提供,分清 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编辑器显式打开的、由别的项目引用的文件由请求参数指定

生命周期由几个命令驱动:

  1. 编辑器启动 → 发 configure,告知日志级别、宿主信息。
  2. 打开文件 → 发 open,服务器为它找到(或创建)Project。
  3. 用户打字 → 发 change,只传增量区间(start、end、新文本),服务器在内存里更新源码并失效受影响的类型缓存。
  4. 关闭文件 → 发 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes