关于你所不知道的 TypeScript

2024/08/29 typescript

译文:One Thing Nobody Explained To You About TypeScript (opens new window)

我已经使用 TypeScript 超过四年了,总的来说,这是一次非常棒的体验。随着时间的推移,使用它的摩擦逐渐减少,直到完全消失,这使得我在编写类型或以类型优先的方式解决问题时变得更加高效。虽然我还远未成为真正的类型大师,但我敢说自己已经熟练掌握了这门语言,经历了类型体操、条件类型、嵌套泛型,并深刻理解了 typeinterface 之间的神圣差异。说实话,我认为我对这门语言已经有了相当不错的理解。

直到我发现我错了。其实,有一个关于 TypeScript 的特定问题我完全理解错了,我相信你也可能犯过同样的错误。而这并不是某个你从未听说过、可能永远不会用到的复杂边缘案例。恰恰相反,这是你和其他所有 TypeScript 开发者直接交互过数百次的东西,一直在我们眼前游弋。

我说的就是 tsconfig.json

而且,这并不是说它有多复杂(我承认我不能不假思索地解释 targetmodule)。相反,这其实是件相当简单的事情。它是关于 tsconfig.json 实际做了什么。

「哦,那是一个配置文件,用来配置 TypeScript,当然。」 没错!确实如此,但不是以你所期望的方式。让我们一起往下看。

# 库、测试与真相

每一次伟大发现背后都有一个很好的例子。我会尽力让这两者兼具。

让我们写一个简单的前端应用。我指的是,完全不使用框架,没有任何依赖。这很容易:

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

document.body.appendChild(greetingText)

创建一个段落元素并向 John 问好。简单,到目前为止一切顺利。

但是 document 从哪里来的?你可以说它是 JavaScript 中的全局变量。没错,不过我们实际上并不在 JavaScript 中。实际上,我们正在我们的 IDE 中查看一些 TypeScript 代码。它需要被编译成 JavaScript,才能进入浏览器,并且浏览器需要将 document 全局变量暴露给它。那么 TypeScript 如何知道 document 的存在及其方法呢?

TypeScript 通过加载一个名为 lib.dom 的默认定义库来实现这一点。可以把它看作一个 .d.ts 文件,里面包含了一堆类型来描述 JavaScript 全局变量,因为这正是它的功能。你可以通过按住 CMD(在 Windows 上是 CTRL)并点击 document 对象来验证这一点。谜团解决了。

既然我们的应用程序是自切面包以来最棒的,那就让我们为它添加一些自动化测试吧。为此,我们将背叛我们对简单性的概念,安装一个叫做 Vitest 的测试框架。接下来,我们编写测试本身:

// 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?

虽然承认这一点让我很痛苦,但编译器是有道理的。它从哪里来?它并不是像 document 那样的全局变量,它必须来自某个地方。实际上,测试框架通常会扩展全局对象并暴露像 itexpect 这样的全局函数,以便你可以在每个测试中访问它们,而不必显式引入它们。

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

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

通过使用 compilerOptions.types,我们要求 TypeScript 加载额外的类型,这里是来自 vitest/globals 的类型,声明了全局的 it 函数。编译器对我们的努力微笑,允许测试通过,这让我们对自己和整个严格类型语言的过程感到特别满意。

但是,别急。

# 问题

我们稍微偏离一下,但我保证最后一切都会有意义。

让我问你个问题:如果你在 TypeScript 中引用一个不存在的代码,会发生什么?没错,出现波浪状的红线和 Cannot find name 类型错误,这就是结果。刚刚我们在测试中尝试调用 it() 时已经见过了。

回到 app.ts 模块,添加一个对不存在的全局变量 test 的引用:

// src/app.ts
// ...应用程序代码。

test

我们没有定义 test。它不是浏览器的全局变量,显然也不存在于 TypeScript 的任何默认库中。这是一个错误,一个 bug,它应该显示为红色。

只是,事实并非如此。因为波浪形的红线并没有出现在代码下方,你会充满不安、困惑。更糟糕的是,TypeScript 不仅没有在这里产生错误,反而试图提供帮助,建议我们输入 test,展示它的调用签名,并声称它来自某个 TestApi 命名空间。但那是 Vitest 的类型,这怎么可能...

这个代码会编译吗?当然。它会在浏览器中工作吗?不,它会像一个投球手在最辉煌的那天一样,老练的抛出错误。为什么会这样?难道使用 TypeScript 的全部目的是为了防止这样的错误?

这里的 test 是我所称之为的幽灵定义。它是一个有效的类型定义,描述了一些根本不存在的东西。你可能会说又是 TypeScript 的把戏。别急着责怪这工具,我来告诉你,情况是这样的。

# 不止一个配置

app.test.ts 测试模块从 src 目录移动到新创建的 test 目录中。打开它。等等,这里又是 it 的类型错误吗?我们不是已经通过将 vitest/globals 添加到 tsconfig.json 解决这个问题了吗?

问题在于,TypeScript 不知道如何处理 test 目录。实际上,TypeScript 甚至不知道它的存在,因为我们在 tsconfig.json 中指向的只有 src

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

正如我之前提到的,TypeScript 配置的工作方式并不是完全显而易见的。很长一段时间我都认为 include 选项代表要包含在编译中的模块,而 exclude 则控制要排除哪些模块。如果我们查阅 TypeScript 文档,我们会读到:

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

我对 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 吗?

嗯,实际上,是的,你应该这样做。只是,不是为每个目录,而是为你的代码要运行的每个环境。

# 运行时和关注点

现代 Web 应用程序的幕后是一个精致的模块沙拉。你的应用程序的直接来源需要被编译、压缩、代码分割、打包,并交付给你的用户。然后还有测试文件,它们也是 TypeScript 模块,永远不需要被编译或交付给任何人。可能还有 Storybook 故事、Playwright 测试,也许还有一两个自定义 *.ts 脚本来自动化某些事情 —— 所有这些都是有用的,具有不同的意图,并且需要在不同的环境中运行。

但是,我们编写模块的目的很重要。这对 TypeScript 也很重要。你认为它为什么默认给你 Document 类型?因为它知道你很可能在开发一个 Web 应用程序。要开发一个 Node.js 服务器?请明确说明这个意图,并安装 @types/node。编译器无法为你猜测,你需要告诉它你想要什么。

而你通过 tsconfig.json 来传达这个意图。但不仅仅是根级的配置。TypeScript 可以非常好地处理嵌套配置,因为它就是为了处理这些而设计的。你需要做的就是明确你的意图。

# The root-level configuration to apply TypeScript
# across the entire project. This mostly contains only references.
- tsconfig.json

# The base configuration that all the other configurations
# extend upon. Describe the shared options here.
- tsconfig.base.json

# The source files configuration.
- tsconfig.src.json

# The build configuration.
- tsconfig.build.json

# Configuration for integration tests.
- tsconfig.test.json

# Configuration for end-to-end tests.
- tsconfig.e2e.test.json

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

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

这是根级 tsconfig.json 的样子:

// tsconfig.json
{
    "references": [
        // Source files (e.g. everything under "./src").
        { "path": "./tsconfig.src.json" },
        // Integration tests (e.g. everything under "./tests").
        { "path": "./tsconfig.test.json" },
        // E2E tests (e.g. everything under "./e2e").
        { "path": "./tsconfig.e2e.test.json" }
    ]
}

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

// tsconfig.src.json
{
    // Inherit the reused options.
    "extends": "./tsconfig.json",
    // Apply this configuration only to the files
    // under the "./src" directory.
    "include": ["./src"],
    "compilerOptions": {
        "composite": true,
        "target": "es2015",
        "module": "esnext",
        // Support JSX for React applications.
        "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",
        // Include test-specific types.
        "types": ["@types/node", "vitest/globals"]
    }
}

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

include vs referencesincludereferences 属性都涉及 TypeScript 可见的文件,但它们的方式不同。让我们回顾一下这个区别。

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

集成测试配置(tsconfig.test.json)完美地说明了这个区别。你希望该配置仅应用于 ./tests 目录下的测试文件,因此你在 include 中提供它。但你还希望能够在这些文件中导入被测试的源代码,这意味着 TypeScript 必须知道那段代码。你在 references 中引用源文件的配置(tsconfig.src.json),这使 TypeScript 的视线扩展到那里包含的文件,而不受集成测试配置的影响。

# 实践方面

无论好坏,我们正朝着开发工具被抽象化的时代迈进。可以合理地期望你选择的框架为你处理这些配置的丛林。事实上,一些框架已经在这样做了。以 Vite 为例。我相当有信心,你可以在几乎任何其他项目中找到一个 TypeScript 的多配置设置。

但我希望你明白,无论是否抽象化,TypeScript 仍然是你的工具,学习更多关于它的知识、理解它并正确使用它都是有益的。

上次更新: 2024/9/20 10:04:26