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

// 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 的视野到那里包含的文件,而不受集成测试配置的影响。