模块循环依赖(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 的执行顺序:
- 先解析所有模块
- 建立 import/export 绑定
- 从入口开始执行
- 遇到循环时,被引用的变量处于 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 ← B3. 事件/回调模式
// 用事件解耦
// 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 报错 |
| 最佳解决 | 延迟访问 / 重构 | 延迟访问 / 重构 |
最佳实践
- 避免循环依赖(重构代码架构)
- 必要时用延迟访问(函数包装)
- 使用工具检测循环依赖(
circular-dependency-plugin、madge)