模块循环依赖的处理
模块循环依赖的处理
一句话概括
A 引 B、B 引 A 就是循环依赖。CommonJS 给你半成品对象,ES Module 用 Live Binding 保住引用但值可能还没填——两者都不完美,但理解行为差异是写出可靠模块的基础。
核心知识点
1. CommonJS 的循环依赖 — 半成品地狱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// a.js
console.log('a 开始');
exports.done = false;
const b = require('./b'); // ← 跳到 b.js
exports.done = true;
console.log('a 结束');
// b.js
console.log('b 开始');
const a = require('./a'); // ← a 还没执行完,拿到缓存里的半成品
console.log(a.done); // false
console.log('b 结束');
// 执行 main.js: require('./a')
// 输出顺序:
// a 开始 → b 开始 → a.done = false → b 结束 → a 结束
根因: require('./a') 第一次调用时就创建了 module.exports 的缓存对象,此时 done 还是初值 false。b.js 拿到的是这个还没执行完的缓存对象。
2. ES Module 的循环依赖 — 引用不丢,值未就绪
1
2
3
4
5
6
7
8
9
// a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log('a 中 b =', b); // 'B' — 拿到了!
// b.mjs
import { a } from './a.mjs';
export const b = 'B';
console.log('b 中 a =', a); // undefined — 引用在,值没到
原理: ESM 在编译阶段就建立了变量绑定(分配内存地址),执行阶段只是往地址里填值。所以 b.mjs 里读到的 a 是 undefined——地址对了,但值还没被 a.mjs 的赋值语句执行到。
3. 三种解决方案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 方案一:延迟访问 —— 把依赖放到函数内部(CJS 和 ESM 通用)
// a.js
exports.getB = () => {
const b = require('./b'); // 运行时再 require,此时双方都执行完了
return b;
};
// 方案二:提取公共模块 —— 最干净
// shared.js
export const shared = '公共逻辑,谁用谁 import';
// a.js 和 b.js 都只依赖 shared,不再互引
// 方案三:TypeScript 用 import type
import type { B } from './b'; // 仅编译时类型,不产生运行时依赖
4. 检测循环依赖
1
2
3
4
5
6
7
8
# madge:画依赖图 + 标循环
npx madge --circular src/
# ESLint
# .eslintrc.js
rules: {
'import/no-cycle': ['error', { maxDepth: Infinity }]
}
其实你每天都在用
- Vuex / Pinia 模块互引:
store/user.ts和store/order.ts里的 action 互相调用对方 - React 父子组件互相引用:
Parent.tsximportChild.tsx,但Child.tsx需要Parent的类型定义 → 用import type解决 - utils/tools 交叉引用:
format.ts用了validate.ts的函数,validate.ts又引用format.ts的格式化 - Node.js 中间件链:多个中间件文件互引对方的处理结果
- 类型声明文件之间:
types/user.ts↔types/post.ts互相引用,但import type不会产生循环
常见误解
❌ 误区:「循环依赖会导致死循环」 不会。CJS 有模块缓存,ESM 有编译阶段绑定——两者都不会让模块代码重复执行。问题不是死循环,而是拿到的值不完整。
❌ 误区:「ESM 彻底解决了循环依赖」 没完全解决。ESM 保证引用不丢,但如果顶层代码直接访问循环依赖方的变量,拿到的还是
undefined——对方代码还没执行到赋值行。解决方案依然是延迟到函数内访问。❌ 误区:「Webpack 打包之后就不怕循环依赖了」 Webpack 只是把 CJS/ESM 统一成自己的模块系统,但执行顺序问题依然存在。打包工具能检测循环,不能消除循环导致的半成品问题。
❌ 误区:「只要跑得动就没问题」 循环依赖的行为可能依赖于执行顺序——顺序换个打包工具、调整了 import 顺序,行为就可能变。这恰恰是最危险的 bug:不是必现,但会在特定条件下爆炸。
一句话总结
循环依赖不是 bug,是架构的预警信号——它在告诉你模块之间的职责边界已经模糊了,该把公共逻辑提出来了。
本文由作者按照 CC BY 4.0 进行授权