Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

模块 - 选择编译器选项

我正在编写一个应用程序

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

我正在使用打包器

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

json5
{
  "compilerOptions": {
    // 这不是一个完整的模板;它只展示
    // 相关的模块相关设置。
    // 请务必设置其他重要选项
    // 如 `target`、`lib` 和 `strict`。

    // 必需
    "module": "esnext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,

    // 查阅你的打包器文档
    "customConditions": ["module"],

    // 推荐
    "noEmit": true, // 或 `emitDeclarationOnly`
    "allowImportingTsExtensions": true,
    "allowArbitraryExtensions": true,
    "verbatimModuleSyntax": true, // 或 `isolatedModules`
  }
}

我正在编译并在 Node.js 中运行输出

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

json5
{
  "compilerOptions": {
    // 这不是一个完整的模板;它只展示
    // 相关的模块相关设置。
    // 请务必设置其他重要选项
    // 如 `target`、`lib` 和 `strict`。

    // 必需
    "module": "nodenext",

    // 由 `"module": "nodenext"` 隐含:
    // "moduleResolution": "nodenext",
    // "esModuleInterop": true,
    // "target": "esnext",

    // 推荐
    "verbatimModuleSyntax": true,
  }
}

我正在使用 ts-node

ts-node 试图与可用于在 Node.js 中编译并运行 JS 输出的相同代码和相同 tsconfig.json 设置兼容。更多细节请参考 ts-node 文档

我正在使用 tsx

ts-node 默认对 Node.js 的模块系统进行最小修改,而 tsx 的行为更像打包器,允许无扩展名/索引模块说明符以及 ESM 和 CJS 的任意混合。对于 tsx,使用与打包器相同的设置。

我正在为浏览器编写 ES 模块,不使用打包器或模块编译器

TypeScript 目前没有专门用于此场景的选项,但你可以通过组合使用 nodenext ESM 模块解析算法和 paths 作为 URL 和导入映射支持的替代品来近似实现。

json5
// tsconfig.json
{
  "compilerOptions": {
    // 这不是一个完整的模板;它只展示
    // 相关的模块相关设置。
    // 请务必设置其他重要选项
    // 如 `target`、`lib` 和 `strict`。

    // 结合本地 package.json 中的 `"type": "module"`,
    // 这强制要求在相对路径导入中包含文件扩展名。
    "module": "nodenext",
    "paths": {
      // 为远程 URL 指向 TS 本地类型:
      "https://esm.sh/lodash@4.17.21": ["./node_modules/@types/lodash/index.d.ts"],
      // 可选:将裸说明符导入指向一个空文件
      // 以禁止导入此处未列出的 node_modules 说明符:
      "*": ["./empty-file.ts"]
    }
  }
}

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

ts
import {} from "lodash";
//             ^^^^^^^^
// 文件 '/project/empty-file.ts' 不是一个模块。ts(2306)

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

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

我正在编写一个库

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

json5
{
  "compilerOptions": {
    "module": "node18",
    "target": "es2020", // 设置为 *最低* 支持的目标
    "strict": true,
    "verbatimModuleSyntax": true,
    "declaration": true,
    "sourceMap": true,
    "declarationMap": true,
    "rootDir": "src",
    "outDir": "dist"
  }
}

让我们分析为什么我们选择了这些设置:

  • 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"。将此值设置为最低你打算支持的 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 源代码。这两者都代表了开发者体验和库大小之间的权衡,因此是否包含它们取决于你。

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

打包库的注意事项

如果你使用打包器来输出你的库,那么你的所有(未外部化的)导入将由打包器以已知行为处理,而不是由用户不可知的环境处理。在这种情况下,你可以使用 "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 的地方编写 import。配置第三方发射器输出与 tsc 不同的模块种类也会使 verbatimModuleSyntax 失效——例如,在 tsconfig.json 中设置 "module": "esnext" 同时配置 Babel 输出 CommonJS。 ↩︎