引言
「Cannot find module」「ERR_REQUIRE_ESM」「require() of ES Module not supported」——这些报错几乎每个 TS/Node 工程师都遇到过。它们的根源是同一件事:JavaScript 有两套模块系统(ESM 与 CJS),而 TypeScript 又有四种模块解析模式,两者的笛卡尔积造成了大量的配置陷阱。
本文从模块解析的历史包袱讲起,覆盖 moduleResolution 的四种模式、exports 条件导出、ESM/CJS 互操作规则、双包危害,最后给出同时发布两种格式的工程实践与报错排查清单。
前置:/typescript-project-architecture-tsconfig/(tsconfig 架构)、/typescript-sdk-package-publishing/(包发布)、/typescript-monorepo-turborepo/(Monorepo)。
目录
- 1. 模块解析的历史包袱
- 2. moduleResolution 的四种模式
- 3. exports 字段与条件导出
- 4. ESM 与 CJS 互操作规则
- 5. 双包问题与 dual package hazard
- 6. package.json 的 type 与扩展名
- 7. 打包器模式:bundler 与解析
- 8. 发布同时支持 ESM 与 CJS
- 9. 常见报错与排查
- 10. 迁移到纯 ESM 的路径
- 延伸阅读
1. 模块解析的历史包袱
1.1 两套模块系统
CommonJS 用 require() / module.exports 同步加载,2009 年随 Node 诞生;ES Modules 用 import / export,静态分析加异步加载,2015 年进标准。Node 长期只支持 CJS,ESM 到 Node 12 才落地、Node 14+ 才稳定,于是出现「同一段代码,不同环境解析方式不同」的分裂。
1.2 三方视角的差异
Node 运行时:看 package.json 的 type 字段与文件扩展名决定格式
TypeScript :看 tsconfig 的 module / moduleResolution 决定如何找类型
打包器 :有自己的解析算法(vite/rollup/webpack 各有差异)
三者规则不完全一致,这正是「编辑器不报错、构建却失败」的根因。
一句话总结:JS 有 ESM 与 CJS 两套模块系统,Node/TypeScript/打包器三方解析规则各不相同——理解差异是排查一切模块报错的前提。
2. moduleResolution 的四种模式
2.1 四种模式对比
| 模式 | 适用场景 | 特点 |
|---|---|---|
| classic | 遗留,几乎不用 | 旧式相对路径查找 |
| node10 | 传统 CJS 项目 | 模拟 Node CJS 解析 |
| node16 / nodenext | 原生 ESM/CJS 项目 | 尊重 exports,区分格式 |
| bundler | vite/esbuild/webpack | 类 bundler 解析,无扩展名 |
2.2 关键配置
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext", // 与 moduleResolution 联动
"moduleResolution": "nodenext" // 或 bundler
}
}
2.3 为什么 bundler 模式流行
bundler 模式允许:import "./foo"(省略扩展名、目录 index 解析)
node16 模式强制:import "./foo.js"(ESM 必须带扩展名)
前端项目用打包器产出,bundler 更贴合直觉;而发布给 Node 直接运行的库,必须用 nodenext 才能验证真实解析行为。
一句话总结:应用/前端用
bundler省心,库/Node 服务用nodenext保真——module与moduleResolution要成对设置。
3. exports 字段与条件导出
3.1 现代包入口
// package.json
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": "./dist/utils.js"
}
}
exports 一旦存在,就封闭了包的可导入路径——未列出的子路径一律不可导入,这既提升封装性也强化了安全。
3.2 条件解析顺序
条件(condition)按声明顺序匹配,命中即止:
types → 类型(必须放最前,否则被忽略)
import → ESM 入口
require → CJS 入口
default → 兜底
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
3.3 常见陷阱
四个高频错误:types 放在 import/require 之后会让 TS 找不到类型;只写 "." 不写 "./package.json" 会让工具读不到 package.json;exports 存在时 main/module 字段被忽略;路径必须以 "./" 开头,否则报 Invalid package target。
一句话总结:
exports用条件映射定义包入口,types必须放最前——它封闭子路径、替代main/module,是现代包分发的核心。
4. ESM 与 CJS 互操作规则
4.1 三条硬规则
1. ESM 可以 import CJS:默认导出 = module.exports
2. CJS 不能 require ESM(Node 20+ 支持 require(esm) 的实验特性除外)
3. ESM 无 __dirname / __filename / require → 用 import.meta.url
4.2 ESM 中拿 __dirname
import { fileURLToPath } from "node:url"
import { dirname } from "node:path"
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
4.3 CJS 中动态 import ESM
// CJS 文件里加载 ESM 包,只能异步
async function load() {
const { render } = await import("some-esm-only-lib")
return render()
}
4.4 互操作的命名导出问题
ESM import CJS 时,Node 用 cjs-module-lexer 静态分析 CJS 的命名导出
动态构造的导出(module.exports[k] = v)可能无法被识别
→ 只能拿到 default,具名导入会 undefined
一句话总结:ESM 能 import CJS、CJS 难 require ESM;ESM 里没有
__dirname要用import.meta.url——CJS 的具名导出依赖静态分析,动态导出会丢失。
5. 双包问题与 dual package hazard
5.1 什么是双包危害
当一个包同时提供 ESM 与 CJS 两份实现,应用里可能同时加载两份,导致:
1. instanceof 失效:两份 class 定义不同
2. 单例失效:模块级状态(缓存、连接池)出现两份
3. 符号/枚举不相等:Symbol() 与枚举比较失败
5.2 危害演示
// 应用同时 require 与 import 了 my-lib
const a = require("my-lib")
const b = await import("my-lib")
a === b.default // false
new a.Client() instanceof b.Client // false
5.3 缓解策略
方案一:只发 ESM(现代项目首选,Node 20+ / 打包器都支持)
方案二:CJS 为主 + ESM 薄封装(ESM 层 re-export CJS)
方案三:状态放到单一来源(如 globalThis 上的符号键)
方案四:用 exports 条件保证同环境只命中一份
方案二(CJS 为核心,ESM wrapper)能保证单例,因为两份实际指向同一 CJS 实例。
一句话总结:双包危害源于 ESM/CJS 两份实现被同时加载,导致
instanceof与单例失效——首选只发 ESM,或让 ESM 层薄封装 CJS。
6. package.json 的 type 与扩展名
6.1 type 字段
{
"type": "module" // .js 视为 ESM
// 或 "type": "commonjs"(默认)→ .js 视为 CJS
}
6.2 扩展名决定一切
.mjs 永远是 ESM、.cjs 永远是 CJS(与 type 无关);.js 由最近的 package.json 的 type 决定;.ts 由 TS 配置与所在包的 type 共同决定。
6.3 配置示例
// ESM 包
{ "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }
// 双格式包
{
"type": "commonjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
一句话总结:
.mjs/.cjs扩展名硬绑定格式,.js由最近的type字段决定——改扩展名前先确认所在包目录的type。
7. 打包器模式:bundler 与解析
7.1 bundler 模式的宽松之处
moduleResolution: "bundler" 允许:
import "./foo" 省略扩展名
import "@/utils" 路径别名(配合 paths)
import "./dir" 目录 index 解析
7.2 与 nodenext 的冲突
// bundler 模式下合法,nodenext 下报错
import { helper } from "./helper" // 缺 .js 扩展名
发布为库时若用 bundler 写源码,下游用 Node 原生运行就会报「Cannot find module」。因此库代码应始终带扩展名。
7.3 一套配置两种用途
// tsconfig.json(应用:bundler)
{ "compilerOptions": { "module": "esnext", "moduleResolution": "bundler" } }
// tsconfig.lib.json(库:nodenext)
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }
一句话总结:
bundler模式宽松、nodenext严格——应用可用 bundler,库必须用 nodenext 验证真实解析,且源码写全扩展名。
8. 发布同时支持 ESM 与 CJS
8.1 用 tsup 一次产出两份
// tsup.config.ts
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
outExtension: ({ format }) => ({ js: format === "esm" ? ".mjs" : ".cjs" }),
})
8.2 对应 package.json
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"files": ["dist"]
}
8.3 类型也要双份
dts: true 只产一份 .d.ts。若两份实现签名不同(如 CJS 用 export =),需分别产出 index.d.mts / index.d.cts,并在 exports 中分条件指向:
{
"exports": {
".": {
"import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}
}
8.4 验证
# 用 arethetypeswrong 检查导出与类型的匹配
npx @arethetypeswrong/cli --pack .
一句话总结:双格式发布 = tsup 产出
.mjs/.cjs+exports分条件指向 + 类型也分.d.mts/.d.cts,最后用arethetypeswrong校验。
9. 常见报错与排查
9.1 报错对照表
| 报错 | 原因 | 修复 |
|---|---|---|
| ERR_REQUIRE_ESM | CJS 里 require 了 ESM 包 | 改用动态 import 或换 CJS 版本 |
| Cannot find module | 扩展名/路径解析失败 | 补 .js 或调 moduleResolution |
| 类型找不到(红波浪线) | exports 缺 types 条件 | 在条件最前加 types |
| ERR_MODULE_NOT_FOUND | ESM 缺扩展名 | import 路径写全 .js |
| 双份单例 | 双包危害 | 只发 ESM 或 ESM 薄封装 CJS |
9.2 排查步骤
排查顺序:先看报错文件是 ESM 还是 CJS(扩展名加最近 type),再查目标包 exports 是否提供对应条件,然后用 node --input-type=module -e "import('pkg')" 复现,用 npx @arethetypeswrong/cli --pack . 查包配置,最后确认 tsconfig 的 module/moduleResolution 成对。
一句话总结:报错排查的顺序是「先定位报错文件格式,再查目标包 exports,再复现与校验」——多数问题出在条件缺失或扩展名省略。
10. 迁移到纯 ESM 的路径
10.1 迁移前评估
阻塞项:依赖里是否有 ESM-only 且只能 require 的包
阻塞项:是否有 __dirname / require.resolve 等 CJS 专属 API
阻塞项:测试框架、构建工具、部署环境是否支持 ESM
10.2 分步迁移
第一步:tsconfig 改 module/moduleResolution 为 nodenext;第二步:源码 import 全部补 .js 扩展名;第三步:__dirname 换成 import.meta.url 推导;第四步:package.json 加 "type": "module",构建产出 .mjs;第五步:CI 用 Node 原生跑一遍,验证无 ERR_* 报错。
10.3 渐进策略
大仓不宜一次性切换:先把「叶子包」迁到 ESM,再逐步向上;CJS 包内部可用动态 import 调用 ESM 依赖,作为过渡。
一句话总结:迁 ESM 的关键是「补扩展名、替换 CJS 专属 API、验证依赖生态」——先迁叶子包,用动态 import 作为 CJS→ESM 的过渡桥。
延伸阅读
- /typescript-project-architecture-tsconfig/ — tsconfig 与模块配置的组织
- /typescript-sdk-package-publishing/ — 包发布与 exports 维护
- /typescript-monorepo-turborepo/ — Monorepo 下的模块解析
- /typescript-major-version-upgrade/ — 版本升级与破坏性变更
- /typescript-build-performance-optimization/ — 构建性能与打包配置
- TypeScript 专题 — TypeScript 专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。