按运行环境选择 TypeScript 模块编译配置

TypeScript 模块参考 · 编译器选项指南

按运行环境选择 TypeScript 模块编译配置

类型检查所理解的模块规则,需要与代码最终运行时的规则一致。这篇官方指南分别讨论打包应用、Node.js、浏览器原生模块和库发布,并说明声明文件与双格式输出容易遗漏的兼容边界。

原文:Modules – Choosing Compiler Options。作者与贡献者:TypeScript 文档贡献者;当前页面列出 Andrew Branch、Tom Mrazauskas。页面标注最后更新于 2026 年 10 月 5 日,未注明首次发表日期。中文翻译与原创示意图:未完纪。

从实际执行环境选择 TypeScript 模块配置:打包应用匹配 bundler,Node.js 应用匹配 nodenext,库还需核对声明文件、外部依赖和 ESM/CJS 两套产物。
原创示意图:先确定谁负责处理模块,再检查交付给使用者的每一类产物。图中选项是原文所用的配置起点,不是完整模板。

我正在开发应用

一份 tsconfig.json 只能描述一种环境,这既包括有哪些全局对象可用,也包括模块如何工作。如果应用包含服务器代码、DOM 代码、Web Worker 代码、测试代码,以及供这些环境共同使用的代码,那么每一部分都应有自己的 tsconfig.json,并通过项目引用连接起来。然后,针对每份 tsconfig.json 分别使用本指南。

对于应用内部具有库性质的项目,尤其是需要在多个运行时环境中运行的项目,请参阅“我正在编写库”一节。

我使用打包器

除了采用下面的设置,原文目前还建议:在打包器项目中,不要设置 { "type": "module" },也不要使用 .mts 文件。某些打包器在这些情况下会采用不同的 ESM/CJS 互操作行为,而 TypeScript 目前无法在 "moduleResolution": "bundler" 下分析这种差异。详情见 issue #54102。

{
  "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 文件。

{
  "compilerOptions": {
    // 这不是完整模板,只展示与模块有关的设置。
    // 请同时设置 target、lib 和 strict 等其他重要选项。

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

    // "module": "nodenext" 隐含以下设置:
    // "moduleResolution": "nodenext",
    // "esModuleInterop": true,
    // "target": "esnext",

    // 建议
    "verbatimModuleSyntax": true,
  }
}

我使用 ts-node

ts-node 尝试兼容这样一套代码与 tsconfig.json 设置:它们也能够用于编译代码,并在 Node.js 中运行输出的 JavaScript。更多信息请参阅 ts-node 文档。

我使用 tsx

默认情况下,ts-node 对 Node.js 模块系统的修改很少;tsx 的行为则更接近打包器,允许省略扩展名、通过目录下的 index 文件解析模块说明符,也允许任意混用 ESM 与 CJS。使用 tsx 时,应采用与打包器相同的设置。

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

TypeScript 目前没有专门针对这一场景的选项。不过,可以结合使用 nodenext 的 ESM 模块解析算法和 paths,近似实现所需行为,用 paths 补足 URL 与 import map 的支持。

// tsconfig.json
{
  "compilerOptions": {
    // 这不是完整模板,只展示与模块有关的设置。
    // 请同时设置 target、lib 和 strict 等其他重要选项。

    // 在本地 package.json 中同时设置 "type": "module",
    // 即可要求相对路径导入必须包含文件扩展名。
    "module": "nodenext",
    "paths": {
      // 将远程 URL 对应到本地类型声明:
      "https://esm.sh/lodash@4.17.21": ["./node_modules/@types/lodash/index.d.ts"],

      // 可选:将裸模块说明符导入指向空文件,
      // 从而禁止导入此处未列出的 node_modules 模块说明符:
      "*": ["./empty-file.ts"]
    }
  }
}

这样配置后,明确列出的 HTTPS 导入可以使用本地安装的类型声明文件;通常会解析到 node_modules 的导入则会报错:

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

这条诊断的意思是:文件 /project/empty-file.ts 不是模块。

另一种做法是使用 import map,在浏览器中把一组裸模块说明符明确映射到 URL,同时依靠 nodenext 默认的 node_modules 查找规则,或依靠 paths,让 TypeScript 找到这些导入对应的类型声明文件:

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

我正在编写库

库作者选择编译设置的过程,与应用作者有本质区别。开发应用时,所选设置用来反映运行时环境或打包器的行为——通常只需面对一种行为已知的环境。编写库时,理想做法是在库使用者可能采用的所有编译设置下检查代码。既然这不现实,就可以改为采用尽可能严格的设置,因为满足这些设置,通常也能满足其他设置。

{
  "compilerOptions": {
    "module": "node18",
    "target": "es2020", // 设为你支持的最低 target
    "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 检查代码。在 Node.js 中,ESM 使用的模块解析算法比 CommonJS 更严格。

例如,假设某个库使用 "moduleResolution": "bundler" 编译下面的代码:

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?

错误提示找不到导入的模块,并询问是否原本想导入 ./utils.js。如果改写为:

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 子句:

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

这段代码只有在启用 strictNullChecks 时才会报错。反过来,只在关闭 strict 时才报错的代码很难写出来,因此强烈建议库在编译时启用 strict。

verbatimModuleSyntax: true

这个设置可以防止几类与模块有关的陷阱,避免给库的使用者带来问题。首先,它会阻止你写出含义不明确的导入语句——这类语句的解释方式可能取决于使用者设置的 esModuleInterop 或 allowSyntheticDefaultImports 值。过去,人们常建议库不要启用 esModuleInterop,因为库使用它可能迫使使用者也启用它。

但也有一些导入写法只在关闭 esModuleInterop 时才能工作。因此,无论这个选项设为哪个值,都不能保证库的可移植性。verbatimModuleSyntax 则可以对这一点提供保证。[1]

其次,它会禁止在最终输出为 CommonJS 的模块中使用 export default。这种写法可能迫使打包器使用者和 Node.js ESM 使用者以不同方式使用模块。更多细节见 ESM/CJS 互操作附录。

declaration: true

这个设置会在输出 JavaScript 的同时生成类型声明文件。库的使用者需要这些声明文件,才能获得类型信息。

sourceMap: true 与 declarationMap: true

这两个设置分别为输出的 JavaScript 和类型声明文件生成源映射。只有在库同时发布源文件(.ts)时,这些映射才有用。将源映射和源文件一起发布,使用者调试库代码会方便一些。将声明映射和源文件一起发布,使用者对从库中导入的符号执行“转到定义”时,就能看到原始 TypeScript 源码。

两者都涉及开发体验与库体积之间的取舍,是否包含它们由你决定。

rootDir: "src" 与 outDir: "dist"

使用单独的输出目录总是一个好习惯;对于会发布输入源文件的库来说,这更是必要条件。否则,文件扩展名替换会让库的使用者加载库的 .ts 文件,而不是 .d.ts 文件,从而造成类型错误和性能问题。

打包库时需要考虑什么

如果使用打包器生成库,那么所有未被外置的导入都会由行为已知的打包器处理,而不是交给使用者那些无法预知的环境。在这种情况下,可以使用 "module": "esnext" 和 "moduleResolution": "bundler",但要满足下面两点。

一、部分文件打包、部分依赖外置时,TypeScript 无法完整建模模块解析

打包一个带有依赖的库时,常见做法是把库自身的源码打包成一个文件,同时让外部依赖的导入在打包结果中继续保留为真正的导入。这实际上意味着模块解析分别由打包器和最终使用者的环境承担。

要在 TypeScript 中描述这种情况,理想做法是用 "moduleResolution": "bundler" 处理将被打包的导入,再用 "moduleResolution": "nodenext" 处理外置的导入;或者采用多组设置,检查各种最终使用环境中都能正常工作。但 TypeScript 无法在同一次编译中使用两种不同的模块解析设置。

因此,使用 "moduleResolution": "bundler" 可能会放行某些外置依赖的导入:它们在打包器中可以工作,在 Node.js 中却不安全。另一方面,使用 "moduleResolution": "nodenext" 又可能对将被打包的导入施加过于严格的要求。

二、必须确保声明文件也经过打包

请记住声明文件的第一条规则:每个声明文件都恰好对应一个 JavaScript 文件。如果采用 "moduleResolution": "bundler",用打包器输出一个 ESM 包,却用 tsc 输出许多独立的声明文件,那么使用者在 "module": "nodenext" 下使用这些声明时,可能会遇到错误。例如下面的输入:

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

JavaScript 打包器会移除这条导入,但生成的声明文件中可能仍保留完全相同的导入语句。由于缺少文件扩展名,这条语句中的模块说明符在 Node.js 中无效。对于 Node.js 使用者,TypeScript 会对该声明文件报错,并让引用 Component 的类型受到 any 的影响,因为它判断这个依赖会在运行时崩溃。

如果所用 TypeScript 打包器不会输出打包后的声明文件,就应使用 "moduleResolution": "nodenext",确保声明文件中保留下来的导入能够兼容最终使用者的 TypeScript 设置。更好的办法是,考虑是否根本不需要打包这个库。

关于同时输出两种模块格式的方案

一次 TypeScript 编译,无论负责生成文件还是仅做类型检查,都假定每个输入文件只会生成一个输出文件。即使 tsc 什么文件都不生成,它检查导入名称时,仍要根据 tsconfig.json 中与模块和输出有关的选项,了解输出文件在运行时会如何表现。

只要能够把 tsc 配置成理解另一个生成工具会输出什么,将第三方生成工具与 tsc 类型检查配合使用通常是安全的。但如果某个方案生成两套模块格式不同的输出,却只进行一次类型检查,就会让至少其中一套输出没有得到检查。

外部依赖可能向 CommonJS 和 ESM 使用者暴露不同的 API,因此不存在某种配置,可以在一次编译中保证两套输出都具备类型安全性。实践中,多数依赖会遵循最佳实践,因此双格式输出往往能够工作。在发布前对所有输出包运行测试和静态分析,能显著降低严重问题未被发现的概率。

原文脚注

[1] 只有当 JavaScript 生成工具输出的模块种类,与 tsc 根据 tsconfig.json、源文件扩展名以及 package.json 中的 "type" 所判断的模块种类一致时,verbatimModuleSyntax 才能发挥作用。这个选项通过强制要求源码中写下的 import/require 与最终输出的 import/require 一致来工作。

任何从同一源文件同时生成 ESM 和 CJS 两套输出的配置,从根本上都与 verbatimModuleSyntax 不兼容,因为这个选项的目的就是:凡是最终会输出 require 的地方,都不允许写 import。如果把第三方生成工具配置成输出与 tsc 不同的模块种类,也会破坏 verbatimModuleSyntax 提供的保证。例如,在 tsconfig.json 中设置 "module": "esnext",却把 Babel 配置为输出 CommonJS。返回正文

译注:版本与使用边界

  • 这是滚动更新的官方指南,不是绑定某个项目版本的配置承诺。上文保留原文的配置选择与限定条件;customConditions 等选项仍需结合具体打包器文档确定。
  • 官方 module 选项参考注明,node18 从 TypeScript 5.8 起可用;nodenext 从 4.7 起可用,但会随最新稳定版 Node.js 的行为变化,并隐含浮动的 target: "esnext"。旧编译器不一定接受原文库模板中的 node18。
  • 上述含注释或尾逗号的配置按 tsconfig.json 可接受的 JSONC 形式展示,不应误当作严格 JSON。涉及 ./empty-file.ts 的示例以该空文件存在为前提;.js 后缀指向实际 JavaScript 输出。如果源文件是 utils/index.ts,对应路径应按产物写为 ./utils/index.js。
  • target 与默认 lib 有关联,但它们不会替运行环境安装 API 或补丁;如果手动覆盖 lib,仍需自行匹配目标环境。浏览器示例中的 paths 用来帮助 TypeScript 找类型,不会替浏览器配置导入映射,也不是远端代码安全保证。
  • 原文引用的 tsx 仓库现跳转至 privatenumber/tsx,本译稿链接已跟随该目标。原文“静态分析”的 npm 页面本次返回 403,因此正文链接改用已核对的同项目 CLI 官方 README;工具用途不变。
  • 代码、诊断和 CDN 地址仅作为文档内容展示。本文没有下载 CDN 模块,没有运行示例,也没有对任何实际项目作出“测试通过”的结论。

来源与许可:本文翻译自 TypeScript 官方文档 Modules – Choosing Compiler Options,贡献者包括 Andrew Branch、Tom Mrazauskas。TypeScript-Website 仓库法律声明说明,Microsoft 与贡献者将文档和其他内容置于 CC BY 4.0 下,并将仓库代码置于 MIT License 下;两份完整许可文本随稿提供。材料按原样提供,不附带保证。

本版本进行了中文翻译、代码注释翻译和排版调整,新增了原创示意图与明确标注的译注;不是官方中文版,也不表示原作者为本译稿背书。译文与原创示意图按 CC BY 4.0 提供。文中代码示例的来源许可按仓库法律声明保留。阅读原文可获取后续更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容