我在编写应用
单个 tsconfig.json 只能表示单一环境,无论是就哪些全局变量可用而言,还是就模块行为而言。如果你的应用包含服务端代码、DOM 代码、Web Worker 代码、测试代码以及供所有这些共享的代码,那么每一类都应有自己的 tsconfig.json,并通过项目引用连接起来。然后,对每个 tsconfig.json 都按本指南处理一次。对于应用中的类库项目,尤其是需要在多种运行时环境中运行的项目,请使用“我在编写库”一节。
我在使用打包器
除了采用以下设置之外,目前还建议不要在打包器项目中设置 { "type": "module" } 或使用 .mts 文件。某些打包器在这些情况下会采用不同的 ESM/CJS 互操作行为,而 TypeScript 目前无法在 "moduleResolution": "bundler" 下分析这种情况。更多信息请参阅 issue #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 试图与在 Node.js 中编译并运行 JS 输出时所用的代码和 tsconfig.json 设置保持兼容。更多详情请参阅 ts-node 文档。
我在使用 tsx
ts-node 默认对 Node.js 的模块系统做尽量少的改动,而 tsx 的行为更像打包器,允许无扩展名或带 index 的模块说明符,也允许任意混用 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/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 中解析的导入报错:
tsimport {} 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/lodash@4.17.21"}}</script>
tsimport {} from "lodash";// Browser: https://esm.sh/lodash@4.17.21// TypeScript: ./node_modules/@types/lodash/index.d.ts
我在编写库
作为库作者选择编译设置,与作为应用作者选择设置是本质不同的过程。编写应用时,所选设置反映的是运行时环境或打包器——通常是具有已知行为的单一实体。编写库时,理想情况下应在所有可能的库使用者编译设置下检查代码。由于这并不现实,你可以改用尽可能严格的设置,因为满足了这些设置通常也就满足了其他所有设置。
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"}}
让我们来看看为什么选择这些设置:
-
module: "node18"。当代码库与 Node.js 的模块系统兼容时,它几乎总是也能在打包器中工作。如果你使用第三方输出器生成 ESM 输出,请确保在 package.json 中设置"type": "module",这样 TypeScript 会按 ESM 检查你的代码,而 Node.js 中 ESM 使用的模块解析算法比 CommonJS 更严格。举个例子,我们来看看如果库用"moduleResolution": "bundler"编译会发生什么:tsexport * 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.jsDid you mean to import ./utils.js?另一方面,如果我们写成:
tsexport * from "./utils.js";这样生成的输出既能在 Node.js 和打包器中工作。
简而言之,
"moduleResolution": "bundler"具有传染性,会允许产生只能在打包器中工作的代码。同样,"moduleResolution": "nodenext"只检查输出能否在 Node.js 中工作,但在大多数情况下,能在 Node.js 中工作的模块代码也能在其他运行时和打包器中工作。 -
target: "es2020"。将此值设置为你打算支持的最低 ECMAScript 版本,可以确保生成的代码不会使用更高版本引入的语言特性。由于target还会隐含对应的lib值,这也能确保你不会访问在旧环境中可能不可用的全局变量。 -
strict: true。如果不启用它,你可能会写出一些类型层面的代码,这些代码最终出现在输出的.d.ts文件中,并在使用者启用strict编译时报错。例如,下面这个extends子句:tsexport 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 bundle,同时用tsc输出许多单独的声明文件,那么你的声明文件在"module": "nodenext"下被使用时就可能引发错误。例如,像这样的输入文件:tsimport { Component } from "./extensionless-relative-import";其导入会被 JS 打包工具抹除,但生成的声明文件里仍会包含一条完全相同的导入语句。然而,在 Node.js 中这条导入语句会包含无效的模块说明符,因为它缺少文件扩展名。对 Node.js 用户来说,TypeScript 会在该声明文件上报错,并假定该依赖会在运行时崩溃,从而用
any污染引用Component的类型。如果你的 TypeScript 打包工具不生成打包后的声明文件,请使用
"moduleResolution": "nodenext",以确保声明文件中保留的导入与最终用户的 TypeScript 设置兼容。更好的做法是考虑不要打包你的库。
关于双输出方案的说明
单次 TypeScript 编译(无论是输出文件还是仅做类型检查)都假定每个输入文件只会产生一个输出文件。即使 tsc 不输出任何内容,它对被导入名称所做的类型检查也依赖于对输出文件在运行时会如何表现的了解,而这种了解来自 tsconfig.json 中与模块和输出相关的选项。只要能把 tsc 配置为理解其他输出工具会输出什么,第三方输出工具通常就可以安全地与 tsc 类型检查配合使用;但任何在只做一次类型检查的情况下以不同模块格式输出两套不同结果的方案,都会使其中(至少)一套输出未经检查。由于外部依赖可能向 CommonJS 和 ESM 使用者暴露不同的 API,因此没有任何配置能在单次编译中保证两套输出都是类型安全的。在实践中,大多数依赖都遵循最佳实践,双输出可以正常工作。在发布前对所有输出 bundle 运行测试和静态分析,能显著降低严重问题未被发现的可能性。
verbatimModuleSyntax只有在 JS 输出工具输出的模块种类与tsc依据 tsconfig.json、源文件扩展名和 package.json 的"type"所输出的模块种类相同时才能生效。该选项的工作原理是强制所写的import/require与所输出的import/require完全一致。任何从同一个源文件同时产生 ESM 和 CJS 输出的配置都与verbatimModuleSyntax根本不相容,因为它的全部目的就是阻止你在任何会输出require的地方写import。verbatimModuleSyntax也可能因为把第三方输出工具配置为输出与tsc不同的模块种类而失效——例如在 tsconfig.json 中设置"module": "esnext",同时把 Babel 配置为输出 CommonJS。↩