JavaScript的模块化加载循环依赖问题,指的是多个模块之间存在相互引用的关系,比如模块A导入模块B的内容,同时模块B又导入模块A的内容,这种相互依赖的场景就是循环依赖。循环依赖如果处理不当,很容易导致模块加载时获取到的导出内容是空值或者未初始化的状态,进而引发业务逻辑异常。

循环依赖的核心问题
循环依赖的本质是模块加载顺序和导出内容初始化的时序冲突。当模块系统加载模块时,如果遇到了循环引用,可能会出现还没完成当前模块的所有导出内容初始化,就被其他模块引用的情况,此时其他模块拿到的就是不完整甚至为空的导出结果。
比如下面这种场景,模块A需要模块B的变量b,模块B需要模块A的变量a,两个模块同时执行导出操作,就可能出现a或b还没赋值就被引用的问题。
CommonJS对循环依赖的处理方式
CommonJS规范采用运行时加载和缓存机制处理循环依赖。模块在第一次加载时会被执行,执行过程中导出的内容会先存入模块的缓存对象,后续其他模块引用该模块时,直接从缓存中获取,不会重复执行模块代码。
当遇到循环依赖时,CommonJS会先加载当前模块,执行到导出语句时,将已经执行完成的部分导出内容存入缓存,然后去加载依赖的模块,依赖模块加载时如果再次引用当前模块,就会直接从缓存中拿当前模块已经导出的部分内容,不会重新执行当前模块。
CommonJS循环依赖示例
先看两个相互依赖的CommonJS模块代码:
// a.js
console.log('开始执行a模块');
const b = require('./b.js');
console.log('a模块中获取到的b变量:', b);
const a = 'a模块的导出内容';
module.exports = a;
console.log('a模块执行完成');
// b.js
console.log('开始执行b模块');
const a = require('./a.js');
console.log('b模块中获取到的a变量:', a);
const b = 'b模块的导出内容';
module.exports = b;
console.log('b模块执行完成');
执行a.js后的输出结果如下:
开始执行a模块
开始执行b模块
b模块中获取到的a变量: {}
a模块中获取到的b变量: b模块的导出内容
a模块执行完成
b模块执行完成
可以看到,执行a模块时先打印开始执行a模块,然后遇到require加载b模块,此时暂停a模块的执行,开始执行b模块。b模块执行时又require加载a模块,此时a模块已经在加载流程中,缓存里已经有a模块的空对象,所以b模块拿到的a是空对象。等b模块执行完成导出b的内容后,回到a模块继续执行,此时a模块才能拿到b的导出内容,最后a模块完成自己的导出。
如果需要在CommonJS中解决循环依赖的问题,通常可以将导出的内容改为对象形式,利用对象的引用特性,在模块执行过程中逐步给对象添加属性,这样其他模块引用时就能拿到最终完整的内容:
// a.js
const a = {};
console.log('开始执行a模块');
const b = require('./b.js');
console.log('a模块中获取到的b变量:', b);
a.value = 'a模块的导出内容';
module.exports = a;
console.log('a模块执行完成');
// b.js
const b = {};
console.log('开始执行b模块');
const a = require('./a.js');
console.log('b模块中获取到的a变量:', a);
b.value = 'b模块的导出内容';
module.exports = b;
console.log('b模块执行完成');
此时执行a.js后,b模块拿到的a对象已经有了value属性,因为a对象在导出前就已经创建,缓存里存的是这个对象的引用,后续给对象添加属性后,引用该对象的其他模块也能同步获取到更新后的内容。
ES6模块对循环依赖的处理方式
ES6模块和CommonJS的处理逻辑完全不同,ES6模块采用静态分析和动态引用的机制。ES6模块在代码编译阶段就会确定模块的依赖关系,导出的是变量的只读引用,而不是值的拷贝。
遇到循环依赖时,ES6模块不会像CommonJS那样缓存执行结果,而是先建立模块之间的引用关系,等到真正执行代码的时候,才会去获取对应模块的导出值。如果导出的值还没初始化,就会报错或者拿到undefined,具体表现和导出语句的执行顺序有关。
ES6模块循环依赖示例
同样是两个相互依赖的ES6模块:
// a.mjs
console.log('开始执行a模块');
import { b } from './b.mjs';
console.log('a模块中获取到的b变量:', b);
export const a = 'a模块的导出内容';
console.log('a模块执行完成');
// b.mjs
console.log('开始执行b模块');
import { a } from './a.mjs';
console.log('b模块中获取到的a变量:', a);
export const b = 'b模块的导出内容';
console.log('b模块执行完成');
执行a.mjs后会直接报错,提示Cannot access 'a' before initialization,因为ES6模块的import语句会提升到模块顶部,在执行模块代码前就建立了引用关系,但是a的导出语句在import之后,b模块执行时尝试获取a的值,此时a还没有初始化,所以会抛出初始化前访问的错误。
调整导出语句的位置,把导出语句放在import之前,就可以避免这个问题:
// a.mjs
export const a = 'a模块的导出内容';
console.log('开始执行a模块');
import { b } from './b.mjs';
console.log('a模块中获取到的b变量:', b);
console.log('a模块执行完成');
// b.mjs
export const b = 'b模块的导出内容';
console.log('开始执行b模块');
import { a } from './a.mjs';
console.log('b模块中获取到的a变量:', a);
console.log('b模块执行完成');
此时执行a.mjs的输出结果如下:
开始执行a模块 开始执行b模块 b模块中获取到的a变量: a模块的导出内容 a模块中获取到的b变量: b模块的导出内容 a模块执行完成 b模块执行完成
这是因为ES6模块的导出是变量的引用,只要导出语句在import之前执行完成,其他模块引用时就能拿到正确的值。另外ES6模块支持函数导出,因为函数声明会提升到模块顶部,即使导出语句在import之后,函数也已经初始化完成,不会报错:
// a.mjs
import { bFunc } from './b.mjs';
console.log('a模块中调用b函数:');
bFunc();
export function aFunc() {
console.log('a模块的函数');
}
// b.mjs
import { aFunc } from './a.mjs';
console.log('b模块中调用a函数:');
aFunc();
export function bFunc() {
console.log('b模块的函数');
}
执行a.mjs后可以正常输出两个函数的调用结果,因为函数声明在模块编译阶段就已经提升,初始化完成,所以循环依赖时也能正常引用。
两种规范处理循环依赖的差异总结
可以通过以下表格对比CommonJS和ES6模块处理循环依赖的核心差异:
| 对比维度 | CommonJS | ES6模块 |
|---|---|---|
| 加载时机 | 运行时加载 | 编译时静态分析 |
| 导出内容 | 值的拷贝,缓存执行结果 | 变量的只读引用 |
| 循环依赖处理逻辑 | 缓存已执行部分的导出,后续引用从缓存获取 | 先建立引用关系,执行时获取值,未初始化会报错 |
| 解决循环依赖常用方式 | 导出对象,动态添加属性 | 调整导出语句顺序,优先导出函数 |
实际开发中的建议
无论是使用CommonJS还是ES6模块,最好的方式还是从设计层面避免循环依赖,比如提取公共逻辑到独立的第三个模块,让原本相互依赖的两个模块都引用这个公共模块,从根源上消除循环关系。
如果无法避免循环依赖,使用CommonJS时可以优先导出对象类型,利用引用特性保证其他模块能拿到完整内容;使用ES6模块时可以将导出语句放在import之前,或者优先导出函数,避免初始化顺序导致的报错问题。
JavaScript模块化循环依赖CommonJSES6_模块修改时间:2026-07-19 18:06:40