这是一个非常简单的前端应用程序,创建一个段落元素并向其中写入欢迎文本。
// 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 和 references 属性都涉及 TypeScript 可见的文件,但它们的方式不同。
集成测试配置(tsconfig.test.json)完美地说明了这一点。