模块 - 选择编译器选项

我正在写一个应用

🌐 I’m writing an app

一个 tsconfig.json 文件只能表示单一环境,无论是在可用全局变量方面,还是在模块行为方面。如果你的应用包含服务器代码、DOM 代码、Web Worker 代码、测试代码,以及所有这些代码共享的代码,每一类都应该有自己的 tsconfig.json,并通过项目引用进行关联。然后,对于每个 tsconfig.json,按照本指南使用一次。对于应用中类似库的项目,特别是那些需要在多种运行时环境下运行的项目,请使用“我正在编写一个库”部分。

🌐 A single tsconfig.json can only represent a single environment, both in terms of what globals are available and in terms of how modules behave. If your app contains server code, DOM code, web worker code, test code, and code to be shared by all of those, each of those should have its own tsconfig.json, connected with project references. Then, use this guide once for each tsconfig.json. For library-like projects within an app, especially ones that need to run in multiple runtime environments, use the “I’m writing a library” section.

我正在使用打包器

🌐 I’m using a bundler

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

🌐 In addition to adopting the following settings, it’s also recommended not to set { "type": "module" } or use .mts files in bundler projects for now. Some bundlers adopt different ESM/CJS interop behavior under these circumstances, which TypeScript cannot currently analyze with "moduleResolution": "bundler". See issue #54102 for more information.

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 中编译并运行输出

🌐 I’m compiling and running the outputs in Node.js

如果你打算输出 ES 模块,记得设置 "type": "module" 或使用 .mts 文件。

🌐 Remember to set "type": "module" or use .mts files if you intend to emit ES modules.

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

🌐 I’m using ts-node

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

🌐 ts-node attempts to be compatible with the same code and the same tsconfig.json settings that can be used to compile and run the JS outputs in Node.js. Refer to ts-node documentation for more details.

我正在使用 tsx

🌐 I’m using tsx

而 ts-node 默认对 Node.js 的模块系统几乎不做修改,tsx 的行为更像是一个打包工具,允许使用无扩展名/索引模块指定符,并可以任意混合 ESM 和 CJS。对 tsx 使用与打包工具相同的配置。

🌐 Whereas ts-node makes minimal modifications to Node.js’s module system by default, tsx behaves more like a bundler, allowing extensionless/index module specifiers and arbitrary mixing of ESM and CJS. Use the same settings for tsx as you would for a bundler.

我正在为浏览器编写 ES 模块,没有打包器或模块编译器

🌐 I’m writing ES modules for the browser, with no bundler or module compiler

TypeScript 目前没有专门针对这种情景的选项,但你可以通过结合使用 nodenext ESM 模块解析算法和将 paths 作为 URL 和导入映射支持的替代方案来实现近似功能。

🌐 TypeScript does not currently have options dedicated to this scenario, but you can approximate them by using a combination of the nodenext ESM module resolution algorithm and paths as a substitute for URL and import map support.

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/lodash@4.17.21": ["./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 中解析的导入上出错:

🌐 This setup allows explicitly listed HTTPS imports to use locally-installed type declaration files, while erroring on imports that would normally resolve in node_modules:

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

或者,你可以使用 import maps 在浏览器中将一组裸导入标识符明确映射到 URL,同时依赖 nodenext 的默认 node_modules 查找,或依赖 paths,将 TypeScript 定向到这些裸导入标识符的类型声明文件:

🌐 Alternatively, you can use import maps to explicitly map a list of bare specifiers to URLs in the browser, while relying on nodenext’s default node_modules lookups, or on paths, to direct TypeScript to type declaration files for those bare specifier imports:

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

我正在写一个库

🌐 I’m writing a library

作为库作者选择编译设置与作为应用作者选择设置是一个本质上不同的过程。当编写应用时,设置的选择反映了运行时环境或打包工具——通常这是一个已知行为的单一实体。而在编写库时,你理想情况下应该在_所有可能的_库使用者的编译设置下检查你的代码。由于这是不切实际的,你可以改为使用尽可能严格的设置,因为满足这些设置通常也能满足其他所有设置。

🌐 Choosing compilation settings as a library author is a fundamentally different process from choosing settings as an app author. When writing an app, settings are chosen that reflect the runtime environment or bundler—typically a single entity with known behavior. When writing a library, you would ideally check your code under all possible library consumer compilation settings. Since this is impractical, you can instead use the strictest possible settings, since satisfying those tends to satisfy all others.

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

让我们来看看为什么我们选择这些设置:

🌐 Let’s examine why we picked each of these settings:

  • module: "node18"。当一个代码库与 Node.js 的模块系统兼容时,它几乎总能在打包工具中正常工作。如果你使用第三方触发器生成 ESM 输出,请确保在 package.json 中设置 "type": "module",这样 TypeScript 会按 ESM 检查你的代码,而 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". Setting this value to the lowest ECMAScript version that you intend to support ensures the emitted code will not use language features introduced in a later version. Since target also implies a corresponding value for lib,这也确保你不会访问在较旧环境中可能不可用的全局变量。

  • strict: true。如果没有它,你可能会编写类型级代码,这些代码最终出现在你的输出 .d.ts 文件中,并在用户启用 strict 时编译会报错。例如,这个 extends 条款:

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

    is only an error under strictNullChecks. On the other hand, it’s very difficult to write code that errors only when strict is disabled, so it’s highly recommended for libraries to compile with 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 源代码。这两者都代表了开发者体验与库体积之间的权衡,因此是否包含它们取决于你。

  • rootDir: "src"outDir: "dist"。使用单独的输出目录总是一个好主意,但对于发布其输入文件的库来说,这是 必要的。否则,扩展名替换 将导致库的使用者加载库的 .ts 文件而不是 .d.ts 文件,从而引发类型错误和性能问题。

打包库的注意事项

🌐 Considerations for bundling libraries

如果你使用打包工具来输出你的库,那么所有未外部化的导入都将由打包工具处理,行为是已知的,而不是由用户不可预知的环境处理。在这种情况下,你可以使用 "module": "esnext""moduleResolution": "bundler",但只有两个注意事项:

🌐 If you’re using a bundler to emit your library, then all your (non-externalized) imports will be processed by the bundler with known behavior, not by your users’ unknowable environments. In this case, you can use "module": "esnext" and "moduleResolution": "bundler", but only with two caveats:

  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 设置兼容。更好的是,考虑不要打包你的库。

双触发解决方案注意事项

🌐 Notes on dual-emit solutions

A single TypeScript compilation (whether emitting or just type checking) assumes that each input file will only produce one output file. Even if tsc isn’t emitting anything, the type checking it performs on imported names rely on knowledge about how the output file will behave at runtime, based on the module- and emit-related options set in the tsconfig.json. While third-party emitters are generally safe to use in combination with tsc type checking as long as tsc can be configured to understand what the other emitter will emit, any solution that emits two different sets of outputs with different module formats while only type checking once leaves (at least) one of the outputs unchecked. Because external dependencies may expose different APIs to CommonJS and ESM consumers, there’s no configuration you can use to guarantee in a single compilation that both outputs will be type-safe. In practice, most dependencies follow best practices and dual-emit outputs work. Running tests and static analysis against all output bundles before publishing significantly reduces the chance of a serious problem going unnoticed.


  1. verbatimModuleSyntax can only work when the JS emitter emits the same module kind as tsc would given the tsconfig.json, source file extension, and package.json "type". The option works by enforcing that the import/require written is identical to the import/require emitted. Any configuration that produces both an ESM and a CJS output from the same source file is fundamentally incompatible with verbatimModuleSyntax, since its whole purpose is to prevent you from writing import anywhere that a require would be emitted. verbatimModuleSyntax can also be defeated by configuring a third-party emitter to emit a different module kind than tsc would—for example, by setting "module": "esnext" in tsconfig.json while configuring Babel to emit CommonJS.