skip to content
logo

Search

tsconfig 分治策略

13 min read

如何通过分治策略来管理 Monorepo 项目中的多个 tsconfig 文件,以实现更好的类型安全和项目结构?

这是一个非常简单的前端应用程序,创建一个段落元素并向其中写入欢迎文本。

 // src/app.ts
const greetingText = document.createElement("p")
greetingText.innerText = "Hello, John!"
document.body.appendChild(greetingText)

但是,有个问题, document 从哪里来的?你可以说它是 JavaScript 中的全局变量,你说得对。

但这是 TypeScript 代码,它必须被编译成 JS,才能让浏览器向它公开 document 全局变量。

那么 TypeScript 是如何知道 document 的存在及其方法呢?

TypeScript 通过加载一个默认的定义库叫做 lib.dom 来做到这一点。(就是一个包含一堆类型的.d.ts文件,用来描述 JavaScript 全局变量。)

接下来,安装 Vites,编写一段测试代码:

 // src/app.test.ts
 it("greets John", async () => {
   await import("./app")
   const greetingText = document.querySelector("p")
   expect(greetingText).toHaveText("Hello, John!")
 })

一旦我们尝试运行这个测试,TypeScript 就会报错:

Cannot find name 'it'. Do you need to install type definitions for a test runner?

编译器的报错是正常的。it 从哪里来呢?它不是像 document 那样的全局变量,它必须来自某个地方。实际上,测试框架很常见地会扩展全局对象,并全局暴露 it 和 expect 这样的函数,这样你就可以在每个测试中访问它们而不需要显式导入。

我们按照测试框架的文档,通过修改tsconfig.json启用全局 it:

// tsconfig.json
 {
   "compilerOptions": {
     "types": ["vitest/globals"]
   },
   "include": ["src"]
 }

通过使用 compilerOptions.types,我们要求 TypeScript 从vitest/globals 加载额外的类型,声明全局的 it 函数。然后编译器才会让测试通过。

**但是,这事儿没有这么快。**如果你在 TypeScript 中引用了一个不存在的代码会发生什么?

是的,一条波浪形的红线和 Cannot find name 类型错误,这就是会发生的事情。

// src/app.ts
const greetingText = document.createElement("p")
greetingText.innerText = "Hello, John!"
document.body.appendChild(greetingText)
// 添加一个对不存在的全局变量 test 的引用
test

我们没有定义 test。它不是一个浏览器全局变量,当然也不存在于任何 TypeScript 默认库中。这是一个错误,一个 bug,它必须显示为红色。 只是,事实并非如此。因为波浪形的红线并没有出现在代码下方,这可能会让你感到困惑。

更糟糕的是,不仅 TypeScript 在这里没有产生错误,实际上它还试图帮忙,建议我们定义 test,显示它的调用签名,说它来自某个 TestApi 命名空间。但那是 Vitest 的类型,这怎么可能……

这个代码会编译吗?当然。 它会在浏览器中工作吗?不会。

这里的 test 我称为 幽灵定义_。它是一个有效的类型定义,描述了一些根本不存在的东西。你可能会说:又一个 TypeScript 的诡计。不过,不要急着责怪工具。

一个配置统治一切

将 app.test.ts 测试模块从 src 目录移动到新创建的 test 目录中。打开它。

等等,it 上又有类型错误吗?我们不是已经通过在 tsconfig.json 中添加 vitest/globals 解决了吗? 问题在于,TypeScript 不知道如何处理 test 目录。实际上,TypeScript 甚至不知道它的存在,因为我们在 tsconfig.json 中指向的只有 src:

 // tsconfig.json
 {
   "compilerOptions": {
     "types": ["vitest/globals"]
   },
   "include": ["src"]
 }

如果我们查阅 TypeScript 文档,我们会读到:

include 指定一个文件名或模式数组,以包含在程序中。

很长一段时间,我都认为 include 选项是用于指定包含在编译中的模块,而 exclude 则分别控制哪些模块被排除。

但后来我对 include 的理解稍有不同,比文档中所述更具体。

include 选项控制应用此 TypeScript 配置影响到哪些模块。

你没看错。如果一个 TypeScript 模块位于 include 选项列出的目录之外,那么 tsconfig.json 对该模块将完全无效。相应地,exclude 选项允许过滤出不受当前配置影响的文件模式。

所以我们应该将 test 添加到 include 中,然后继续我们的工作?

 // tsconfig.json
 {
   "compilerOptions": {
     "types": ["vitest/globals"]
   },
   "include": ["src", "test"]
 }

这是大多数开发者完全错误的地方

通过在 include 中添加新目录,你正在扩展此配置以影响所有这些目录。虽然此更改修复了 test 中的测试框架类型,但它会将这些类型泄漏到所有 src 模块中!

你刚刚让你的整个源代码成为一个闹鬼的豪宅,释放了数百个幽灵类型。不存在的东西将被类型化,已被类型化的东西可能与其他定义冲突,使用 TypeScript 的整体体验将显著下降,特别是随着应用程序的增长。

那么,解决方案是什么呢?我们应该为每个目录创建一堆 tsconfig.json 吗?

实际上,你确实应该创建多个 tsconfig,不过不是为目录创建,而是为你的代码运行的每种环境。

运行时和关注点

现代 Web 应用程序的幕后是一个精美的模块沙拉。

你的应用程序的即时源码需要被编译、压缩、代码分割、打包并传送给用户。

然后是测试文件,这些也是 TypeScript 模块,永远不需要编译或传送给任何人。

可能还有 Storybook 故事、Playwright 测试,也许还有一两个自定义的*.ts脚本来自动化任务… 所有这些都很有用,都有不同的意图,并且在 不同的环境 中运行。

我们编写不同模块的目的很重要。这对 TypeScript 也很重要。

为什么你认为它默认给你提供 Document 类型?因为它知道你可能在开发一个 Web 应用程序。如果在开发 Node.js 服务器呢?请明确说明这个意图并安装@types/node。编译器不能为你猜测,你需要告诉它你想要什么。 你通过 tsconfig.json 传达这个意图。不仅仅是根级别的配置。TypeScript 也可以很好地处理嵌套的配置。因为它被设计成那样。你所需要做的就是明确你的意图。

# 应用 TypeScript 的根级配置
# 横跨整个项目这主要只包含 references 引用
- tsconfig.json
 
# 所有其他配置的基本配置说明
# 扩展在这里描述共享选项
- tsconfig.base.json
 
# 源文件配置
- tsconfig.src.json
 
# 构建配置
- tsconfig.build.json
 
# 集成测试的配置
- tsconfig.test.json
 
# 端到端测试的配置
- tsconfig.e2e.test.json

哇,有很多配置!那么,也有很多意图:从源文件到各个测试级别再到生产构建。所有这些都是为了类型安全。你通过使用 TypeScript 配置的 references 属性来使它们类型安全!

魔法从根级别的 tsconfig.json 开始。请放心,这是 TypeScript 唯一会拾取的配置。所有其他配置都成为根级配置的引用,仅适用于匹配其 include 的文件。

这是根级 tsconfig.json 的样子:

 // tsconfig.json
 {
   "references": [
     // 源文件 (e.g. "./src").
     { "path": "./tsconfig.src.json" },
     // 集成测试 (e.g.  "./tests").
     { "path": "./tsconfig.test.json" },
     // E2E 测试 (e.g. "./e2e").
     { "path": "./tsconfig.e2e.test.json" }
   ]
 }

既然你使用了 references 字段,所有引用的配置都必须将 compilerOptions.composite 设置为 true。以下是用于源文件的 tsconfig.src.json 示例:

 // tsconfig.src.json
 {
   // 继承重用的选项
   "extends": "./tsconfig.json",
   // 将此配置仅应用于 src 目录下的文件
   "include": ["./src"],
   "compilerOptions": {
     "composite": true,
     "target": "es2015",
     "module": "esnext",
     // 为React应用程序支持JSX
     "jsx": "react"
   }
 }

你为源文件和构建使用单独的配置,因为具有 compilerOptions.composite 的配置不能直接运行。你将 tsc 指向特定的 -p tsconfig.build.json 进行构建。 对于交叉的配置会有点复杂,比如集成测试的配置,它应该只适用于 ./tests下的文件,同时仍然允许你导入被测试的源代码。对此,需要再次利用 references 属性!

 // tsconfig.test.json
 {
   "extends": "./tsconfig.json",
   "include": ["./tests"],
   "references": [{ "path": "./tsconfig.src.json" }],
   "compilerOptions": {
     "composite": true,
     "target": "esnext",
     "module": "esnext",
     // 包括特定于测试的类型。
     "types": ["@types/node", "vitest/globals"]
   }
 }

references 属性告诉 TypeScript 在类型检查中,依赖给定的配置,但是这个依赖关系不会改变当前配置影响的模块范围。

include vs references

include 和 references 属性都涉及 TypeScript 可见的文件,但它们的方式不同。

  • include 控制哪些文件受当前配置影响
  • references 控制哪些文件是当前配置的依赖,但当前配置影响的模块范围不受其影响。

集成测试配置(tsconfig.test.json)完美地说明了这一点。

  • 你希望该配置仅适用于./tests 目录下的测试文件,所以你在 include 中提供了它。
  • 但你也希望能够在这些文件中导入被测试的源代码,这意味着 TypeScript 必须知道这些代码。你在 references 中引用源文件的配置(tsconfig.src.json),这从传递上扩展了 TypeScript 的视野到那里包含的文件,而不受集成测试配置的影响。

标准化配置

从 TypeScript v5.5 开始,TypeScript 引入了一个强大的新特性:在 tsconfig 中使用 ${configDir} 变量。这个特性让配置继承变得更加灵活和标准化。让我们看看如何利用它:

  1. 首先,创建一个基础的 tsconfig.base.json 作为模板:
{
  "compilerOptions": {
    "baseUrl": "${configDir}",
    "outDir": "${configDir}/dist",
    "paths": {
      "@/*": ["${configDir}/src/*"]
    }
  }
}
  1. 然后,让每个子包的 tsconfig.json 通过 extends 继承这个配置。

这种方式的优势在于:

  • 路径自动调整:使用 ${configDir} 变量,每个继承配置的子包都能根据自己的目录结构自动调整相对路径,不需要手动修改。
  • 配置一致性:所有子包共享相同的基础配置,确保了项目配置的一致性。
  • 灵活性:子包可以根据需要覆盖或扩展基础配置,同时保持核心设置的统一。
  • 维护性:当需要更新共享配置时,只需修改 tsconfig.base.json,所有继承的配置都会自动更新。

这个特性特别适合 Monorepo 项目,它让多包项目的 TypeScript 配置管理变得更加优雅和可维护。