CommonJS允许循环引用并返回未完成的exports对象,ESM则因静态绑定在访问未初始化导出时抛出ReferenceError;二者差异源于运行时加载与静态分析、值拷贝与活绑定的本质区别。

JavaScript中循环引用在CommonJS和ESM下表现截然不同:CommonJS允许“不完整”的模块对象被导出和导入,而ESM要求模块图静态可解析,禁止运行时才暴露未定义的导出,导致循环引用时行为更严格、更易暴露问题。
CommonJS中的循环引用:支持但返回空对象
CommonJS通过module.exports和require()实现模块加载,采用同步、动态、运行时解析机制。当发生循环引用(如 A.js require B.js,B.js 又 require A.js),Node.js 会立即返回 A.js 当前已初始化的exports对象(可能是空对象或部分填充的对象),而非等待 A.js 执行完毕。
- A.js 导出一个函数后,再 require B.js
- B.js 在 require A.js 时,拿到的是 A.js 此刻的 exports(比如
{}或{ foo: undefined }) - 后续 A.js 继续执行并赋值
exports.bar = 'ok',B.js 无法自动感知该更新(除非显式重新读取)
ESM中的循环引用:静态绑定 + undefined 占位符
ESM 模块在编译阶段就确定所有导入/导出关系,使用import/export语法,导出绑定是实时、只读、不可变的。循环引用时,ESM 不会返回空对象,而是为尚未执行完的模块创建“未初始化的绑定”,访问对应导出时抛出ReferenceError: Cannot access 'xxx' before initialization。
- A.mjs 导出
let a = 1; export { a }; import './B.mjs'; - B.mjs 导入
a并尝试读取:import { a } from './A.mjs'; console.log(a); - 执行时 B.mjs 试图访问 A.mjs 中尚未完成初始化的
a,直接报错 - 若改为默认导出对象属性(如
export default { a: 1 }),则 B.mjs 可安全访问该对象,但无法读取其内部尚未赋值的字段
关键差异总结:时机、绑定方式与错误表现
根本区别在于模块生命周期管理和绑定语义:
立即学习“Java免费学习笔记(深入)”;
- 时机:CommonJS 是运行时、按需加载;ESM 是加载阶段静态分析 + 执行阶段顺序求值
- 导出本质:CommonJS 导出的是值的拷贝或引用(可随时重赋值);ESM 导出的是活绑定(live binding),指向原始声明位置
-
错误行为:CommonJS 循环引用通常静默(可能返回
{}或undefined),ESM 则明确报ReferenceError,强制开发者面对初始化顺序问题
如何规避循环引用问题
无论使用哪种模块系统,循环依赖都暗示设计耦合过紧,应优先重构。临时应对策略包括:
- 将共享状态或工具函数抽离到第三个独立模块(如
utils.js) - 用函数封装依赖(延迟执行):B.mjs 不直接 import A 的变量,而是 import 一个函数
getAValue(),在需要时调用 - ESM 中可利用顶层
await或动态import()推迟依赖解析(注意这会变成异步) - CommonJS 中确保导出逻辑在 require 前完成,或用 getter 包装属性以支持后续赋值










