Node.js生态在模块化规范上经历了长期的演进,CommonJS作为早期官方默认标准,支撑了无数庞大的业务系统。随着ES Modules成为JavaScript官方标准,越来越多的新项目倾向于采用ESM以获得更好的静态分析和Tree-shaking能力。然而,在庞大的存量业务中,彻底废弃CJS转向ESM成本极高,这就催生了两者必须长期共存与混合使用的现实需求。理解它们的互操作机制,是保证Node.js服务稳定运行的关键。

底层加载机制与互操作原理
CommonJS采用同步加载策略,通过require()函数引入模块,模块导出通过module.exports实现。这种机制下,模块的加载和执行是紧密耦合的,且支持动态路径解析。而ES Modules采用静态声明方式,所有的import语句必须位于文件顶层,这使得引擎在执行代码前就能构建完整的依赖关系图。
Node.js在处理这两种模块时,使用了不同的解析算法。CJS使用基于文件系统的解析,而ESM使用更严格的URL解析。为了区分它们,Node.js引入了package.json中的type字段。如果不显式声明,Node.js默认将.js文件视为CJS模块。如果将type设置为module,则.js文件会被强制识别为ESM。
// package.json 配置示例
{
"name": "my-hybrid-app",
"version": "1.0.0",
"type": "module", // 指定项目默认使用ES Modules
"exports": {
".": "./src/index.js",
"./utils": "./src/utils.cjs" // 显式指定CJS文件后缀
}
}
这种底层差异决定了它们在互相引用时不能简单直连。CJS的同步特性与ESM的异步特性存在天然鸿沟,Node.js通过内部封装的异步包装器来桥接这两者,但这要求开发者必须遵循特定的API调用规则,否则极易触发ERR_REQUIRE_ESM等致命错误。
在CJS项目中引入ES Modules的实践
在传统的CJS项目中引入ESM依赖是最常见的混合场景。由于require()是同步的,而ESM模块的顶层await和静态加载机制是异步的,直接使用require()加载ESM模块会抛出错误。官方给出的解决方案是使用动态import()函数。
动态import()返回一个Promise,这意味着在CJS环境中使用ESM模块时,必须采用异步处理逻辑。如果ESM模块使用默认导出,那么通过import()得到的对象会有一个default属性指向导出的值。如果是具名导出,则直接作为对象的属性挂载。
// 在CJS文件中引入ESM模块 (app.cjs)
async function loadFeature() {
// 使用动态import()加载ESM模块
const esmModule = await import('./feature.mjs');
// 访问默认导出
const defaultFunc = esmModule.default;
// 访问具名导出
const namedUtil = esmModule.utilFunction;
await defaultFunc();
}
这种异步加载方式虽然解决了兼容问题,但也破坏了CJS原本同步引用的代码流。在大型项目中,如果某个底层CJS工具函数被迫变成异步,会导致调用链上的所有函数都产生异步传染。因此,在架构设计时,应尽量将ESM模块的调用限制在应用的入口层或异步任务调度器中,避免深入底层同步逻辑库。
在ES Modules项目中引入CommonJS的实践
反向情况下,在ESM项目中引入CJS模块相对容易一些,但也存在概念映射的陷阱。当使用import语句引入CJS模块时,Node.js会将整个module.exports对象作为ESM的默认导出。这意味着你只能通过默认导入的方式来获取CJS模块的内容。
一个常见的错误是尝试使用具名导入来解构CJS模块的属性。因为CJS的导出是动态的,在代码执行前引擎无法静态分析出具体的导出名,所以具名导入在标准互操作下会失败或得到undefined。正确的做法是先进行默认导入,再从默认对象中按需取值。
// 在ESM文件中引入CJS模块 (main.mjs)
import cjsLibrary from 'legacy-cjs-lib';
// 错误示范:具名导入可能导致解析失败
// import { specificMethod } from 'legacy-cjs-lib';
// 正确做法:从默认导出对象中获取属性
const specificMethod = cjsLibrary.specificMethod;
export function execute() {
specificMethod();
}
为了缓解这种静态分析失效的问题,社区引入了cjs-module-lexer工具。Node.js内置了该工具来识别常见的CJS导出模式。如果CJS模块使用了如exports.a = 1这种标准模式,Node.js能够识别并允许在ESM中进行具名导入。但对于复杂动态拼接的导出,依然无能为力。因此,编写供ESM调用的CJS库时,应尽量保持导出语句的简单和规范。
混合使用中的常见陷阱与排错策略
混合使用最容易踩坑的领域是路径解析与扩展名省略。在CJS中,require('./config')会自动尝试加载config.js或config.json等文件。但在ESM中,import要求必须提供完整的文件扩展名,即必须写成./config.js。这种严格规定是为了符合浏览器端的标准规范,但在迁移老项目时会导致大量报错。
另一个深坑是循环依赖的处理差异。CJS在遇到循环引用时,会返回当前时刻已经执行部分的exports对象快照,这虽然可能导致未定义错误,但不会阻塞执行。而ESM由于采用深度优先的静态分析,处理循环依赖时行为更加复杂,可能会得到未完全初始化的绑定。混用这两种机制时,循环依赖的边界会变得极其模糊,强烈建议在混合架构中彻底重构消除循环依赖。
最后,打包工具和转译器的配置也会影响混合模块的行为。例如Babel或TypeScript在编译时可能会将ESM降级编译为CJS,这会掩盖Node.js原生的模块解析行为。在排查混合使用报错时,务必先确认代码运行环境是直接由Node.js执行,还是经过了转译层。建议在tsconfig.json或Babel配置中明确module的输出策略,确保最终产物符合Node.js的预期。
Node.jsCommonJSES Modules修改时间:2026-08-23 13:51:21