es module通过解析→实例化→求值三阶段机制承载循环依赖:解析阶段静态构建依赖图,实例化阶段建立实时绑定(变量声明即存在但未赋值),求值阶段按拓扑序执行,使导入方能访问已声明未初始化的变量(如undefined)或后续更新值。

ES Module 对循环依赖不是“阻止”,而是用一套可预测的机制去承载它。关键在于理解它的三阶段流程:解析 → 实例化 → 求值。只要变量已声明(哪怕还没赋值),导入方就能拿到一个实时绑定的引用,后续更新也能被感知——但访问时机决定你看到的是 undefined 还是真实值。
静态分析提前锁定依赖关系
ESM 在代码执行前就扫描所有 import 和 export 语句,构建完整的依赖图。这意味着:
- 模块 A 和 B 相互
import,引擎在加载阶段就知道双方的存在,不会等到运行时才报错 - 没有动态路径(如
import('./' + name + '.js'))干扰分析,所以依赖关系稳定、可追踪 - 构建工具(如 Rollup、Webpack)能借此做 Tree Shaking,也便于用
madge等工具可视化循环链
实例化阶段创建实时绑定
模块进入实例化阶段时,并不执行代码,而是为每个 export 变量分配内存位置并建立引用链接:
-
import { x } from './a.js'拿到的不是x的副本,而是指向a.js中x声明位置的只读连接 - 即使
a.js尚未执行到x = 123这一行,x在内存中已是“存在但未初始化”状态 - 函数声明和类声明会提升,所以通常能安全访问;而
let/const变量在赋值前处于 TDZ(暂时性死区),访问会报错
求值顺序决定实际输出结果
模块按依赖拓扑序执行:谁没被依赖,谁先执行;有循环时,引擎按首次遇到的 import 路径推进:
- 若入口加载
moduleA.js,它 importmoduleB.js,则先跳进B执行 -
B中 importA时,A已实例化但尚未求值,所以valueFromA是undefined -
B执行完并导出valueFromB = 'B'后,控制权回到A,此时A才完成自己的赋值 - 因此常见输出:Module B: value from A is undefined,Module A: value from B is B
实用建议:让循环更可控
不推荐主动设计循环依赖,但若已存在,可通过以下方式降低风险:
- 把共享数据或逻辑抽离成第三个模块(如
shared.js),A 和 B 都只依赖它 - 用函数封装导出项(
export const getValue = () => value),延迟取值,避开初始化时机问题 - 避免在顶层直接读取对方的
let/const变量,优先用函数或类实例作为导出主体 - 在 Node.js 或现代浏览器中启用
"type": "module"并配合 ESLint 规则import/no-cycle主动拦截











