Read OSS

语言服务与 tsserver:驱动 IDE 体验的核心引擎

高级

前置知识

  • 第 1-5 篇:完整的编译器流水线理解
  • 熟悉语言服务器协议(LSP)的基本概念
  • 了解编辑器与语言服务器之间的通信方式

语言服务与 tsserver:驱动 IDE 体验的核心引擎

每次你看到红色波浪线、触发自动补全、跳转到定义,或在编辑器中应用快速修复,背后运行的正是本文要探讨的代码。TypeScript 语言服务与 tsserver 共同构成了编译器核心与编辑器体验之间的桥梁——它们将解析、绑定和类型检查流水线封装为面向 IDE 的 API,并通过 JSON 协议对外暴露。

这一层在编译器核心之上额外引入了约 40,000 行代码,分为两个独立层次:LanguageService(编程式 API)和 tsserver 进程(wire 协议服务器)。理解这套架构,有助于我们搞清楚 TypeScript 是如何在运行完整类型检查的同时,仍能保持近乎即时的编辑器响应速度的。

LanguageService 接口与 createLanguageService

LanguageService 接口定义了编辑器功能的完整 API 契约:

export interface LanguageService {
    getSyntacticDiagnostics(fileName: string): DiagnosticWithLocation[];
    getSemanticDiagnostics(fileName: string): Diagnostic[];
    getSuggestionDiagnostics(fileName: string): DiagnosticWithLocation[];
    getCompletionsAtPosition(fileName, position, options?): CompletionInfo;
    getCompletionEntryDetails(fileName, position, name, ...): CompletionEntryDetails;
    getDefinitionAtPosition(fileName, position): DefinitionInfo[];
    getTypeDefinitionAtPosition(fileName, position): DefinitionInfo[];
    findReferences(fileName, position): ReferencedSymbol[];
    getRenameInfo(fileName, position, preferences?): RenameInfo;
    getSignatureHelpItems(fileName, position, options?): SignatureHelpItems;
    getQuickInfoAtPosition(fileName, position): QuickInfo;
    getApplicableRefactors(fileName, positionOrRange, ...): ApplicableRefactorInfo[];
    getCodeFixesAtPosition(fileName, start, end, errorCodes, ...): CodeFixAction[];
    // ... 50+ 个方法
}

createLanguageService() 接收一个 LanguageServiceHost——这是对文件系统和项目配置的抽象封装——并创建一个与 host 文件版本保持同步的 Program。每次调用语言服务方法时,它都会先执行 synchronizeHostData(),确保内部的 Program 反映最新的文件变更。

classDiagram
    class LanguageServiceHost {
        +getScriptFileNames(): string[]
        +getScriptVersion(fileName): string
        +getScriptSnapshot(fileName): IScriptSnapshot
        +getCompilationSettings(): CompilerOptions
        +getCurrentDirectory(): string
    }
    class LanguageService {
        +getCompletionsAtPosition()
        +getSemanticDiagnostics()
        +getDefinitionAtPosition()
        +findReferences()
        +getCodeFixesAtPosition()
    }
    class Program {
        +getSourceFiles()
        +getTypeChecker()
        +emit()
    }
    LanguageServiceHost <-- LanguageService : reads from
    LanguageService --> Program : creates/manages
    Program --> TypeChecker : creates on demand

LanguageServiceHost 的设计是 TypeScript 支持多种嵌入方式的关键所在。VS Code 的 TypeScript 扩展、tsserver 进程、webpack 的 ts-loader,以及各类编程式消费者,都以不同方式实现了 LanguageServiceHost,却能共享同一套语言服务能力。

提示: 如果你正在构建需要 TypeScript 类型信息的工具(如 linter、代码生成器、文档工具),可以实现 LanguageServiceHost 并调用 createLanguageService()。这样就能获得类型检查器的全部能力,而无需处理 tsserver 的通信协议。

补全、诊断与导航

补全引擎

src/services/completions.ts 中的自动补全引擎共 6,173 行,是服务层中体量最大的单一功能实现。给定一个光标位置,它需要判断以下几件事:

  1. 当前属于哪种补全上下文? ——成员访问(foo.)、字符串字面量(import 路径)、JSX 属性、类型位置、语句位置等
  2. 当前作用域内有哪些符号? ——标识符补全使用 checker 的 getSymbolsInScope(),成员访问使用 getPropertiesOfType()
  3. 如何对结果排序和过滤 ——模糊匹配、基于类型的相关性、最近使用频率、标签详情
sequenceDiagram
    participant Editor
    participant LS as LanguageService
    participant Comp as completions.ts
    participant TC as TypeChecker

    Editor->>LS: getCompletionsAtPosition(file, pos)
    LS->>Comp: getCompletions(sourceFile, position, ...)
    Comp->>Comp: Determine completion context
    alt Member access (foo.|)
        Comp->>TC: getTypeAtLocation(foo)
        Comp->>TC: getPropertiesOfType(type)
    else Identifier position
        Comp->>TC: getSymbolsInScope(location)
    else Import path
        Comp->>Comp: Search file system / package.json
    end
    Comp->>Comp: Filter, rank, format entries
    Comp-->>Editor: CompletionInfo { entries: [...] }

通过声明合并扩展节点

代码库中最具创意的模式之一,是服务层为 AST 节点添加便捷方法的方式。在 src/services/types.ts 中可以看到:

declare module "../compiler/types.js" {
    export interface Node {
        getSourceFile(): SourceFile;
        getChildCount(sourceFile?: SourceFile): number;
        getChildAt(index: number, sourceFile?: SourceFile): Node;
        getChildren(sourceFile?: SourceFile): readonly Node[];
        getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number;
        getFullStart(): number;
        getEnd(): number;
        getWidth(sourceFile?: SourceFileLike): number;
        getFullWidth(): number;
        getText(sourceFile?: SourceFile): string;
        getFirstToken(sourceFile?: SourceFile): Node | undefined;
        getLastToken(sourceFile?: SourceFile): Node | undefined;
        forEachChild<T>(cbNode: (node: Node) => T | undefined, ...): T | undefined;
    }
}

这里利用了 TypeScript 自身的声明合并特性,为编译器的 Node 接口追加了方法。这些方法的实现在运行时由服务层挂载到 Node.prototype 上。编译器核心本身并不依赖这些方法——它们的存在,纯粹是为了方便服务层代码以及公共 API 的外部使用者。

诊断的分层处理

诊断信息在服务层分三个层次流转:

  1. 语法诊断 ——速度快,来自 parser,无需类型检查
  2. 语义诊断 ——来自 checker,需要完整的类型检查
  3. 建议诊断 ——非错误性的改进提示

服务层对诊断信息按文件缓存,并在文件版本变更时使缓存失效。在编辑器场景下,语法诊断会优先返回(即时反馈),语义诊断随后到达(若 checker 需要执行额外工作,则可能有延迟)。

代码修复与重构

服务层内置了两个可扩展的自动代码操作注册表。

代码修复(70+ 个实现)

src/services/codefixes/ 目录下包含 70 余个独立的代码修复提供者,每个针对特定的错误码:

代码修复 触发条件
importFixes.ts "Cannot find name 'X'"
fixSpelling.ts "Property 'X' does not exist. Did you mean 'Y'?"
fixClassIncorrectlyImplementsInterface.ts "Class incorrectly implements interface"
fixMissingAsync.ts 在非 async 函数中使用了 await
addMissingAwait.ts 在期望值的位置使用了 Promise
fixStrictClassInitialization.ts 属性未在构造函数中初始化
convertToTypeOnlyImport.ts import 仅用作类型

每个提供者通过 registerCodeFix() 调用注册自身,并声明它所处理的错误码。当编辑器为某条诊断请求代码修复时,注册表将请求分发给匹配的提供者,提供者分析错误上下文并生成描述修复内容的 TextChange 对象。

重构(16 个提供者)

src/services/refactors/ 目录包含以下重构提供者:

  • extractSymbol.ts ——提取函数 / 提取常量
  • moveToFile.ts / moveToNewFile.ts ——在文件间移动声明
  • convertParamsToDestructuredObject.ts ——将位置参数转换为具名对象
  • convertImport.ts ——在具名导入与命名空间导入之间切换
  • inlineVariable.ts ——在所有引用处内联变量
  • convertToOptionalChainExpression.ts ——将 && 链式调用转换为 ?.
flowchart TD
    Editor["Editor: 'Refactor...'"] --> LS["getApplicableRefactors(file, range)"]
    LS --> Registry["Refactoring Registry"]
    Registry --> R1["extractSymbol"]
    Registry --> R2["moveToFile"]
    Registry --> R3["convertImport"]
    Registry --> RN["...16 total"]
    R1 --> Check["Is refactoring applicable<br/>at this position?"]
    Check -->|Yes| Available["Return refactoring info"]
    Check -->|No| Skip["Skip"]
    Available --> Editor2["User selects refactoring"]
    Editor2 --> Apply["getEditsForRefactor()"]
    Apply --> Edits["TextChange[] across files"]

tsserver:协议、Session 与项目管理

编辑器实际通信的对象是 tsserver 进程。它是一个 Node.js 进程,从 stdin 读取 JSON 请求,将 JSON 响应写入 stdout。

Wire 协议

src/server/protocol.ts 定义了 CommandTypes 枚举——服务器能够处理的所有请求类型:

export const enum CommandTypes {
    CompletionInfo = "completionInfo",
    CompletionDetails = "completionEntryDetails",
    Definition = "definition",
    DefinitionAndBoundSpan = "definitionAndBoundSpan",
    Implementation = "implementation",
    References = "references",
    Rename = "rename",
    Geterr = "geterr",
    Format = "format",
    Configure = "configure",
    Open = "open",
    Close = "close",
    Change = "change",
    NavBar = "navbar",
    // ... 50+ 个命令
}

这套协议并非 LSP(语言服务器协议)——它是 TypeScript 自己的协议,诞生时间早于 LSP。VS Code 的 TypeScript 扩展负责在 LSP 与这套协议之间进行转换。部分编辑器(如旧版 Visual Studio)则直接使用此协议。

Session 类

src/server/session.ts 中的 Session 类负责分发传入请求。它维护着一张从命令名到处理函数的映射表,每个处理函数负责:

  1. 从请求中提取参数
  2. 将行/列位置转换为内部偏移量
  3. 调用相应的 LanguageService 方法
  4. 将内部结果转换回协议响应格式

ProjectService

src/server/editorServices.ts 中的 ProjectService 是整个工作区的管理者。单个 tsserver 进程可以同时管理多个项目:

flowchart TD
    PS["ProjectService"] --> CP["ConfiguredProject<br/>(tsconfig.json based)"]
    PS --> IP["InferredProject<br/>(loose files)"]
    PS --> EP["ExternalProject<br/>(build tool managed)"]
    CP --> LS1["LanguageService"]
    IP --> LS2["LanguageService"]
    EP --> LS3["LanguageService"]
    LS1 --> P1["Program"]
    LS2 --> P2["Program"]
    LS3 --> P3["Program"]
  • ConfiguredProject:基于 tsconfig.json 文件创建。当你打开一个 .ts 文件时,服务器会向上查找 tsconfig.json 并为其创建对应的项目。
  • InferredProject:为不属于任何 tsconfig.json 的文件创建。服务器以默认配置创建项目,并将当前打开的所有散落文件纳入其中。
  • ExternalProject:由外部构建工具创建,构建工具会明确告知服务器需要使用哪些文件和选项。

每个项目拥有独立的 LanguageServiceProgramProjectService 负责整个生命周期的管理——在文件打开时创建项目、在文件变更时更新项目、在项目不再需要时将其销毁。

服务器入口点

src/tsserver/server.ts 入口点负责初始化 Node.js 系统、创建 logger、配置取消机制,然后调用 start()。有一个值得关注的细节:它覆盖了 console.logconsole.warnconsole.error,将所有输出重定向到 logger。这样做的目的是防止语言服务插件(它们可能使用 console.log)意外污染 stdout 上的 JSON 协议流。

console.log = (...args) =>
    logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Info);
console.warn = (...args) =>
    logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Err);
console.error = (...args) =>
    logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Err);

提示: 调试 tsserver 时,可以将环境变量 TSS_LOG 设置为某个文件路径。服务器会将每条请求、响应和内部事件的详细信息写入该文件——在排查编辑器集成问题时非常有价值。

全局视图

通过六篇文章对 TypeScript 编译器的完整梳理,让我们看看所有组件是如何协同运作的:

flowchart TD
    subgraph "User Interface"
        Editor["VS Code / Editor"]
    end
    subgraph "Server Layer (src/server)"
        tsserver["tsserver process"]
        Session["Session"]
        ProjectService["ProjectService"]
    end
    subgraph "Service Layer (src/services)"
        LS["LanguageService"]
        Completions["Completions"]
        Diagnostics["Diagnostics"]
        CodeFixes["CodeFixes (70+)"]
        Refactors["Refactors (16)"]
    end
    subgraph "Compiler Core (src/compiler)"
        Program["Program"]
        Scanner["Scanner"]
        Parser["Parser"]
        Binder["Binder"]
        Checker["Checker (54K lines)"]
        Emitter["Emitter + Transformers"]
    end
    Editor <-->|"JSON protocol"| tsserver
    tsserver --> Session
    Session --> ProjectService
    ProjectService --> LS
    LS --> Completions
    LS --> Diagnostics
    LS --> CodeFixes
    LS --> Refactors
    LS --> Program
    Program --> Scanner
    Program --> Parser
    Program --> Binder
    Program --> Checker
    Program --> Emitter

这套架构——精简的入口点、五阶段编译流水线、三种核心数据结构(Node、Symbol、Type),以及从编译器核心到语言服务再到服务器的清晰分层——已经支撑 TypeScript 运行了十余年。当团队用 Go 重写编译器以实现 10 倍性能提升时,基本架构思路仍将延续。深入理解这份 JavaScript 实现,能为你阅读新版实现打下坚实的基础。

TypeScript 编译器是 JavaScript 生态系统中最令人叹为观止的开源项目之一。无论你是想为 TypeScript 本身做贡献、构建消费其 API 的工具,还是单纯好奇这门语言底层的工作原理,希望本系列文章能为你绘制一张清晰的地图,让你在这片代码疆域中游刃有余。