本节目标:把「TypeScript 到底按什么规则找模块」这件事讲清楚。读完后你能说出 classic、node10、node16、nodenext、bundler 五种模式的判定差异,知道
module与moduleResolution为什么必须成对配置,能独立为一个 Node 应用、一个打包器工程、一个待发布的老库分别选出正确的模式,并看懂Cannot find module与must be set to这两类报错的真实含义。
9.1 moduleResolution 各模式对照
第 8 章我们把代码交给 V8,看它如何采集类型反馈、如何做推测式优化。但有个前提一直悬而未决:import { foo } from "bar" 里的 "bar",究竟指向磁盘上的哪个文件? 浏览器与 Node 各有一套自己的答案,而 TypeScript 既不负责运行、也不负责打包,它必须自己再实现一遍——这就是模块解析(module resolution)。
难点在于:TypeScript 编译时看到的是源码,运行时执行的却是产物,而两者的解析规则可能完全不同。你在 src 里写 import "./utils",打包器会替你补全扩展名;可如果产物直接交给 Node 跑,Node 会毫不留情地抛 ERR_MODULE_NOT_FOUND。moduleResolution 就是用来声明「我的产物最终由谁执行」的那个开关。
9.1.1 为什么解析规则要分模式
先明确一件事:模块解析不是 TypeScript 发明的,而是它要去「模拟」的目标环境规定的。 同一个 import 语句,在不同环境下语义不同:
| 执行环境 | 解析规则来源 | 扩展名 | 目录导入 |
|---|---|---|---|
| 旧式 AMD/SystemJS | TypeScript 自创(classic) | 可省略 | 支持 |
| Node CommonJS | require.resolve 算法 | 可省略 | 支持 index.js |
| Node ESM | Node 的 ESM 解析算法 | 必须写全 | 不支持 |
| 打包器(Vite/esbuild) | 各自的解析器 | 可省略 | 支持 |
TypeScript 的目标是:在你写代码的这一刻,就用目标环境的规则去检查导入是否有效。 所以它没法给出一个「通用正确」的答案,只能提供多个模式让你挑。挑错的代价不是编辑器里飘红,而是产物在运行时崩溃——这类问题极难定位,因为编译期一切正常。
9.1.2 五种模式总览
moduleResolution 目前有五个可选值,其中 node 是 node10 的旧别名:
| 模式 | 引入版本 | 读 exports | 扩展名要求 | 典型场景 |
|---|---|---|---|---|
classic | 1.0 | 否 | 可省略 | 已废弃,仅历史遗留 |
node10(旧名 node) | 1.0 | 否 | 可省略 | 发布 CJS 的老库 |
node16 | 4.7 | 是 | 必须写全 | Node 16+ 的双模式包 |
nodenext | 4.7 | 是 | 必须写全 | 跟随最新 Node 语义 |
bundler | 5.0 | 是 | 可省略 | Vite / tsup / esbuild 产物 |
一个常见的误解是「越新的模式越好」。事实是:模式要与产物的执行方式匹配。 用 bundler 给一个直接 node dist/index.js 的服务端项目,会得到一个编译通过、运行即崩的项目;反过来,用 nodenext 写前端代码,你会被迫在每个相对导入后面写 .js,而打包器其实并不需要。
9.1.3 classic 与 node10:旧世界的规则
classic 是 TypeScript 最初为 AMD 模块设计的解析算法。它的行为很简单:从当前文件所在目录开始向上逐级查找同名 .ts / .d.ts,完全不查 node_modules。
// classic 模式下,这行永远找不到任何东西
import { z } from "zod";
// 错误:Cannot find module 'zod' or its corresponding type declarations.
今天已经没有理由再使用 classic,看到它基本可以判定为上古配置。
node10 则模拟 Node 的 CommonJS 解析,也就是 require.resolve 的那套:从当前目录向上逐级查找 node_modules,支持目录 index.ts 兜底,也读 package.json 的 main 与 types 字段。
// node10 下这些都合法
import { helper } from "./utils"; // → ./utils.ts
import { helper } from "./utils/"; // → ./utils/index.ts
import { z } from "zod"; // → node_modules/zod/package.json
node10 的关键缺陷是它不认识 exports 字段。 一个现代包如果只写了 exports 而没写 main,node10 会直接找不到它的类型声明。这就是为什么很多库作者明明只想维护一份 exports,却不得不额外写 main 和 types 来兼容旧解析器。
9.1.4 node16 / nodenext:双模式解析
node16 和 nodenext 是同一套算法的两个版本号,差别只在跟随的 Node 版本。它们最显著的特征是:同一个包里的文件,可能是 ESM 也可能是 CJS,取决于最近的 package.json 里 type 字段的值。
{
"name": "my-lib",
"type": "module"
}
在这个包下,所有 .ts 文件都被当作 ESM 模块来解析。于是下面这段代码会报错:
import { readConfig } from "./config";
// 错误 TS2835: Relative import paths need explicit file extensions in
// ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
// Did you mean './config.js'?
正确写法是 import { readConfig } from "./config.js"。注意这里写的是 .js 而不是 .ts——因为解析规则面向的是产物,产物叫 config.js。源文件叫什么,解析器不关心。这一点是新手最常困惑的地方,也是 nodenext 被抱怨「反直觉」的根源。
node16/nodenext 还有两个容易踩的行为:
| 行为 | 表现 |
|---|---|
| 目录导入 | import "./utils" 不再兜底到 ./utils/index.ts |
| 条件导出 | 同一包内 require 与 import 可解析到不同文件 |
| ESM/CJS 互操作 | CJS 文件里 import 一个 ESM 包会报 TS1479 |
import type | 在 ESM 下依然推荐显式写出,便于逐文件转译 |
第二条值得展开。当 exports 里同时声明了 require 与 import 条件时,nodenext 会根据导入方是 CJS 还是 ESM 选择不同分支。这意味着类型检查必须能区分「这个文件是 CJS 还是 ESM」,而这正是 node16 引入「按文件判定模块格式」的原因。双包发布的完整细节我们会在 7.2 双包(ESM/CJS)与类型解析
里再深入。
9.1.5 bundler:为打包器而生
TypeScript 5.0 加入的 bundler 模式,目标非常明确:模拟打包器的解析行为,让前端项目的类型检查与打包结果一致。
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
}
}
它相对 node16 放宽了三处:
| 行为 | node16 / nodenext | bundler |
|---|---|---|
| 相对导入扩展名 | 必须写全 | 可省略 |
目录 index 兜底 | 不支持 | 支持 |
| 按文件判定 ESM/CJS | 会判定并报错 | 不做此检查 |
同时它保留了 exports 支持,所以现代包的 exports 映射在 bundler 下依然有效。可以说它是「node10 的宽松 + node16 的 exports」的折中。
bundler 有一个硬约束:它只能配合支持 ESM 的 module 值使用。若写成 module: "CommonJS",编译器会直接拒绝并给出报错:
npx tsc --module commonjs --moduleResolution bundler
报错信息为 TS5095: Option 'bundler' can only be used when 'module' is set to 'preserve' or to 'es2015' or later. 也就是说,module: "CommonJS" + moduleResolution: "Bundler" 这个组合是非法的。理由很清楚:既然扩展名由打包器补全,产物必然是 ESM 语法,module 不该是 CJS。
9.1.6 module 与 moduleResolution 的合法组合
这两个选项必须成对考虑:module 决定产物用哪种模块语法,moduleResolution 决定导入按什么规则找文件。常见的合法组合如下:
| 场景 | module | moduleResolution | 说明 |
|---|---|---|---|
| 打包器构建 | ESNext / Preserve | Bundler | 扩展名可省,交给打包器 |
| Node ESM 服务 | NodeNext | NodeNext | 相对导入写 .js,最严格 |
| Node 双模式库 | NodeNext | NodeNext | 由 package.json 的 type 决定 |
| 纯 CJS 老库 | CommonJS | Node10 | 仅在必须发 CJS 时使用 |
| 保留 import/export 语法 | Preserve | Bundler | 交给上层工具处理 |
module: "Preserve" 是 TS 5.4 引入的:它让 tsc 原样保留 import / export 语句,不做任何转换,配合 bundler 与 allowImportingTsExtensions 使用,适合「tsc 只做类型检查,产物由别的工具生成」的工程。
一个实用判断法:问自己「dist 目录里的文件由谁执行?」
- 由 Node 直接执行 →
NodeNext - 由打包器消费、最终在浏览器运行 →
Bundler - 由另一个 Node 项目
require→NodeNext并导出 CJS 分支
如果你正在维护一个 monorepo,各子包可能同时存在这三种情况,此时应让每个子包用自己的 tsconfig,而不是在根配置里一刀切。组织方式可延伸阅读 TypeScript Monorepo 与 Turborepo
。
9.1.7 paths、baseUrl 与解析优先级
paths 常被误认为「给导入起了个别名」,实际上它只是在解析流程最前面插了一张映射表:命中的导入被替换成另一个路径,然后继续按当前模式解析。
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["../shared/src/*"]
}
}
}
从 TS 4.1 起,paths 不再依赖 baseUrl,可以单独使用(映射值相对配置文件解析)。但有一个必须记住的事实:
paths只影响类型检查,不影响产物。
import { User } from "@/models/user";
// tsc 检查通过,但产物里这行仍然是 "@/models/user"
// Node 运行时会报 ERR_MODULE_NOT_FOUND,除非打包器或 import maps 认识这个别名
所以 paths 必须与运行时方案配套:用 Vite 就在 vite.config.ts 里配 resolve.alias,用 Node 就用 imports 字段(# 前缀的子路径导入)或打包器插件。只配一半,就会得到「编辑器不报错、运行时崩」的最糟组合。
解析优先级可以简化为一条链:
| 顺序 | 规则 | 说明 |
|---|---|---|
| 1 | paths 映射 | 命中即替换,再继续后续步骤 |
| 2 | 相对 / 绝对路径 | ./、../、/ 开头的直接按文件系统找 |
| 3 | 自引用(exports) | 包名等于自己时读自身 exports |
| 4 | node_modules 查找 | 逐级向上,按模式的规则读 main / types / exports |
9.1.8 三个真实工程的配置
把上面的规则落到三个具体场景。
场景一:Node 服务,产物直接 node dist/index.js。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true
}
}
源码里所有相对导入都写 .js 后缀,package.json 写 "type": "module"。产物无需任何后处理即可运行。
场景二:Vite 前端应用,产物经打包器处理。
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"strict": true
}
}
noEmit: true 是关键:类型检查与构建彻底分离,构建交给 Vite。想了解打包器侧的依赖预构建机制,可延伸阅读 Vite 依赖预构建
。
场景三:待发布的组件库,需要同时输出 ESM 与 CJS。
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist"
}
}
这里选 NodeNext 而不是 Bundler,因为库的产物要能被别人的 Node 工程消费,类型解析必须按 Node 的真实规则来。配合 exports 的 import / require 双分支,才能做到「谁导入都给对类型」。相关发布策略可延伸阅读 TypeScript SDK 包发布
。
9.1.9 常见报错与排查
TS2307:Cannot find module './utils' or its corresponding type declarations.
先看 moduleResolution。Bundler 下 import "./utils" 合法;NodeNext 下必须写 ./utils.js。如果导入的是第三方包,则检查它是否只写了 exports 而当前模式是 node10。
TS2835:Relative import paths need explicit file extensions...
这是 node16/nodenext 的经典提示,括号里会直接告诉你应该写哪个扩展名。注意补的是 .js,不是 .ts。
TS5095:Option 'bundler' can only be used when 'module' is set to 'preserve' or to 'es2015' or later.
module 与 moduleResolution 不配套。把两者当一对参数改,不要只动一个。
TS1479:The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module...
在 CJS 文件里 import 了一个纯 ESM 包。三种解法:改用动态 import()、把当前文件改成 ESM、或让依赖提供 CJS 分支。
TS7016:Could not find a declaration file for module 'x'.
不是解析模式的问题,而是依赖没带类型且没有 @types/x。临时解法是补一个 .d.ts 声明,正解是给依赖提 PR 或安装社区类型包。
排查顺序建议:
| 步骤 | 命令 | 看什么 |
|---|---|---|
| 1 | npx tsc --showConfig | 确认最终生效的 module / moduleResolution |
| 2 | npx tsc --traceResolution | 看解析器逐步尝试了哪些路径 |
| 3 | node -e "console.log(require.resolve('x'))" | 确认运行时实际解析到哪个文件 |
--traceResolution 会打印海量日志,建议配合 grep 使用。它的价值在于:你能看到解析器「尝试过但失败」的每一个候选路径,从而判断到底是缺文件、缺声明,还是模式不匹配。
小结
本节把模块解析从「黑箱」拆成了可查的规则表。核心结论有四条:其一,解析规则不是 TypeScript 发明的,它只是去模拟目标环境,所以模式的选择取决于产物由谁执行;其二,node10 不认识 exports,node16/nodenext 要求写全 .js 扩展名并会按文件判定 ESM/CJS,bundler 放宽扩展名但保留 exports;其三,module 与 moduleResolution 必须成对配置,bundler 不能搭配 CommonJS;其四,paths 只影响类型检查,必须与运行时别名方案配套,否则会造出「编辑器不报错、运行即崩」的组合。
解析规则确定后,下一个问题自然浮现:当导入命中一个包时,TypeScript 与 Node 究竟从 package.json 的哪些字段里读出「该给哪个文件、哪个类型」?答案就是 exports 的条件映射。下一节 9.2 条件导出与 bundler 语义
会逐条拆解它。如果你想先补齐模块解析在工程组织层面的用法,可延伸阅读 TypeScript 模块解析:ESM 与 CJS
与 Node.js 模块系统与 ESM
。
阅读导航:上一节:8.3 性能剖析与火焰图 · 下一节:9.2 条件导出与 bundler 语义 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。