循环依赖(Circular Dependency):CommonJS 和 ESM 分别是如何处理的?

循环依赖:CommonJS 与 ESM 的处理机制深度解析

大家好,欢迎来到今天的讲座!我是你们的技术导师,今天我们要深入探讨一个在现代 JavaScript 开发中经常遇到但又容易被忽视的问题——循环依赖(Circular Dependency)。你是否曾在 Node.js 项目中遇到过 ReferenceError: Cannot access 'xxx' before initialization?或者在前端打包时看到 Webpack 报错说“Module parse failed”?这背后很可能就是循环依赖惹的祸。

我们将从理论到实践,分两部分来剖析这个问题:

  1. 什么是循环依赖?
  2. CommonJS 如何处理循环依赖?
  3. ESM(ECMAScript Modules)又是怎么做的?
  4. 两者对比总结 + 实战建议

准备好了吗?让我们开始!


一、什么是循环依赖?

先来看个简单的定义:

循环依赖是指两个或多个模块之间互相引用对方,形成闭环关系。

举个例子:

// a.js
const b = require('./b');
console.log('a loaded');
module.exports = { name: 'a' };
// b.js
const a = require('./a');
console.log('b loaded');
module.exports = { name: 'b' };

如果你运行 node a.js,会发生什么?

答案是:会报错!

因为:

  • a.js 要求加载 b.js
  • b.js 又要求加载 a.js
  • 最终导致无限递归加载 → 内存溢出或 ReferenceError

这就是典型的循环依赖问题。

不过别急着下结论,不同模块系统对它的处理方式完全不同 —— 这正是我们今天要讲的重点!


二、CommonJS(Node.js 默认模块系统)如何处理循环依赖?

✅ CommonJS 的核心特性:动态加载 + 模块缓存

CommonJS 在 Node.js 中使用的是 require() 函数,它有以下关键行为:

特性 描述
动态执行 require() 是运行时调用,不是编译期
缓存机制 第一次加载后缓存结果,后续直接返回缓存对象
即时绑定 引用的是模块导出的对象本身

这意味着:CommonJS 其实可以容忍一定程度的循环依赖!

🧪 示例验证:CommonJS 的循环依赖行为

我们写一段代码测试一下:

文件结构:

project/
├── a.js
├── b.js
└── index.js

a.js:

console.log('Loading a.js...');
const b = require('./b');
console.log('a has b:', b);
module.exports = { name: 'a', b };

b.js:

console.log('Loading b.js...');
const a = require('./a');
console.log('b has a:', a);
module.exports = { name: 'b', a };

index.js:

const a = require('./a');
console.log('Final result:', a);

现在执行:

node index.js

输出如下(顺序可能略有差异,但逻辑清晰):

Loading a.js...
Loading b.js...
b has a: [Circular]
a has b: [Circular]
Final result: { name: 'a', b: [Circular] }

💡 关键点来了!

  • a.js 加载了 b.js
  • b.js 开始加载 a.js,此时 a.js 已经处于“正在加载”的状态,尚未完成执行;
  • 所以 b.js 获取到的是一个 空对象 {}(或者说未完全初始化的模块对象)
  • a.js 继续执行完后,才真正赋值给 module.exports
  • 最终 a.bb.a 都指向同一个“半成品”对象 —— 即所谓的 [Circular]

✅ 结论:CommonJS 不会崩溃,而是通过“提前暴露模块对象”来绕过死锁。

这种机制叫做 “部分初始化”(Partially Initialized Module),虽然不完美,但在很多场景下足够用了。


三、ESM(ECMAScript Modules)如何处理循环依赖?

⚠️ ESM 的核心特性:静态分析 + 声明式导入

ESM 使用 importexport,其最大特点是:

特性 描述
静态语法 导入/导出必须在顶层作用域,不能动态判断
编译期解析 所有依赖关系在编译阶段就确定,支持 tree-shaking
严格初始化 模块必须完整加载完成后才能访问其成员

⚠️ 正是因为这些特性,ESM 对循环依赖的处理比 CommonJS 更加“刚硬”。

🧪 示例验证:ESM 的循环依赖行为

同样结构,改为 ESM 格式:

a.mjs:

console.log('Loading a.mjs...');
import { b } from './b.mjs';
console.log('a has b:', b);
export const name = 'a';
export { b };

b.mjs:

console.log('Loading b.mjs...');
import { a } from './a.mjs';
console.log('b has a:', a);
export const name = 'b';
export { a };

index.mjs:

import { name as aName } from './a.mjs';
console.log('Final result:', aName);

执行命令:

node --experimental-modules index.mjs

你会看到错误:

Error: Cannot access 'a' before initialization
    at Object.<anonymous> (./b.mjs:3:1)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    ...

❌ 错误原因:

  • ESM 在编译期就知道所有导入关系;
  • 它试图同时加载 a.mjsb.mjs,发现彼此都依赖对方;
  • 因为没有“部分初始化”的能力,只能等待模块完全执行完毕;
  • 但由于双方都在等对方完成,陷入僵局 —— 无法满足“先声明再使用”的规则

👉 所以 ESM 直接抛出异常:Cannot access ‘xxx’ before initialization

这是 ESM 的设计哲学体现:宁可报错也不让开发者写出难以调试的诡异逻辑。


四、对比表格:CommonJS vs ESM 处理循环依赖的方式

方面 CommonJS ESM
是否允许循环依赖 ✅ 允许(但返回未完全初始化对象) ❌ 不允许(直接报错)
加载时机 运行时动态加载 编译期静态分析
缓存策略 模块首次加载后缓存 同样缓存,但需确保无循环
初始化顺序 允许中途访问模块属性 必须等到整个模块执行完才能访问
报错信息 通常不报错,仅显示 [Circular] 明确提示 Cannot access 'xxx' before initialization
使用场景推荐 Node.js 环境下的传统项目 现代前端框架(React/Vue)、浏览器环境、TypeScript 支持更好
解决方案灵活性 可靠(如延迟导入、工厂函数) 强制重构(拆分模块、引入中间层)

📌 总结一句话:

CommonJS 是“容忍型”,ESM 是“严苛型”。


五、实战建议:如何避免和修复循环依赖?

✅ 方法一:重构模块结构(最佳实践)

不要让两个模块互相依赖,而是引入第三个中间模块:

// shared.js
export const config = { version: '1.0' };

// a.js
import { config } from './shared.js';
export const a = { ...config, name: 'a' };

// b.js
import { config } from './shared.js';
export const b = { ...config, name: 'b' };

这样就不会出现循环了!

✅ 方法二:使用工厂函数或懒加载(CommonJS 特有技巧)

// a.js
let _b;
function getB() {
  if (!_b) _b = require('./b');
  return _b;
}
module.exports = { name: 'a', getB };

这种方式可以把对 b 的依赖推迟到实际需要的时候,避免一开始就触发循环。

✅ 方法三:ESM 下使用 import() 动态导入(现代解决方案)

// a.mjs
import('./b.mjs').then(({ default: b }) => {
  console.log('Dynamic import success:', b);
});

注意:import() 返回 Promise,适合异步场景,但不适合同步逻辑。

✅ 方法四:使用 TypeScript 的类型导入(辅助诊断)

即使在 ESM 中,也可以利用 TS 的类型系统提前发现问题:

// types.ts
export interface A { name: string; b?: B }

// a.ts
import type { B } from './b'; // 只导入类型,不会触发运行时依赖
export class A implements A { name = 'a'; }

这样可以在不破坏运行时结构的前提下进行类型检查。


六、常见误区澄清

误区 正确理解
“CommonJS 不会出错所以没问题” 实际上会导致数据缺失或意外行为,比如访问不到预期属性
“ESM 更先进所以更安全” 是的,但它牺牲了灵活性,开发者必须主动解决循环问题
“只要我用 webpack 就不会有问题” Webpack 会自动处理一部分循环依赖(通过模块合并),但本质还是靠你的代码结构合理
“我可以用 --no-warnings 忽略警告” 别这么做!这只是掩盖问题,不是解决问题

七、结语:选择哪种模块系统?

场景 推荐模块系统
Node.js 后端开发(尤其是老项目迁移) CommonJS(兼容性强)
前端项目(React/Vue/Angular) ESM(标准未来方向)
新建项目 / TypeScript 支持良好 ESM(强制规范,利于工具链优化)
需要快速迭代且不怕复杂度 CommonJS(容错高)
追求极致性能和可预测性 ESM(静态分析 + tree-shaking)

记住一句话:

循环依赖不是 bug,而是设计缺陷;ESM 让你早点发现它,CommonJS 让你晚点发现它。


🔚 总结回顾

今天我们从底层原理出发,详细讲解了:

  • 循环依赖的本质是什么?
  • CommonJS 如何通过“部分初始化”来规避死锁?
  • ESM 为何拒绝循环依赖并直接报错?
  • 如何在两种系统中优雅地解决循环依赖?
  • 实战中的最佳实践和常见误区。

无论你是初学者还是资深工程师,理解这两种模块系统的差异,都能帮助你在团队协作、项目维护、性能优化等方面做出更明智的选择。

希望今天的分享对你有所启发!如果还有疑问,欢迎留言讨论 😊

发表回复

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