深入探索 tsserver 的 Solution Searching 与 Project References 加载机制
问题的起点
在大型 monorepo 中使用 TypeScript 时,开发者经常遇到这样的困惑:打开一个简单的 .ts 文件,tsserver 却开始加载数十甚至上百个 ConfiguredProject,导致 IDE 响应缓慢甚至卡死。更令人困惑的是,同样的操作在不同的 package 中表现差异巨大——有的 package 秒开,有的则需要等待数分钟。
理解这一现象需要回答两个核心问题:
- tsserver 如何决定一个文件属于哪个
tsconfig.json? - 在什么情况下,tsserver 会"连锁"加载大量工程?
本文将沿着"打开一个文件后 tsserver 做了什么"这条主线,逐层拆解 TS Language Service 的配置发现与工程加载机制,并解释两个关键 compilerOptions——disableSolutionSearching 与 disableReferencedProjectLoad——如何控制这一过程。所有结论均附 TypeScript 5.9.3 源码引用。
TS 版本:5.9.3 Commit:
c63de15a992d37f0d6cec03ac7631872838602cb
第一步:就近发现
当你在编辑器中打开 /repo/packages/my-pkg/src/index.ts,tsserver 首先需要回答:这个文件附近有没有 tsconfig.json?
tsserver 的做法非常直接:从文件所在目录开始,逐级向上查找名为 tsconfig.json 或 jsconfig.json 的文件。找到第一个即停止。
这一逻辑的关键约束是:tsserver 只搜索这两个标准文件名。它不会扫描 tsconfig.web.json、tsconfig.build.json 或任何其他变体。这意味着,即使 tsconfig.web.json 就在同一目录下且其 include 完美覆盖当前文件,tsserver 的标准发现流程也不会"看到"它。
源码位置:src/server/editorServices.ts:L2579-L2631(forEachConfigFileLocation)
// 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.json 与 jsconfig.json,别无其他。
第二步:归属判定
找到 tsconfig.json 后,tsserver 会创建(或复用已有的)ConfiguredProject,然后判断这个 config 是否真的"覆盖"了当前文件。判定依据是 config 中的 include、files、exclude 规则,以及文件扩展名是否被支持。
如果判定为覆盖,流程到此结束——文件归属该 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.json 的 include 覆盖了当前文件,归属问题就解决了。
这条路径的关键在于:非标准命名的 tsconfig(如 tsconfig.web.json)只能通过被其他 config 的 references 指向才能被发现。标准发现流程永远不会直接找到它们。
源码位置:src/server/editorServices.ts:L4744-L4789(ensureProjectChildren)
// 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-L831(forEachAncestorProjectLoad)
// 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 语句是两个短路条件:
searchOnlyPotentialSolution && !composite:如果这趟搜索是专门为了找 solution,但当前 config 不是composite,则认为没必要继续向上disableSolutionSearching:直接禁止向上搜索
设置 disableSolutionSearching: true 后,这条"向上"的路径被完全切断。
两条路径的交互
现在我们可以理解为什么 monorepo 中会出现"连锁加载"现象:
- 你打开一个文件
- 就近发现的 config 不覆盖它
- tsserver 向上找到 root solution(路径 B)
- root solution 的
references指向数十个 package - tsserver 沿 references 向下加载这些 package(路径 A)
- 每个 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.jsondisableSolutionSearching:影响较小,因为包内 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 时:
- 就近发现命中
tsconfig.json,但它不覆盖src - 没有 references 可向下走
- 唯一的出路是向上查找 root solution
- 如果 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 getConfigFileNameForFile:src/server/editorServices.ts:L2704-L2712- FAR 扩展工程集合:
src/server/session.ts:L758-L798 - 单测:
disableSolutionSearching语义验证:src/testRunner/unittests/tsserver/projectReferences.ts:L895-L970
