在 Stack Overflow 2025 年度开发者调查中,TypeScript 的模块解析错误(Cannot find module)连续第三年位列"最令人沮丧的错误"前三名。超过 67% 的 TypeScript 开发者表示曾在 ESM 与 CJS 的模块系统切换中浪费超过 8 小时排查问题。如果你也曾被 "type": "module" 和 "moduleResolution": "bundler" 的组合搞得焦头烂额,这篇文章将彻底终结你的困惑。
🔧 一、JavaScript 模块系统的演进与核心差异
1.1 从 IIFE 到 ESM:二十年的模块化之路
JavaScript 的模块化历程堪称一部"补丁史"。2009 年 CommonJS 随 Node.js 诞生,用 require() 和 module.exports 解决了服务端的模块化问题。同年 AMD(Asynchronous Module Definition)用 define() 解决了浏览器端的异步加载。2015 年,ES2015 正式引入 ESM(ECMAScript Modules),用 import / export 语法统一了前端和后端的模块标准。
但现实是残酷的。截至 2026 年,npm 上仍有约 38% 的包只提供 CommonJS 格式,而 Node.js 生态中 ESM 的采用率刚过 50%。这意味着你无法逃避 CJS 与 ESM 共存的现实。
1.2 CJS 与 ESM 的五大核心差异
理解 CJS 和 ESM 的差异是掌握模块解析的前提。很多开发者只知道语法不同,却忽略了底层机制的本质区别:
| 特性 | CommonJS (CJS) | ESM |
|---|---|---|
| 加载方式 | 运行时同步加载 | 编译时静态分析 + 异步加载 |
| 语法 | require() / module.exports |
import / export |
| 值的绑定 | 值的拷贝(copy) | 值的引用(live binding) |
| 循环依赖 | 返回部分完成的对象 | 通过 TDZ 处理,可能得到 undefined |
顶层 this |
module.exports(即当前模块对象) |
undefined |
| 动态导入 | 原生支持(require() 可在任意位置调用) |
需使用 import() 表达式 |
⚠️ **警告:**ESM 的
import语句必须在文件顶层使用(静态声明),不能在if、try等块级作用域中调用。如果需要条件导入,必须使用import()动态表达式。这是一个极其常见的错误来源。
一个容易被忽视的关键差异是值的绑定方式。CJS 导出的是值的拷贝,而 ESM 导出的是值的活引用(live binding)。看下面的对比:
❌ CJS 的值拷贝行为(容易踩坑)
// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// main.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0 — count 是值的拷贝,不会随 increment() 改变!
✅ ESM 的活引用行为(符合直觉)
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1 — count 是活引用,始终指向最新值
💡 **提示:**这个差异在实现响应式状态管理、计数器、缓存等场景时至关重要。如果你从 CJS 迁移到 ESM,务必检查所有依赖"导出值不会变"假设的代码。
1.3 文件扩展名与 MIME Type 的隐形陷阱
在 Node.js 中,.js 文件到底被当作 CJS 还是 ESM 处理,取决于两个因素:文件扩展名和最近的 package.json 中的 "type" 字段。
- 没有
"type"字段(或"type": "commonjs"):.js= CJS,.mjs= ESM "type": "module":.js= ESM,.cjs= CJS
// package.json — 启用 ESM
{
"type": "module",
"engines": { "node": ">=18" }
}
📌 **记住:**如果你在
"type": "module"的项目中写了一个.js文件并使用require(),Node.js 会直接报错:ReferenceError: require is not defined in ES module scope。这是最常见的 ESM 迁移错误之一。
🎯 二、TypeScript 模块解析的四种策略
2.1 module 与 moduleResolution 的关系
TypeScript 的模块解析由 tsconfig.json 中的两个关键字段控制:
module:决定 TypeScript 编译输出的模块格式(CJS / ESM / AMD / UMD 等)moduleResolution:决定 TypeScript 如何查找模块(即import './foo'到底对应哪个文件)
这两个字段必须搭配使用,错误的组合会导致编译通过但运行时报错,或者编译时就报 Cannot find module。
以下是 TypeScript 5.x+ 支持的四种 moduleResolution 策略及其适用场景:
| moduleResolution | 适用场景 | 特点 |
|---|---|---|
"classic" |
历史项目、AMD/UMD | 已废弃,不要使用 |
"node" |
CJS 项目(Node.js 10-15) | 模拟 Node.js CJS 解析逻辑 |
"node16" / "nodenext" |
现代 Node.js 项目(16+) | 支持 ESM + CJS 双模式,严格遵循 Node.js 解析规则 |
"bundler" |
使用打包工具的项目(Vite/Webpack/Rspack) | 更宽松的解析规则,不要求文件扩展名 |
⚠️ 警告:
"moduleResolution": "node"虽然名字里有 “node”,但它模拟的是 Node.js CJS 的解析逻辑,不支持 ESM 的exports字段映射。如果你使用"node"但代码中有 ESM 的import,可能会遇到解析错误。
2.2 Bundler 模式 vs Node16/NodeNext 模式
这是 2024-2026 年 TypeScript 开发者最常遇到的选择题。两种模式的核心差异在于对文件扩展名的要求和对 exports 字段的支持。
Bundler 模式(适合 Vite / Webpack / Rspack 用户):
// tsconfig.json — Bundler 模式
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"jsx": "react-jsx",
"esModuleInterop": true,
"allowImportingTsExtensions": true
}
}
Bundler 模式的优势是宽松——你不需要在 import 路径中写文件扩展名,可以导入 .ts 文件(通过 allowImportingTsExtensions),不需要 exports 映射。因为打包工具(Vite、Webpack 等)会自行处理模块解析。
Node16/NodeNext 模式(适合纯 Node.js 项目):
// tsconfig.json — Node16 模式
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2022",
"esModuleInterop": true,
"declaration": true,
"outDir": "./dist"
}
}
Node16 模式严格遵循 Node.js 的 ESM 解析规则:import 路径必须包含文件扩展名(.js,不是 .ts),必须正确配置 package.json 的 exports 字段。
// ❌ Node16 模式下会报错 —— 缺少扩展名
import { formatDate } from './utils'
// ✅ Node16 模式下正确 —— 使用 .js 扩展名(即使源文件是 .ts)
import { formatDate } from './utils.js'
💡 **提示:**在 Node16 模式下,import 路径写
.js而不是.ts,因为 TypeScript 编译后.ts会变成.js。TypeScript 编译器会自动将.js映射到对应的.ts源文件进行类型检查。
⚡ **关键结论:**如果你的项目使用 Vite/Webpack 等打包工具,选择 "bundler" 模式;如果你在写纯 Node.js 库或 CLI 工具,选择 "node16" 模式。不要在打包项目中使用 "node16",否则你需要在所有 import 路径中加 .js 扩展名。
2.3 Cannot find module 终极排查方案
Cannot find module 是 TypeScript 开发者最常遇到的错误之一。以下是系统化的排查流程:
第一步:检查 moduleResolution 与依赖的匹配度
# 检查目标包是否支持当前 moduleResolution 模式
# 方法:查看依赖的 package.json 中是否有 exports 字段
cat node_modules/some-package/package.json | grep -A 20 '"exports"'
如果依赖包没有 exports 字段,而你使用了 "node16" 或 "nodenext",可能会出现解析失败。解决方案是在 tsconfig.json 中添加 paths 映射:
// tsconfig.json — 为缺少 exports 的包添加路径映射
{
"compilerOptions": {
"paths": {
"some-package": ["./node_modules/some-package/dist/index.d.ts"]
}
}
}
第二步:检查类型声明文件是否存在
# 检查包是否自带类型声明
ls node_modules/some-package/dist/*.d.ts 2>/dev/null
# 检查是否有 @types 包
ls node_modules/@types/some-package 2>/dev/null
如果既没有内置类型也没有 @types 包,创建一个声明文件:
// src/types/some-package.d.ts
declare module 'some-package' {
export function doSomething(input: string): number
export default class SomeClass {
constructor(options: Record<string, unknown>)
run(): Promise<void>
}
}
第三步:检查 include 和 exclude 配置
// tsconfig.json — 确保目标文件在 include 范围内
{
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}
📌 记住:
paths映射只影响 TypeScript 编译器的类型检查,不影响运行时的模块解析。如果你使用paths做了映射,运行时还需要配合打包工具的alias或 Node.js 的--import选项。
🚀 三、CJS 到 ESM 的渐进式迁移实战
3.1 评估迁移成本与制定策略
在开始迁移之前,你需要评估项目的迁移难度。以下是三个关键评估维度:
依赖兼容性检查——这是最重要的评估维度。使用以下命令检查你的依赖是否支持 ESM:
# 检查所有直接依赖是否支持 ESM
# 方法 1:查看 package.json 的 exports 或 type 字段
for pkg in $(cat package.json | grep -oP '"(?!=name|version|dev|peer)[^"]+":' | tr -d '":{'); do
if [ -f "node_modules/$pkg/package.json" ]; then
type=$(grep '"type"' "node_modules/$pkg/package.json" | head -1)
exports=$(grep '"exports"' "node_modules/$pkg/package.json" | head -1)
echo "$pkg: type=$type | exports=$exports"
fi
done
迁移策略选择——根据项目规模,选择合适的迁移策略:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 一步到位 | 新项目、小型项目 | 彻底干净 | 风险高,可能破坏依赖 |
| 渐进式迁移(文件级) | 中型项目 | 风险可控 | 需要维护两套模块系统 |
| 双包发布(Dual Package) | 开源库 | 同时支持 CJS 和 ESM 消费者 | 构建复杂度高 |
| Wrapper 模式 | 大型项目 | 对现有代码零侵入 | 有额外的维护成本 |
3.2 双包发布(Dual Package)方案
如果你是库作者,最推荐的方式是同时发布 CJS 和 ESM 格式。这样无论消费者使用哪种模块系统,都能正常工作。
// package.json — 双包发布配置
{
"name": "my-library",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"]
}
使用 tsup 构建双包:
// tsup.config.ts — 构建 ESM + CJS 双格式
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true, // 同时生成 .d.ts 和 .d.cts
clean: true, // 清理旧的 dist 目录
splitting: true, // 代码分割(ESM 专用)
sourcemap: true, // 生成 source map
treeshake: true, // 摇树优化
outExtension({ format }) {
return {
js: format === 'esm' ? '.js' : '.cjs'
}
}
})
⚠️ **警告:**双包发布有一个著名的"Dual Package Hazard"问题——如果同一个进程同时通过
import和require加载了同一个包,会产生两个独立的实例,导致instanceof检查失败、单例模式失效等问题。解决方案是使用package.json的exports字段(而不是main+module),确保 Node.js 只解析到一个版本。
3.3 渐进式迁移 Checklist
对于中大型项目的渐进式迁移,以下是经过实战验证的步骤清单:
阶段一:准备工作(1-2 天)
- ✅ 升级 Node.js 到 18+(原生支持 ESM)
- ✅ 升级 TypeScript 到 5.x+(支持
moduleResolution: "node16") - ✅ 运行
npx are-the-types-wrong --pack检查依赖的类型兼容性 - ✅ 创建一个 ESM 分支,设置
"type": "module"和"module": "Node16"
阶段二:逐文件迁移(核心工作)
- ✅ 将
require()改为import(使用import { x } from './y.js') - ✅ 将
module.exports改为export - ✅ 将
__dirname/__filename替换为 ESM 等效写法 - ✅ 将 JSON
require替换为fs.readFileSync+JSON.parse或importwith assertion - ✅ 将动态
require()替换为import()
ESM 中替换 __dirname 和 __filename 的标准写法:
// ❌ CJS 写法(在 ESM 中不可用)
const __dirname = __dirname
const __filename = __filename
// ✅ ESM 标准写法
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// 使用示例
const configPath = join(__dirname, '../config/default.json')
阶段三:验证与回归测试
- ✅ 运行完整测试套件,确保所有测试通过
- ✅ 测试动态导入
import()是否正常工作 - ✅ 测试第三方依赖的导入是否正常
- ✅ 测试构建产物的格式是否正确(
.js= ESM,.cjs= CJS) - ✅ 使用
npx publint检查包的发布兼容性
💡 **提示:**迁移过程中最常遇到的问题是第三方库不支持 ESM。对于这类依赖,可以通过创建一个 CJS wrapper 文件来桥接:
// lib/third-party-wrapper.cjs — 桥接不支持 ESM 的第三方库
const legacyLib = require('legacy-cjs-package')
module.exports = legacyLib
// src/app.ts — 在 ESM 代码中通过 wrapper 导入
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const legacyLib = require('legacy-cjs-package')
💡 四、最佳实践与未来趋势
4.1 三种场景的推荐配置
根据项目类型,以下是经过社区验证的最佳配置方案:
场景一:Vite + React/Vue 前端项目
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
场景二:Node.js 后端项目(CLI 工具 / API 服务)
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
场景三:开源库(需要支持多种消费者)
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2020",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist"
}
}
4.2 2026 年的模块系统趋势
⚡ **关键结论:**ESM 已经是不可逆转的趋势。Node.js 22+ 的 --experimental-require-module 允许在 CJS 中 require() ESM 模块,极大地降低了迁移门槛。TypeScript Go (tsgo) 的原生编译器也默认采用 ESM 优先策略。
以下是几个值得关注的趋势:
- ✅ Node.js
require(esm)稳定化——Node.js 22 已经实验性支持在 CJS 中 require ESM 模块,预计 2026 年下半年稳定。这将大幅降低 CJS 到 ESM 的迁移成本。 - ✅ Bun 和 Deno 默认 ESM——Bun 和 Deno 2 都默认使用 ESM,CJS 支持只是兼容层。新项目应优先选择 ESM。
- ✅ TypeScript tsgo 编译器——TypeScript 的 Go 重写版本(tsgo)在模块解析上做了重大优化,编译速度提升 10 倍以上,同时对 ESM 的支持更加完善。
- ❌ 不要在新项目中使用
moduleResolution: "node"——这个选项模拟的是旧版 Node.js CJS 的解析逻辑,不支持exports字段映射,已不推荐使用。
4.3 常见错误速查表
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Cannot find module 'xxx' |
moduleResolution 不匹配或缺少类型声明 | 检查 tsconfig 配置和 @types 包 |
require is not defined in ES module scope |
在 ESM 文件中使用了 require() |
改用 import 或 createRequire() |
ERR_MODULE_NOT_FOUND |
Node.js ESM 解析失败,缺少文件扩展名 | 在 import 路径中添加 .js 扩展名 |
ERR_REQUIRE_ESM |
尝试用 require() 加载 ESM-only 包 |
改用 import() 动态导入或降级依赖版本 |
SyntaxError: Cannot use import statement |
CJS 环境中加载了 ESM 代码 | 检查 package.json 的 type 字段 |
📝 总结
TypeScript 的模块解析看似复杂,但核心逻辑只有两条:
module决定输出格式(编译后的代码是 CJS 还是 ESM)moduleResolution决定查找策略(TypeScript 如何找到你 import 的模块)
对于新项目,直接选择 ESM + "moduleResolution": "bundler"(前端项目)或 "Node16"(Node.js 项目),避免使用已废弃的 "classic" 和即将被淘汰的 "node" 模式。
对于存量项目,采用渐进式迁移策略——先在 ESM 分支中验证依赖兼容性,逐文件替换 require / module.exports,最后通过双包发布(Dual Package)方案确保向后兼容。
- 🔧 arethetypeswrong.dev — 检查 npm 包的类型和模块格式是否正确
- 🔧 publint — 检查包的发布兼容性
- 🔧 tsup — 零配置的 TypeScript 库构建工具,支持双包发布
- 🔧 arethetypeswrong.github.io — 在线检查包的模块格式问题
- 📖 Node.js ESM 文档 — 官方 ESM 文档