ES Module循环引用不会死循环,因其严格遵循解析→实例化→执行三阶段机制;实例化阶段建立绑定但值未初始化,故可调用函数而读let/const变量会触发TDZ报错。

ES Module 的循环引用不会导致死循环,关键在于它的三阶段加载机制:解析 → 实例化 → 执行。循环中各模块能拿到彼此的绑定地址,但值可能尚未初始化——这就是为什么有时能访问函数却读不到变量。
理解 ES Module 的三阶段加载
ESM 不是边加载边执行,而是严格分三个步骤:
-
解析阶段:扫描所有
import语句,构建模块依赖图,确定导出标识符与内存位置的静态映射(不执行任何代码) -
实例化阶段:为每个模块分配内存空间,建立导入/导出绑定(此时所有
export let x已分配地址,但值仍为未初始化状态) - 执行阶段:按拓扑顺序逐个运行模块顶层代码,真正给导出变量赋值
为什么函数能调用而变量可能报错
因为函数声明会被提升且在实例化后就可调用,而 let/const 声明受暂时性死区(TDZ)约束——值未赋之前访问会抛 ReferenceError。
- 在
mod2.mjs中调用mod1Fn('mod2')成功:函数已绑定,且其声明在模块执行前就“就绪” - 但紧接着读取
mod1Value失败:该let变量在mod1.mjs执行完前仍未赋值,处于 TDZ - 若把
mod1Value改为const mod1Value = 'mod1Value',同样会卡在 TDZ;换成var则无效(ESM 不支持 var 声明导出)
安全处理循环依赖的实践方式
避免在模块顶层直接读取对方尚未初始化的值,把依赖延迟到函数调用时再取。
- 导出函数而非裸值:让消费方在需要时才访问,避开实例化阶段的 TDZ
- 用 getter 封装导出:例如
export const getMod1Value = () => mod1Value,调用时保证模块已执行 - 拆分导出逻辑:将易引发循环的值提取到独立模块(如
constants.js),不参与双向依赖链 - 检查执行时机:可在函数内加判断,如
if (mod1Value !== undefined),但更推荐结构解耦
对比 CommonJS 看清本质差异
CommonJS 是运行时同步执行 + 缓存 exports 对象,循环中返回的是“已执行部分”的快照;ESM 是编译期绑定 + 分阶段求值,提供的是“未来值”的实时引用。
- CommonJS:a.js require b.js → b.js require a.js → 返回 a.exports 当前内容(可能不完整)
- ESM:a.mjs import { fn } from './b.mjs' → 绑定成功 → b.mjs 执行时可调用 a 的函数 → 但读 a 的 let 变量需等 a 执行完
- 因此 ESM 循环更“精确”,但也更“脆弱”——它暴露了执行时序,要求开发者关注初始化顺序

















