模块循环依赖(Circular Dependency)是什么?CJS 和 ESM 如何处理?

循环依赖

模块 A 依赖 B,B 又依赖 A,形成循环。

A → B → A → B → ...(循环)

CommonJS 的处理

CJS 返回已执行部分的导出:

// a.js
console.log('a 开始')
const b = require('./b')
console.log('b in a:', b)
module.exports = { a: 1 }

// b.js
console.log('b 开始')
const a = require('./a')  // a 尚未执行完,返回 {}
console.log('a in b:', a)  // {}
module.exports = { b: 2 }

// 执行 require('./a')
// 输出:
// a 开始
// b 开始
// a in b: {}        ← a 的 exports 是空对象(还没执行 module.exports)
// b in a: { b: 2 }

ESM 的处理

ESM 返回引用绑定,可以获取最终值:

// a.mjs
import { b } from './b.mjs'
export const a = 1
console.log('a 执行, b =', b)  // b 的值取决于加载顺序

// b.mjs
import { a } from './a.mjs'
export const b = 2
console.log('b 执行, a =', a)  // a 可能是 undefined(TDZ)

ESM 的执行顺序:

  1. 先解析所有模块
  2. 建立 import/export 绑定
  3. 从入口开始执行
  4. 遇到循环时,被引用的变量处于 TDZ(暂时性死区),访问会报错
// ESM 循环依赖示例
// a.mjs
import { b } from './b.mjs'
export const a = b + 1  // ❌ ReferenceError: Cannot access 'b' before initialization

// b.mjs
import { a } from './a.mjs'
export const b = a + 1  // ❌ 同样报错

解决方案

1. 延迟访问(函数包装)

// CJS 方案
// a.js
const b = require('./b')
function getB() { return b.value }  // 延迟到运行时访问
module.exports = { getB }

// ESM 方案
// a.mjs
import { b } from './b.mjs'
export function getB() { return b }  // 函数执行时 b 已初始化

2. 重构代码(推荐)

将循环依赖的部分提取到第三个模块:

// 原来: A ↔ B
// 重构: A → C ← B

3. 事件/回调模式

// 用事件解耦
// a.js
const { EventBus } = require('./eventBus')
EventBus.on('b-ready', (b) => { /* 使用 b */ })

// b.js
const { EventBus } = require('./eventBus')
module.exports = { b: 2 }
EventBus.emit('b-ready', module.exports)

对比

特性 CJS ESM
循环时返回 已执行的部分(可能空对象) 引用绑定(可能 TDZ)
函数内访问 ✅ 可以(延迟访问) ✅ 可以
顶层访问 部分值 可能 TDZ 报错
最佳解决 延迟访问 / 重构 延迟访问 / 重构

最佳实践

同分类其他题目