《TypeScript高级编程》9.1 moduleResolution 各模式对照

本节把 TypeScript 的模块解析讲成一张对照表:先说明编译器为何要自己实现解析规则,再拆解 classic、node10、node16、nodenext、bundler 五种模式在扩展名与条件导出上的差异,再给出 module 与 moduleResolution 的合法组合,最后用三种典型工程配置收束。读完你能为任意项目选对模式,并看懂 Cannot find module 报错。

本节目标:把「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/SystemJSTypeScript 自创(classic)可省略支持
Node CommonJSrequire.resolve 算法可省略支持 index.js
Node ESMNode 的 ESM 解析算法必须写全不支持
打包器(Vite/esbuild)各自的解析器可省略支持

TypeScript 的目标是:在你写代码的这一刻,就用目标环境的规则去检查导入是否有效。 所以它没法给出一个「通用正确」的答案,只能提供多个模式让你挑。挑错的代价不是编辑器里飘红,而是产物在运行时崩溃——这类问题极难定位,因为编译期一切正常。

9.1.2 五种模式总览

moduleResolution 目前有五个可选值,其中 node 是 node10 的旧别名:

模式引入版本读 exports扩展名要求典型场景
classic1.0否可省略已废弃,仅历史遗留
node10(旧名 node)1.0否可省略发布 CJS 的老库
node164.7是必须写全Node 16+ 的双模式包
nodenext4.7是必须写全跟随最新 Node 语义
bundler5.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 / nodenextbundler
相对导入扩展名必须写全可省略
目录 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 决定导入按什么规则找文件。常见的合法组合如下:

场景modulemoduleResolution说明
打包器构建ESNext / PreserveBundler扩展名可省,交给打包器
Node ESM 服务NodeNextNodeNext相对导入写 .js,最严格
Node 双模式库NodeNextNodeNext由 package.json 的 type 决定
纯 CJS 老库CommonJSNode10仅在必须发 CJS 时使用
保留 import/export 语法PreserveBundler交给上层工具处理

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 字段(# 前缀的子路径导入)或打包器插件。只配一半,就会得到「编辑器不报错、运行时崩」的最糟组合。

解析优先级可以简化为一条链:

顺序规则说明
1paths 映射命中即替换,再继续后续步骤
2相对 / 绝对路径./、../、/ 开头的直接按文件系统找
3自引用(exports)包名等于自己时读自身 exports
4node_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 或安装社区类型包。

排查顺序建议:

步骤命令看什么
1npx tsc --showConfig确认最终生效的 module / moduleResolution
2npx tsc --traceResolution看解析器逐步尝试了哪些路径
3node -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 语义 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes