Node.js 的 require(esm) 支持:CommonJS 与 ESM 互操作的最终解决方案(讲座模式)
各位同学、开发者朋友们,大家好!
今天我们来深入探讨一个在 Node.js 开发中非常关键且常被误解的话题:如何让 CommonJS 和 ES Modules(ESM)之间实现无缝互操作?
这个问题困扰了无数开发者多年。直到最近几年,Node.js 官方终于给出了一个真正可行的方案——通过内置的 require('esm') 模块,我们可以优雅地在 CommonJS 中导入 ESM 模块,反之亦然。这不仅是语法层面的进步,更是生态演进的重要一步。
一、背景:为什么我们需要“互操作”?
1.1 Node.js 的双轨制历史
从 Node.js 诞生之初,它就默认使用 CommonJS(CJS)作为模块系统。比如:
// commonjs-example.js
const fs = require('fs');
module.exports = { hello: 'world' };
但随着 ECMAScript 标准的发展,ES6 引入了 ES Modules(ESM),支持 import 和 export,语法更现代、静态分析更友好。
// esm-example.js
import fs from 'fs';
export const hello = 'world';
然而,在很长一段时间里,这两种模块系统是完全隔离的:
- CJS 不能直接
importESM; - ESM 不能直接
require()CJS; - 如果你尝试混用,会遇到各种报错,如:
SyntaxError: Cannot use import statement outside a module
这就导致了一个尴尬的局面:
👉 你想用最新的 ESM 特性(如动态导入、顶层 await),却不得不保留大量旧的 CJS 代码;
👉 或者反过来,你有一个庞大的 CJS 生态项目,无法轻易迁移到 ESM。
这就是为什么我们说,“互操作”不是锦上添花,而是刚需。
二、早期解决方案及其局限性
2.1 使用 .mjs 文件扩展名(不推荐)
最早的做法是给 ESM 文件加 .mjs 扩展名,这样 Node.js 就知道这是 ESM,不会混淆。
// utils.mjs
export function greet(name) {
return `Hello, ${name}!`;
}
然后在 CJS 中可以用 import() 动态加载:
// index.js (CJS)
const { greet } = await import('./utils.mjs');
console.log(greet('Alice'));
✅ 好处:简单粗暴,避免冲突。
❌ 缺点:
- 需要改文件后缀,破坏统一性;
- 不适合已有项目迁移;
- 多数工具链对
.mjs支持不够完善。
2.2 使用 --experimental-modules(实验阶段)
Node.js 曾提供过实验性的 ESM 支持,但只能通过命令行参数启用,而且兼容性差,不适合生产环境。
node --experimental-modules index.js
这就像开着一辆没保险的跑车上路,虽然酷炫,但风险太高。
三、真正的答案:require('esm') 是什么?
3.1 它是谁?怎么来的?
require('esm') 并不是一个第三方库,而是 Node.js 自带的一个核心模块(自 v14.13.0 起可用)。它的作用就是:
允许你在 CommonJS 环境下安全地加载和执行 ESM 模块,同时保持 ESM 的完整语义。
换句话说,它是官方为了解决“CJS ↔ ESM”互操作问题而设计的一套机制。
✅ 关键特性:
| 特性 | 描述 |
|---|---|
| 透明桥接 | CJS 可以 require('esm') 加载 ESM,无需修改源码 |
| 原生支持 | 不依赖第三方包(如 esm npm 包) |
| 静态分析兼容 | 工具链(如 ESLint、Webpack)能正确识别模块类型 |
| 无副作用 | 不影响全局模块缓存或执行顺序 |
3.2 如何使用?举个例子!
假设你有一个 ESM 模块:
// math-utils.js (ESM)
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
现在你想在 CommonJS 中使用它:
// index.js (CJS)
const esm = require('esm')(module);
const { add, multiply } = esm('./math-utils.js');
console.log(add(5, 3)); // 8
console.log(multiply(5, 3)); // 15
是不是很简单?你只需要一行 require('esm')(module),就能把 ESM 当成普通模块来用!
💡 注意:这里的
module参数很重要,它是当前模块的上下文对象,用于正确处理模块缓存和路径解析。
四、更复杂的场景:双向互操作实战
4.1 在 ESM 中导入 CJS 模块
// main.js (ESM)
import esm from 'esm';
const cjsModule = esm('./cjs-module.js');
console.log(cjsModule.default); // 输出 CJS 的 exports.default
对应的 CJS 文件:
// cjs-module.js
module.exports = {
name: 'My CJS Module',
version: '1.0.0'
};
这里的关键在于:esm() 返回的是一个包装后的模块对象,包含 .default 属性指向原始导出。
4.2 动态导入 ESM(推荐方式)
如果你只想在特定逻辑中加载 ESM,可以使用动态导入:
// index.js (CJS)
async function loadFeature() {
const esm = require('esm')(module);
const feature = await esm('./feature.js');
return feature;
}
loadFeature().then(result => {
console.log(result);
});
这种写法特别适合按需加载,提升启动性能。
五、常见误区与陷阱(务必避开!)
| 误区 | 正确做法 | 原因说明 |
|---|---|---|
❌ 直接 require('esm') 不传 module 参数 |
必须传 require('esm')(module) |
否则模块缓存混乱,可能重复加载或找不到模块 |
❌ 把 esm 当作全局变量 |
应该只在需要时引入 | 避免污染全局命名空间,尤其是 SSR 场景 |
❌ 在 ESM 中用 require() 导入 CJS |
使用 import() 或 esm() |
ESM 中不能直接调用 require(),除非用 esm() 包装 |
❌ 认为 esm 会影响原模块行为 |
它只是代理,不影响原始代码逻辑 | 本质是运行时桥接,非编译期转换 |
示例:错误用法 vs 正确用法
❌ 错误示例(会导致内存泄漏或模块失效):
// index.js
const esm = require('esm'); // ❌ 忘记传 module 参数
const mod = esm('./some-esm.js');
✅ 正确示例:
// index.js
const esm = require('esm')(module); // ✅ 传入 module 上下文
const mod = esm('./some-esm.js');
六、最佳实践建议(适用于任何项目)
| 场景 | 推荐做法 |
|---|---|
| 新项目起步 | 直接使用 ESM(.js 文件设 "type": "module") |
| 旧项目迁移 | 逐步替换,先用 require('esm')(module) 引入 ESM |
| 第三方库混合 | 对于纯 CJS 库,继续用 require();对于 ESM 库,用 esm() 包装 |
| 构建工具集成 | Webpack/Vite 等现代构建器已原生支持,无需额外配置 |
| 测试环境 | Jest 默认支持 ESM,只需设置 testEnvironment: 'node' 即可 |
🧪 示例:测试文件中的互操作
// test/utils.test.js (CJS)
const esm = require('esm')(module);
const { add } = esm('../src/math-utils.js');
describe('add', () => {
it('should add two numbers', () => {
expect(add(2, 3)).toBe(5);
});
});
七、未来展望:Node.js 的模块生态系统正在统一
目前 Node.js 已经在向单一模块系统迈进:
- Node.js v14+ 默认支持 ESM(只要指定
"type": "module"); require('esm')成为过渡期的标准工具;- V8 引擎优化了 ESM 的加载速度;
- 工具链(Babel、TypeScript、Jest)全面拥抱 ESM。
这意味着,未来几年内,所有新项目都应优先采用 ESM,而旧项目可以通过 require('esm') 渐进式改造。
八、总结:这不是妥协,而是进化
很多人一开始觉得 require('esm') 是“权宜之计”,但实际上它是 Node.js 生态走向成熟的关键一步。
它不是让你永远停留在“CJS 和 ESM 共存”的状态,而是为你提供一条清晰、稳定、可预测的迁移路径。
📌 总结一句话:
“互操作”不是终点,而是通向统一模块系统的桥梁。
无论你现在是用 CJS 还是 ESM,只要记住这个技巧:
const esm = require('esm')(module);
你就拥有了连接两个世界的钥匙。
希望今天的分享对你有帮助!
如果你还在纠结要不要迁移到 ESM,请记住:
现在就开始用 require('esm'),你会感谢自己今天的决定。
谢谢大家!