Logo
深入探索 tsserver 的 Solution Searching 与 Project References 加载机制

深入探索 tsserver 的 Solution Searching 与 Project References 加载机制

问题的起点

在大型 monorepo 中使用 TypeScript 时,开发者经常遇到这样的困惑:打开一个简单的 .ts 文件,tsserver 却开始加载数十甚至上百个 ConfiguredProject,导致 IDE 响应缓慢甚至卡死。更令人困惑的是,同样的操作在不同的 package 中表现差异巨大——有的 package 秒开,有的则需要等待数分钟。

理解这一现象需要回答两个核心问题:

  1. tsserver 如何决定一个文件属于哪个 tsconfig.json
  2. 在什么情况下,tsserver 会"连锁"加载大量工程?

本文将沿着"打开一个文件后 tsserver 做了什么"这条主线,逐层拆解 TS Language Service 的配置发现与工程加载机制,并解释两个关键 compilerOptions——disableSolutionSearchingdisableReferencedProjectLoad——如何控制这一过程。所有结论均附 TypeScript 5.9.3 源码引用。

TS 版本:5.9.3 Commitc63de15a992d37f0d6cec03ac7631872838602cb


第一步:就近发现

当你在编辑器中打开 /repo/packages/my-pkg/src/index.ts,tsserver 首先需要回答:这个文件附近有没有 tsconfig.json

tsserver 的做法非常直接:从文件所在目录开始,逐级向上查找名为 tsconfig.jsonjsconfig.json 的文件。找到第一个即停止。

这一逻辑的关键约束是:tsserver 只搜索这两个标准文件名。它不会扫描 tsconfig.web.jsontsconfig.build.json 或任何其他变体。这意味着,即使 tsconfig.web.json 就在同一目录下且其 include 完美覆盖当前文件,tsserver 的标准发现流程也不会"看到"它。

源码位置:src/server/editorServices.ts:L2579-L2631forEachConfigFileLocation

// src/server/editorServices.ts:L2579-L2631
if (searchTsconfig) {
  const tsconfigFileName = asNormalizedPath(combinePaths(searchPath, "tsconfig.json"));
  const result = action(...);
  if (result) return tsconfigFileName;
}
if (searchJsconfig) {
  const jsconfigFileName = asNormalizedPath(combinePaths(searchPath, "jsconfig.json"));
  const result = action(...);
  if (result) return jsconfigFileName;
}

这段代码清楚地表明:每一层目录仅尝试 tsconfig.jsonjsconfig.json,别无其他。


第二步:归属判定

找到 tsconfig.json 后,tsserver 会创建(或复用已有的)ConfiguredProject,然后判断这个 config 是否真的"覆盖"了当前文件。判定依据是 config 中的 includefilesexclude 规则,以及文件扩展名是否被支持。

如果判定为覆盖,流程到此结束——文件归属该 ConfiguredProject,tsserver 可以开始提供语言服务。

但如果判定为不覆盖,问题就来了:tsserver 需要找到一个"更合适"的 config。这时,流程进入下一阶段。


第三步:扩大上下文

当就近发现的 config 不覆盖当前文件时,tsserver 有两条路径来寻找正确归属:

路径 A:向下加载 Project References

如果当前 config 声明了 references,tsserver 可以沿着 references 向下查找子项目。例如,/repo/packages/my-pkg/tsconfig.json 声明了:

{
  "references": [{ "path": "./tsconfig.web.json" }]
}

此时 tsserver 会加载 tsconfig.web.json 作为子项目。如果 tsconfig.web.jsoninclude 覆盖了当前文件,归属问题就解决了。

这条路径的关键在于:非标准命名的 tsconfig(如 tsconfig.web.json)只能通过被其他 config 的 references 指向才能被发现。标准发现流程永远不会直接找到它们。

源码位置:src/server/editorServices.ts:L4744-L4789ensureProjectChildren

// src/server/editorServices.ts:L4766-L4767
private ensureProjectChildren(project, forProjects, seenProjects) {
  if (!tryAddToSet(seenProjects, project.canonicalConfigFilePath)) return;
  if (project.getCompilerOptions().disableReferencedProjectLoad) return;
  const children = project.getCurrentProgram()?.getResolvedProjectReferences();
  // 递归加载子项目
}

注意第二个 if:这就是 disableReferencedProjectLoad 的短路点。设为 true 时,tsserver 不会向下加载 references,这条路径被完全切断。

路径 B:向上查找 Solution

如果当前 config 没有声明 references(或 references 中的子项目也不覆盖当前文件),tsserver 还有另一条路:沿祖先目录向上查找,看是否存在一个"更大的 solution"能够提供正确的上下文。

这就是 Solution Searching。它的典型场景是:仓库根目录存在一个 solution-style 的 tsconfig.json,其 references 列出了所有 package:

{
  "files": [],
  "references": [{ "path": "./packages/pkg-a" }, { "path": "./packages/pkg-b/tsconfig.web.json" }]
}

当 tsserver 向上找到这个 root solution 后,会沿其 references 向下展开,从而可能找到覆盖当前文件的正确 config。

源码位置:src/server/editorServices.ts:L761-L831forEachAncestorProjectLoad

// src/server/editorServices.ts:L781-L792
while (true) {
  if (
    project.parsedCommandLine &&
    (
      (searchOnlyPotentialSolution && !project.parsedCommandLine.options.composite) ||
      project.parsedCommandLine.options.disableSolutionSearching
    )
  ) return;

  const configFileName = project.projectService.getConfigFileNameForFile(...);
  if (!configFileName) return;
  const ancestor = project.projectService.findCreateOrReloadConfiguredProject(configFileName, ...);
  if (!ancestor) return;
  project = ancestor.project;
}

这是一个 while (true) 循环,不断向上迭代祖先 config。循环开头的 if 语句是两个短路条件:

  1. searchOnlyPotentialSolution && !composite:如果这趟搜索是专门为了找 solution,但当前 config 不是 composite,则认为没必要继续向上
  2. disableSolutionSearching:直接禁止向上搜索

设置 disableSolutionSearching: true 后,这条"向上"的路径被完全切断。


两条路径的交互

现在我们可以理解为什么 monorepo 中会出现"连锁加载"现象:

  1. 你打开一个文件
  2. 就近发现的 config 不覆盖它
  3. tsserver 向上找到 root solution(路径 B)
  4. root solution 的 references 指向数十个 package
  5. tsserver 沿 references 向下加载这些 package(路径 A)
  6. 每个 package 可能又有自己的 references...

这就是"爆炸"的来源。

两个开关分别控制这两条路径:

  • disableSolutionSearching:切断路径 B(向上)
  • disableReferencedProjectLoad:切断路径 A(向下)

它们可以单独使用,也可以组合使用,但组合使用时需要谨慎——如果两条路径都被切断,而就近发现的 config 又不覆盖当前文件,那么文件将无法归属到任何 ConfiguredProject,只能落入 Inferred Project。


四种常见 tsconfig 形态

理解了上述机制后,我们可以分析 monorepo 中常见的四种 tsconfig 配置形态,以及它们与这两个开关的交互。

以下分析假设仓库根存在 root solution,路径约定:

  • /repo/tsconfig.json:root solution
  • /repo/packages/pkg-x/...:具体 package

形态一:直接 Include

/repo/packages/pkg-simple/
├── tsconfig.json    # include: ["src"]
└── src/
    └── index.ts

这是最简单的情况。tsconfig.json 直接覆盖 src,就近发现即完成归属。tsserver 无需向上查找 solution,也无需向下加载 references。

两个开关对这种形态基本没有影响。

形态二:Package-level Orchestrator

/repo/packages/pkg-solution/
├── tsconfig.json        # files: [], references: ["./tsconfig.web.json"]
├── tsconfig.web.json    # include: ["src"]
└── src/
    └── index.ts

tsconfig.json 是一个 orchestrator,不直接 include 任何文件,而是通过 references 指向 tsconfig.web.json

打开 src/index.ts 时,就近发现命中 tsconfig.json,但它不覆盖 src。tsserver 随后沿 references 向下加载 tsconfig.web.json,完成归属。

开关影响

  • disableReferencedProjectLoad: true:切断向下路径,src/index.ts 无法归属到 tsconfig.web.json
  • disableSolutionSearching:影响较小,因为包内 references 已足够完成归属

形态三:Split Config with Reference

/repo/packages/pkg-split/
├── tsconfig.json        # include: ["server"], references: ["./tsconfig.web.json"]
├── tsconfig.web.json    # include: ["src"]
├── server/
│   └── main.ts
└── src/
    └── index.ts

tsconfig.json 覆盖 server/,但也 references 了 tsconfig.web.json

打开 src/index.ts 时,就近发现命中 tsconfig.json,但它不覆盖 src。tsserver 沿 references 向下加载 tsconfig.web.json,完成归属。

这与形态二的机制相同,开关影响也相同。

形态四:Split Config without Reference

/repo/packages/pkg-implicit/
├── tsconfig.json        # include: ["server"], references: []
├── tsconfig.web.json    # include: ["src"]
├── server/
│   └── main.ts
└── src/
    └── index.ts

关键差异:tsconfig.json 没有 reference tsconfig.web.json

打开 src/index.ts 时:

  1. 就近发现命中 tsconfig.json,但它不覆盖 src
  2. 没有 references 可向下走
  3. 唯一的出路是向上查找 root solution
  4. 如果 root solution 的 references 中包含 pkg-implicit/tsconfig.web.json(或某个会再指向它的中间层),tsserver 可以通过这条路径找到正确归属

开关影响

  • disableSolutionSearching: true(设在 pkg-implicit/tsconfig.json):致命。向上路径被切断,而向下路径本就不存在,src/index.ts 将无法归属
  • disableReferencedProjectLoad: true(设在 root solution):同样致命,root solution 无法向下展开 references

这是最脆弱的形态,完全依赖 solution searching 才能正常工作。


Find All References:另一个触发点

除了打开文件,Find All References(FAR)是另一个容易触发大规模工程加载的操作。

FAR 的特点是需要在"可能相关的工程"中搜索所有引用。当搜索过程中发现跨工程线索时,tsserver 会调用 loadAncestorProjectTree 扩展已知工程集合,然后在新增工程中继续搜索。

源码位置:src/server/session.ts:L758-L798

// src/server/session.ts:L758-L798
if (defaultDefinition) {
  projectService.loadAncestorProjectTree(searchedProjectKeys);
  projectService.forEachEnabledProject((project) => {
    // 在新增 project 中继续搜索
  });
}

这解释了为什么执行一次 FAR 后,tsserver 可能突然开始加载大量 ConfiguredProject——它在尝试构建一个足够完整的工程图来提供准确的搜索结果。


配置策略

基于上述分析,可以得出以下配置策略:

形态一:无需配置开关。

形态二/三:如果 references 子树过大导致加载缓慢,可考虑 disableReferencedProjectLoad: true。代价是跨工程语义能力下降(FAR/Rename/Go to Definition 可能不完整)。

形态四:不要在近端 tsconfig 设置 disableSolutionSearching: true,否则会完全破坏归属机制。如果确实需要隔离某个 package 避免被 root solution 拖入,应重构为形态二或三(显式声明 references)。

通用原则:两个开关同时启用将导致向上、向下路径均被切断,只有就近发现的 config 能生效。这在形态一中没问题,但在其他形态中可能导致文件无法正确归属。


源码索引

  • 标准 config 发现src/server/editorServices.ts:L2579-L2631
  • Solution searching + disableSolutionSearching 短路src/server/editorServices.ts:L761-L831
  • References 加载 + disableReferencedProjectLoad 短路src/server/editorServices.ts:L4744-L4789
  • getConfigFileNameForFilesrc/server/editorServices.ts:L2704-L2712
  • FAR 扩展工程集合src/server/session.ts:L758-L798
  • 单测:disableSolutionSearching 语义验证src/testRunner/unittests/tsserver/projectReferences.ts:L895-L970