模块 - 选择编译器选项

我正在写一个应用

¥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" 进行分析。有关详细信息,请参阅 问题#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)

或者,你可以使用 导入映射 将裸说明符列表显式映射到浏览器中的 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": "node16",
"target": "es2020", // set to the *lowest* target you support
"strict": true,
"verbatimModuleSyntax": true,
"declaration": true,
"sourceMap": true,
"declarationMap": true
}
}

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

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

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

    ¥**module: "node16"**. When a codebase is compatible with Node.js’s module system, it almost always works in bundlers as well. If you’re using a third-party emitter to emit ESM outputs, ensure that you set "type": "module" in your package.json so TypeScript checks your code as ESM, which uses a stricter module resolution algorithm in Node.js than CommonJS does. As an example, let’s look at what would happen if a library were to compile with "moduleResolution": "bundler":

    ts
    export * from "./utils";

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

    ¥Assuming ./utils.ts (or ./utils/index.ts) exists, a bundler would be fine with this code, so "moduleResolution": "bundler" doesn’t complain. Compiled with "module": "esnext", the output JavaScript for this export statement will look exactly the same as the input. If that JavaScript were published to npm, it would be usable by projects that use a bundler, but it would cause an error when run in 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?

    另一方面,如果我们写:

    ¥On the other hand, if we had written:

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

    这将产生在 Node.js 和打包器中都有效的输出。

    ¥This would produce output that works both in Node.js and in bundlers.

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

    ¥In short, "moduleResolution": "bundler" is infectious, allowing code that only works in bundlers to be produced. Likewise, "moduleResolution": "nodenext" is only checking that the output works in Node.js, but in most cases, module code that works in Node.js will work in other runtimes and in bundlers.

  • target: "es2020"。将此值设置为你打算支持的最低 ECMAScript 版本可确保触发的代码不会使用更高版本中引入的语言功能。由于 target 也意味着 lib 的相应值,这也确保你不会访问旧环境中可能不可用的全局变量。

    ¥**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, this also ensures you don’t access globals that may not be available in older environments.

  • strict: true。如果没有这个,你可能会编写类型级代码,最终会出现在输出 .d.ts 文件中,并且当使用者在启用 strict 的情况下进行编译时会出现错误。例如,这个 extends 子句:

    ¥**strict: true**. Without this, you may write type-level code that ends up in your output .d.ts files and errors when a consumer compiles with strict enabled. For example, this extends clause:

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

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

    ¥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 互操作 的附录。

    ¥**verbatimModuleSyntax: true**. This setting protects against a few module-related pitfalls that can cause problems for library consumers. First, it prevents writing any import statements that could be interpreted ambiguously based on the user’s value of esModuleInterop or allowSyntheticDefaultImports. Previously, it was often suggested that libraries compile without esModuleInterop, since its use in libraries could force users to adopt it too. However, it’s also possible to write imports that only work without esModuleInterop, so neither value for the setting guarantees portability for libraries. verbatimModuleSyntax does provide such a guarantee.1 Second, it prevents the use of export default in modules that will be emitted as CommonJS, which can require bundler users and Node.js ESM users to consume the module differently. See the appendix on ESM/CJS Interop for more details.

  • declaration: true 在输出 JavaScript 的同时触发类型声明文件。这是库的使用者获得任何类型信息所必需的。

    ¥**declaration: true** emits type declaration files alongside the output JavaScript. This is needed for consumers of the library to have any type information.

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

    ¥**sourceMap: true** and declarationMap: true emit source maps for the output JavaScript and type declaration files, respectively. These are only useful if the library also ships its source (.ts) files. By shipping source maps and source files, consumers of the library will be able to debug the library code somewhat more easily. By shipping declaration maps and source files, consumers will be able to see the original TypeScript sources when they run Go To Definition on imports from the libraries. Both of these represent a tradeoff between developer experience and library size, so it’s up to you whether to include them.

打包库的注意事项

¥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" 可能会对打包导入提出过于严格的要求。

    ¥TypeScript cannot model module resolution when some files are bundled and some are externalized. When bundling libraries with dependencies, it’s common to bundle the first-party library source code into a single file, but leave imports of external dependencies as real imports in the bundled output. This essentially means module resolution is split between the bundler and the end user’s environment. To model this in TypeScript, you would want to process bundled imports with "moduleResolution": "bundler" and externalized imports with "moduleResolution": "nodenext" (or with multiple options to check that everything will work in a range of end-user environments). But TypeScript cannot be configured to use two different module resolution settings in the same compilation. As a consequence, using "moduleResolution": "bundler" may allow imports of externalized dependencies that would work in a bundler but are unsafe in Node.js. On the other hand, using "moduleResolution": "nodenext" may impose overly strict requirements on bundled imports.

  2. 你必须确保你的声明文件也被打包在一起。回想一下 声明文件的第一条规则:每个声明文件都代表一个 JavaScript 文件。如果你使用 "moduleResolution": "bundler" 并使用打包器触发 ESM 打包包,同时使用 tsc 触发许多单独的声明文件,则你的声明文件在 "module": "nodenext" 下使用时可能会导致错误。例如,输入文件如下:

    ¥You must ensure that your declaration files get bundled as well. Recall the first rule of declaration files: every declaration file represents exactly one JavaScript file. If you use "moduleResolution": "bundler" and use a bundler to emit an ESM bundle while using tsc to emit many individual declaration files, your declaration files may cause errors when consumed under "module": "nodenext". For example, an input file like:

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

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

    ¥will have its import erased by the JS bundler, but produce a declaration file with an identical import statement. That import statement, however, will contain an invalid module specifier in Node.js, since it’s missing a file extension. For Node.js users, TypeScript will error on the declaration file and infect types referencing Component with any, assuming the dependency will crash at runtime.

    如果你的 TypeScript 打包器不生成打包的声明文件,请使用 "moduleResolution": "nodenext" 确保声明文件中保留的导入与终端用户的 TypeScript 设置兼容。更好的是,考虑不要打包你的库。

    ¥If your TypeScript bundler does not produce bundled declaration files, use "moduleResolution": "nodenext" to ensure that the imports preserved in your declaration files will be compatible with end-users’ TypeScript settings. Even better, consider not bundling your library.

双触发解决方案注意事项

¥Notes on dual-emit solutions

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

¥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. 仅当 JS 触发器触发与 tsc 相同的模块类型(给定 tsconfig.json、源文件扩展名和 package.json)时,verbatimModuleSyntax 才能工作。该选项的工作原理是强制写入的 import/require 与触发的 import/require 相同。任何从同一源文件生成 ESM 和 CJS 输出的配置从根本上来说与 verbatimModuleSyntax 不兼容,因为它的全部目的是防止你在将触发 require 的任何地方写入 importverbatimModuleSyntax 也可以通过配置第三方触发器触发与 tsc 不同的模块类型来击败,例如,通过在 tsconfig.json 中设置 "module": "esnext",同时配置 Babel 触发 CommonJS。

    ¥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.