工程化实战|模块系统 | 你以为
"type": "module"就完事了?生产环境中 ESM 和 CJS 混用导致的「这个模块明明存在却报错」「require is not defined」「default 导出变了个样」------几乎每个项目都会踩一遍。
问题场景
前阵子在修一个老项目升级的 Bug:项目原本是 CJS(CommonJS)体系,逐步迁移到 ESM。一切按照文档配置好 "type": "module",结果跑起来一堆报错:
javascript
Error [ERR_REQUIRE_ESM]: require() of ES Module xxx from xxx not supported.
更诡异的是,import pkg from 'some-lib' 明明可以正常运行,换另一台机器 clone 下来就跑不通了。调试了半天,发现是 依赖锁文件中混入了不同版本的模块系统......
这坑,你也大概率踩过。
原因分析
ESM 和 CJS 是两套完全不同的模块系统:
| 维度 | CJS(require) | ESM(import) |
|---|---|---|
| 加载时机 | 同步(运行时) | 异步(静态分析) |
| 导出机制 | module.exports 对象引用 |
具名/默认导出,只读绑定 |
顶层的 this |
module.exports |
undefined |
| 文件识别 | .js / .cjs |
.mjs / 包内 "type": "module" |
| 循环依赖 | 可处理(返回部分导出) | 设计上不可靠 |
核心矛盾 :CJS 的 require() 是同步的,但 ESM 的 import 是异步的。Node 不允许 require() 加载一个 ESM 模块------这就是 ERR_REQUIRE_ESM 的根源。
解决方案(含实操代码)
坑 1:require() 加载 ESM 包
最常见的场景:你用 CJS 的项目引用了一个只提供 ESM 的 npm 包。
js
// ❌ 报错:ERR_REQUIRE_ESM
const clipboard = require('clipboardy');
方案 A:动态 import()
js
// ✅ 只在 CJS 环境下有效
async function main() {
const clipboard = await import('clipboardy');
clipboard.writeSync('hello');
}
main();
方案 B:项目整体迁移到 ESM
json
// package.json
{
"type": "module"
}
然后所有 .js 文件自动变为 ESM,require 改为 import。
坑 2:import default 是 undefined
js
// 某个 CJS 模块导出
module.exports = { foo: 'bar' };
// ESM 导入
import mod from './cjs-module.js';
console.log(mod); // ??? 可能是 { default: { foo: 'bar' } } 而不是 { foo: 'bar' }
原因 :CJS module.exports 被 Node 处理成 ESM 的 default 导出------它变成了 { default: { foo: 'bar' } }。
js
// ✅ 正确解法:使用命名导入代替默认导入
import { foo } from './cjs-module.js';
// 或者用 *
import * as mod from './cjs-module.js';
坑 3:TypeScript 编译后模块格式不匹配
json
// tsconfig.json
{
"compilerOptions": {
"module": "ESNext", // 编译输出 ESM
"moduleResolution": "Node" // 但用 Node 解析策略
}
}
后果:TypeScript 编译后产出的 .js 是 import/export,但 moduleResolution: "Node" 不会给文件加 .js 后缀,导致运行时找不到模块。
✅ 正确组合(Node 18+):
json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist"
}
}
此时 TSC 会自动补全 .js 后缀。
坑 4:package.json exports 字段错误配置
json
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js"
}
}
这段配置会导致:ESM 和 CJS 用同一入口,但内部写法可能冲突。
✅ 正确做法------分条件导出:
json
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
坑 5:__dirname 在 ESM 中不可用
js
// CJS 这样用没问题
const path = require('path');
console.log(__dirname); // 当前目录
// ESM 中 ❌ ReferenceError: __dirname is not defined
✅ ESM 替代方案:
js
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
建议抽取成工具函数,省得每次写。
坑 6:JSON 模块导入限制
js
// ESM 默认不允许直接 import JSON
import data from './config.json';
// ❌ 报错:Unknown file extension ".json"
✅ 解法 A:使用 assert/with(Node 17.5+ / 18+)
js
import data from './config.json' with { type: 'json' };
✅ 解法 B:用 fs 读
js
import { readFileSync } from 'fs';
const data = JSON.parse(readFileSync('./config.json', 'utf-8'));
坑 7:.mjs 和 .cjs 后缀的「隐形契约」
很多人不知道:.mjs = 强制 ESM,.cjs = 强制 CJS,不受 package.json 的 "type" 字段影响。
这是解决混用问题的最简单手段:
bash
# 把 ESM 入口写成 .mjs,CJS 入口写成 .cjs
src/
index.mjs # ESM
index.cjs # CJS(拷贝或编译)
index.d.ts # 类型声明
然后在 package.json 中配置:
json
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
这样不管使用者是 CJS 还是 ESM 项目,都能正确加载。
实战检查清单
下次遇到模块系统问题,按这个顺序排查:
bash
# 1. 确认当前文件的模块系统
- .mjs → ESM | .cjs → CJS | .js → 看 package.json 的 "type"
# 2. 检查被导入包的导出方式
- 查看 node_modules/pkg/package.json → "exports"、"type"
# 3. 检查 Node 版本(低于 16 对 ESM 支持很差)
node -v
# 4. 使用 --experimental-xxx 标志了吗?18+ 基本不需要了
# 5. 确认 tsconfig.json 的 module / moduleResolution 配对正确
要点总结
- 最根治的方案 :整个项目统一到 ESM(
"type": "module"),没必要混用 - 被迫混用时 :利用
.mjs/.cjs后缀的强制语义,比跑"type"配置更可靠 - 动态加载 CJS/ESM 桥梁 :
import()函数可以在 CJS 中加载 ESM 模块 - TypeScript 用户务必 :用
"module": "NodeNext"+"moduleResolution": "NodeNext",否则编译产物无法运行 - 工具的
.d.ts声明不受模块系统影响------模块系统问题是运行时问题,和类型提示无关 - 用
exports条件导出 为库的用户提供双格式兼容,这是现代 npm 包的标配