Node.js ESM 和 CJS 混用怎么迁移
javascript // CJS 导出 module.exports = function add(a, b) { return a + b; }; exports.add = (a, b) => a + b;
// CJS 导入 const add = require('./add'); const { add } = require('./math');
```javascript
// ESM 导出
export default function add(a, b) { return a + b; }
export const add = (a, b) => a + b;
// ESM 导入
import add from './add.js'; // 注意扩展名!
import { add } from './math.js';
import * as math from './math.js';
文件扩展名与 package.json
- CJS 文件通常使用
.js;ESM 推荐使用.mjs,或者在package.json中设置"type": "module"可将.js视为 ESM。 - 若目录下有
package.json且"type": "module",则该目录中.js文件按 ESM 解析;若需写 CJS,必须使用.cjs扩展名。 - 没有
"type"字段时,默认是"commonjs"。
关键限制速览
| 操作 | CJS 中可执行 | ESM 中可执行 | 备注 |
|---|---|---|---|
require() ESM |
❌ | - | 报错:ERR_REQUIRE_ESM |
import CJS |
✅ (异步包装) | - | 只能引入 CJS 默认导出(module.exports),命名导出需特殊处理 |
require() CJS |
✅ | ✅(通过 createRequire) |
ESM 内需要借助 module.createRequire |
import() 动态导入 |
✅ | ✅ | 两者均支持动态 ESM 导入,返回 Promise |
混用现状诊断:你的项目到底有多痛?
在动手前,先搞清楚项目里 CJS 和 ESM 的分布:
- 检查入口文件和配置:查看
package.json中"main"或"exports"指向的文件模块类型。检查自身源码是require还是import为主。 - 依赖分析:运行
npm ls --depth=0,然后检查第三方包的package.json的"type"或"module"字段。很多现代包(如node-fetch3.x、chalk5.x)已纯 ESM。 - 测试互操作限制:写一个临时脚本,尝试用
require引入你怀疑的 ESM 包,或用import引入一个深层 CJS 模块,看是否报错。
典型痛点场景:
- 你想使用某个纯 ESM 库,但你的项目是 CJS,无法直接
require。 - 你的项目改造为 ESM 后,Jest(默认 CJS)不能直接运行,需要配置。
- 构建工具(Webpack、Rollup)配置混乱,因为入口文件既有
import又有require。
迁移方案一:双模块格式兼容(渐进式过渡)
适用场景:项目团队逐步迁移,新旧代码需要共存较长时间,或需要同时提供 CJS 和 ESM 出口给下游使用。
核心技巧:利用动态导入和 createRequire 架起桥梁
1. 在 CJS 中加载 ESM 模块
因为 CJS 无法使用静态 import,只能通过动态 import() 异步加载 ESM。
// cjs-module.js
(async () => {
const { default: chalk } = await import('chalk'); // chalk 5.x 是纯 ESM
console.log(chalk.green('Hello from CJS using ESM!'));
})();
如果需要在顶层同步获取,可以将逻辑包裹在异步函数中,但无法完全同步。对于顶层导出依赖 ESM 的情况,这会改变模块的导出方式(变为异步),需要调整上层调用。
2. 在 ESM 中加载 CJS 模块
ESM 里有两种方法:
- 默认导入:
import pkg from 'cjs-package'会获取module.exports整个对象。如果想模拟 CJS 的命名导出,需手动解构。 - 命名导入(Node.js 22+ 或通过静态分析):Node.js 14 起支持从 CJS 中导入“一些命名导出”,但需注意这些是动态计算的,且容易出错。更稳妥的仍然是默认导入或使用
createRequire。
使用 createRequire 在 ESM 中制造一个 require 函数:
// esm-file.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { add } = require('./cjs-math.cjs');
console.log(add(2, 3));
这种方法完全保留 CJS 的行为,同步且可靠,非常适合在 ESM 文件中大范围使用仍为 CJS 的工具库。
3. 配置 package.json 的 exports 字段实现双格式入口
如果你开发的包要同时支持 CJS 和 ESM 消费者,可以这样配置:
{
"name": "my-package",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}
}
注意需提供真实的不同格式文件,或使用条件导出指向同一文件但通过构建工具区分。常见做法是用打包器(如 Rollup、esbuild)从同一源码生成 index.cjs 和 index.mjs。
迁移方案二:彻底单向迁移到 ESM(推荐长期策略)
如果你有条件做一次彻底升级,将项目全部转为 ESM 是更洁净的方式。以下是标准步骤。
第一步:全局启用 "type": "module"
在根 package.json 中添加:
{
"type": "module"
}
此后,所有 .js 文件默认解析为 ESM。对于必须保留的 CJS 文件,重命名为 .cjs 或将它们放在拥有自己 package.json 且 "type": "commonjs" 的子目录中。
第二步:批处理替换语法
编写脚本或使用 codemod 工具自动转换大部分代码:
const X = require('X')→import X from 'X'const { a, b } = require('X')→import { a, b } from 'X'(需确认 CJS 包支持命名导出,否则用import X from 'X'; const { a, b } = X;)module.exports =→export defaultexports.foo =→export const foo =__filename→import.meta.url配合fileURLToPath__dirname→path.dirname(fileURLToPath(import.meta.url))
手动处理动态 require 场景(使用条件、循环加载等),改为 import() 动态导入并处理 Promise。
第三步:处理全局变量差异
CJS 提供 __dirname、__filename、require、module、exports 等全局量,ESM 中没有。常用替换:
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
如果有深层依赖仍需要 require,可以使用前面提到的 createRequire。
第四步:调整测试框架
- Jest:需要配置
transform或直接使用jest.config.js为 ESM(jest.config.mjs),并开启--experimental-vm-modules。推荐使用 Vitest 这种原生 ESM 友好的测试框架,迁移成本极低。 - Mocha:使用
.mocharc.cjs或配置"node-option"包含--experimental-require-module等。或直接用 mocha 的 ESM 实验支持。
第五步:脚本和工具链适配
package.json中的"scripts"可以保持不变,Node.js 会根据"type"解析.js。- 构建工具(Webpack、Rollup、tsup 等)需要确认入口文件解析正确:最好显式指定入口扩展名为
.mjs或使用"type":"module"。 - ESLint:更新配置,添加
parserOptions: { sourceType: "module" },环境设置es2022: true。
第六步:逐文件验证
使用 node --check 对每个 ESM 文件进行语法检查。然后通过单元测试覆盖全部的导入导出路径。特别关注之前使用了动态 require 和循环依赖的部分。
常见陷阱与解决方案
1. 无扩展名导入报错
现象:Cannot find module '/path/to/file'
原因:ESM 要求模块标识符必须完全指定,包括 .js、.mjs 等扩展名(导入目录下的 index.js 也需要完整路径)。
解决:
// 错误
import foo from './foo';
// 正确
import foo from './foo.js';
2. CJS 命名导入不稳定
现象:import { writeFile } from 'fs' 在 ESM 中可以使用,但 import { someUtil } from 'a-cjs-pkg' 可能为 undefined。
原因:Node.js 只会静态分析 CJS 包的 exports 对象中可静态检测到的属性。复杂的动态导出无法被检测。
解决:回退到默认导入再解构,或者要求包的作者提供 ESM 封装。
3. 循环依赖导致的死锁
ESM 的静态结构能更好地处理循环依赖问题,但混用期间如果 CJS 和 ESM 互相循环引用,可能导致 ReferenceError 或 undefined。
解决:重构代码打破循环,或确保循环引用仅发生在同一个模块系统内部。迁移时先从叶子模块开始,逐步向上。
4. __dirname 缺失导致路径错误
使用 fileURLToPath + import.meta.url 替换后,务必保证路径运算的一致性。考虑统一使用 URL 对象来拼接路径:
const dir = new URL('.', import.meta.url);
const file = new URL('./data.json', import.meta.url);