模块 - 选择编译器选项

我正在编写一个应用程序

单个 tsconfig.json 只能代表单个环境,无论是可用的全局变量还是模块的行为。如果您的应用程序包含服务器代码、DOM 代码、Web Worker 代码、测试代码以及所有这些代码共享的代码,那么这些代码中的每一个都应该有自己的 tsconfig.json,并通过 项目引用 连接起来。然后,针对每个 tsconfig.json 使用本指南一次。对于应用程序中的类似库的项目,尤其是需要在多个运行时环境中运行的项目,请使用“我正在编写一个库”部分。

我正在使用打包器

除了采用以下设置之外,还建议不要在打包器项目中设置 { "type": "module" } 或使用 .mts 文件。 一些打包器 在这些情况下采用不同的 ESM/CJS 互操作行为,TypeScript 目前无法使用 "moduleResolution": "bundler" 分析这些行为。有关更多信息,请参阅 问题 #54102

json
{
"compilerOptions": {
// This is not a complete template; it only
// shows relevant module-related settings.
// Be sure to set other important options
// like `target`, `lib`, and `strict`.
// Required
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
// Consult your bundler’s documentation
"customConditions": ["module"],
// Recommended
"noEmit": true, // or `emitDeclarationOnly`
"allowImportingTsExtensions": true,
"allowArbitraryExtensions": true,
"verbatimModuleSyntax": true, // or `isolatedModules`
}
}

我正在使用 Node.js 编译和运行输出。

请记住,如果您打算发出 ES 模块,请设置 "type": "module" 或使用 .mts 文件。

json
{
"compilerOptions": {
// This is not a complete template; it only
// shows relevant module-related settings.
// Be sure to set other important options
// like `target`, `lib`, and `strict`.
// Required
"module": "nodenext",
// Implied by `"module": "nodenext"`:
// "moduleResolution": "nodenext",
// "esModuleInterop": true,
// "target": "esnext",
// Recommended
"verbatimModuleSyntax": true,
}
}

我正在使用 ts-node。

ts-node 尝试与相同的代码和相同的 tsconfig.json 设置兼容,这些设置可用于 在 Node.js 中编译和运行 JS 输出。有关更多详细信息,请参阅 ts-node 文档

我正在使用 tsx。

虽然 ts-node 默认情况下对 Node.js 的模块系统进行最小的修改,但 tsx 的行为更像一个捆绑器,允许使用无扩展名/索引模块说明符以及任意混合 ESM 和 CJS。对 tsx 使用与您 对捆绑器使用相同的设置。

我正在为浏览器编写 ES 模块,没有捆绑器或模块编译器。

TypeScript 目前没有专门针对这种情况的选项,但您可以通过结合使用 nodenext ESM 模块解析算法和 paths 来近似它们,以替代 URL 和导入映射支持。

json
// tsconfig.json
{
"compilerOptions": {
// This is not a complete template; it only
// shows relevant module-related settings.
// Be sure to set other important options
// like `target`, `lib`, and `strict`.
// Combined with `"type": "module"` in a local package.json,
// this enforces including file extensions on relative path imports.
"module": "nodenext",
"paths": {
// Point TS to local types for remote URLs:
"https://esm.sh/[email protected]": ["./node_modules/@types/lodash/index.d.ts"],
// Optional: point bare specifier imports to an empty file
// to prohibit importing from node_modules specifiers not listed here:
"*": ["./empty-file.ts"]
}
}
}

此设置允许显式列出的 HTTPS 导入使用本地安装的类型声明文件,同时对通常在 node_modules 中解析的导入报错。

ts
import {} from "lodash";
// ^^^^^^^^
// File '/project/empty-file.ts' is not a module. ts(2306)

或者,您可以使用 导入映射 在浏览器中显式地将一组裸规范映射到 URL,同时依赖于 nodenext 的默认 node_modules 查找,或依赖于 paths 来指导 TypeScript 为这些裸规范导入找到类型声明文件。

html
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/[email protected]"
}
}
</script>
ts
import {} from "lodash";
// Browser: https://esm.sh/[email protected]
// TypeScript: ./node_modules/@types/lodash/index.d.ts

我正在编写一个库

作为库作者选择编译设置与作为应用程序作者选择设置是一个根本不同的过程。在编写应用程序时,选择的设置反映了运行时环境或捆绑器——通常是一个具有已知行为的单一实体。在编写库时,理想情况下,您应该在所有可能的库使用者编译设置下检查您的代码。由于这在实际操作中不可行,您可以改为使用最严格的设置,因为满足这些设置往往会满足所有其他设置。

json
{
"compilerOptions": {
"module": "node16",
"target": "es2020", // set to the *lowest* target you support
"strict": true,
"verbatimModuleSyntax": true,
"declaration": true,
"sourceMap": true,
"declarationMap": true
}
}

让我们来检查一下为什么我们选择了这些设置中的每一个

  • module: "node16"。当一个代码库与 Node.js 的模块系统兼容时,它几乎总是也能在捆绑器中工作。如果您使用第三方发射器来发射 ESM 输出,请确保在您的 package.json 中设置 "type": "module",以便 TypeScript 将您的代码检查为 ESM,它在 Node.js 中使用比 CommonJS 更严格的模块解析算法。例如,让我们看看如果一个库使用 "moduleResolution": "bundler" 编译会发生什么

    ts
    export * from "./utils";

    假设 ./utils.ts(或 ./utils/index.ts)存在,捆绑器会接受这段代码,因此 "moduleResolution": "bundler" 不会报错。使用 "module": "esnext" 编译后,这段导出语句的输出 JavaScript 将与输入完全相同。如果这段 JavaScript 发布到 npm,它可以被使用捆绑器的项目使用,但在 Node.js 中运行时会导致错误

    Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js Did you mean to import ./utils.js?

    另一方面,如果我们写了

    ts
    export * from "./utils.js";

    这将产生在 Node.js 捆绑器中都能工作的输出。

    简而言之,"moduleResolution": "bundler" 是具有传染性的,允许生成仅在捆绑器中工作的代码。同样,"moduleResolution": "nodenext" 只是检查输出是否在 Node.js 中工作,但在大多数情况下,在 Node.js 中工作的模块代码将在其他运行时和捆绑器中工作。

  • target: "es2020"。将此值设置为您打算支持的最低 ECMAScript 版本,可以确保发出的代码不会使用在更高版本中引入的语言特性。由于 target 也隐含了 lib 的相应值,这也确保您不会访问可能在旧环境中不可用的全局变量。

  • strict: true。如果没有此选项,您可能会编写类型级代码,这些代码最终会出现在您的输出.d.ts文件中,并在消费者使用strict启用时出现错误。例如,此extends子句

    ts
    export interface Super {
    foo: string;
    }
    export interface Sub extends Super {
    foo: string | undefined;
    }

    仅在strictNullChecks下才会出现错误。另一方面,编写仅在strict禁用时才会出现错误的代码非常困难,因此强烈建议库使用strict进行编译。

  • verbatimModuleSyntax: true。此设置可以防止一些与模块相关的陷阱,这些陷阱会导致库消费者出现问题。首先,它可以防止编写任何导入语句,这些语句可能会根据用户的esModuleInteropallowSyntheticDefaultImports值进行模糊解释。以前,通常建议库在没有esModuleInterop的情况下进行编译,因为在库中使用它可能会迫使用户也采用它。但是,也可以编写仅在没有esModuleInterop的情况下才能工作的导入,因此设置的任何值都不能保证库的可移植性。verbatimModuleSyntax确实提供了这样的保证。1 其次,它可以防止在将作为 CommonJS 发出的模块中使用export default,这可能需要捆绑器用户和 Node.js ESM 用户以不同的方式使用该模块。有关更多详细信息,请参阅关于ESM/CJS 互操作性的附录。

  • declaration: true 在输出 JavaScript 旁边发出类型声明文件。库的消费者需要此文件才能获得任何类型信息。

  • sourceMap: truedeclarationMap: true 分别为输出 JavaScript 和类型声明文件发出源映射。这些只有在库也提供其源代码(.ts)文件时才有用。通过提供源映射和源文件,库的消费者将能够更轻松地调试库代码。通过提供声明映射和源文件,消费者将能够在对库中的导入运行“转到定义”时看到原始的 TypeScript 源代码。这两者都代表了开发人员体验和库大小之间的权衡,因此是否包含它们取决于您。

捆绑库的注意事项

如果您使用捆绑器来发出您的库,那么您所有(非外部化的)导入都将由捆绑器使用已知行为进行处理,而不是由用户不可知的环境进行处理。在这种情况下,您可以使用"module": "esnext""moduleResolution": "bundler",但只有两个注意事项

  1. TypeScript 无法在某些文件被捆绑而另一些文件被外部化的情况下模拟模块解析。在捆绑包含依赖项的库时,通常将第一方库源代码捆绑到单个文件中,但将外部依赖项的导入保留为捆绑输出中的实际导入。这实际上意味着模块解析在捆绑器和最终用户的环境之间被拆分。为了在 TypeScript 中模拟这种情况,您希望使用 "moduleResolution": "bundler" 处理捆绑的导入,并使用 "moduleResolution": "nodenext" 处理外部化的导入(或者使用多个选项来检查所有内容是否可以在一系列最终用户环境中正常工作)。但是,无法配置 TypeScript 在同一个编译中使用两种不同的模块解析设置。因此,使用 "moduleResolution": "bundler" 可能会允许导入在捆绑器中可以工作但在 Node.js 中不安全的外部化依赖项。另一方面,使用 "moduleResolution": "nodenext" 可能会对捆绑的导入施加过于严格的要求。

  2. 您必须确保您的声明文件也被捆绑。回顾一下 声明文件的首要规则:每个声明文件都代表一个 JavaScript 文件。如果您使用 "moduleResolution": "bundler" 并使用捆绑器来发出 ESM 捆绑包,同时使用 tsc 来发出多个单独的声明文件,那么您的声明文件在 "module": "nodenext" 下使用时可能会导致错误。例如,像这样的输入文件

    ts
    import { Component } from "./extensionless-relative-import";

    它的导入将被 JS 捆绑器删除,但会生成一个包含相同导入语句的声明文件。但是,该导入语句将在 Node.js 中包含一个无效的模块说明符,因为它缺少文件扩展名。对于 Node.js 用户,TypeScript 将在声明文件上报错,并将引用 Component 的类型感染为 any,假设依赖项将在运行时崩溃。

    如果您的 TypeScript 捆绑器不生成捆绑的声明文件,请使用 "moduleResolution": "nodenext" 以确保声明文件中保留的导入与最终用户的 TypeScript 设置兼容。更重要的是,考虑不要捆绑您的库。

关于双重发出解决方案的说明

一次 TypeScript 编译(无论是生成代码还是仅进行类型检查)都假设每个输入文件只生成一个输出文件。即使 tsc 没有生成任何内容,它对导入名称执行的类型检查也依赖于对输出文件在运行时的行为的了解,这些行为基于 tsconfig.json 中设置的模块和生成相关选项。虽然第三方生成器通常可以安全地与 tsc 类型检查结合使用,只要 tsc 可以被配置为理解其他生成器将生成的内容,但任何从同一个源文件生成两个不同模块格式的输出集,而只进行一次类型检查的解决方案,都会导致(至少)一个输出未被检查。由于外部依赖项可能对 CommonJS 和 ESM 消费者公开不同的 API,因此您无法使用任何配置来保证在一次编译中,两个输出都是类型安全的。在实践中,大多数依赖项遵循最佳实践,双重生成输出可以正常工作。在发布之前,对所有输出包运行测试和 静态分析 可以显著降低严重问题未被发现的可能性。


  1. verbatimModuleSyntax 只能在 JS 生成器生成与 tsc 根据 tsconfig.json、源文件扩展名和 package.json 中的 "type" 生成的相同模块类型时才起作用。该选项通过强制 import/require 的写法与 import/require 的生成方式相同来实现。任何从同一个源文件生成 ESM 和 CJS 输出的配置,从根本上与 verbatimModuleSyntax 不兼容,因为它的目的就是防止您在应该生成 require 的地方写 importverbatimModuleSyntax 也可以通过配置第三方生成器来生成与 tsc 不同的模块类型来绕过——例如,在 tsconfig.json 中设置 "module": "esnext",同时配置 Babel 生成 CommonJS。

TypeScript 文档是一个开源项目。欢迎您通过 发送 Pull Request 来帮助我们改进这些页面 ❤

本页贡献者
ABAndrew Branch (6)

上次更新时间:2024 年 3 月 21 日