Node.js 的 `require(esm)` 支持:CommonJS 与 ESM 互操作的最终解决方案

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),支持 importexport,语法更现代、静态分析更友好。

// esm-example.js
import fs from 'fs';
export const hello = 'world';

然而,在很长一段时间里,这两种模块系统是完全隔离的:

  • CJS 不能直接 import ESM;
  • 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'),你会感谢自己今天的决定。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注