es modules通过导出绑定(live bindings)和静态执行顺序处理循环依赖,而非变量提升;模块解析时建立双向绑定,未初始化值为undefined,不报错但可能产生nan等异常结果。

ES Modules(ESM)本身不实现变量提升,也不通过“提升”来解决循环依赖。它处理循环依赖的方式与 CommonJS 完全不同——核心是绑定实时性(live bindings)和静态执行顺序,而非把声明提前或缓存初始值。
ESM 的循环依赖靠的是“导出绑定”,不是变量提升
ESM 在解析阶段就建立模块间的导入/导出绑定关系,这些绑定是“活的”(live):即使模块尚未执行完毕,导入的变量也已指向其未来将被赋值的内存位置。这意味着:
- 导入语句在编译期就确定,不执行代码也能知道依赖图
- 导入的值不是拷贝,而是对导出变量的引用
- 当模块 A 导入模块 B 的
foo,而 B 又导入 A 的bar,两者在执行时能读到对方已初始化的部分,未初始化部分为undefined(不会报错,但值可能暂时不可用)
一个典型执行过程示例
假设两个文件:
// a.js
Java项目代码review工具。分析Git变更+完整调用链路上下文,推断业务需求,进行多维度评分和分类汇总,生成完整PRD文档。包含细粒度Java代码审查清单(Null安全、异常处理、Streams、并发、equals/hashCode、资源管理、API设计、性能、MyBatis/ORM、事务边界、SQL/DD...
import { bValue } from './b.js';<br>
export const aValue = bValue + 10;<br>
console.log('a executed');
// b.js
import { aValue } from './a.js';<br>
export const bValue = aValue * 2;<br>
console.log('b executed');
运行 node --experimental-modules a.js(或现代打包器中)时:
- ESM 先解析所有 import,建立
aValue ⇄ bValue的双向绑定 - 开始执行 a.js → 遇到
import { bValue },此时 b.js 已解析但未执行,bValue绑定存在但值为undefined - a.js 中
aValue = undefined + 10→ 结果为NaN(注意:不是报错,而是计算发生) - 接着执行 b.js → 读取
aValue,此时它已是NaN,于是bValue = NaN * 2 = NaN
为什么不能叫“变量提升”?
变量提升(hoisting)特指 var 声明被预扫描并初始化为 undefined 的行为,属于 JavaScript 执行上下文的内部机制。而 ESM 的循环依赖处理:
- 发生在模块生命周期的实例化(instantiation)阶段,早于执行
- 依赖的是导出绑定的静态图谱,不是作用域内变量的声明移动
-
let/const声明本身仍受 TDZ(暂时性死区)约束,导入的绑定不绕过 TDZ —— 若在模块顶部立即访问一个尚未初始化的导出值,仍会抛ReferenceError
实际开发中该怎么应对?
依赖“活绑定”并不等于可以放心写循环逻辑。更稳妥的做法是:
- 把共享状态或计算逻辑抽离到第三个模块(如
shared.js),让 A 和 B 都单向依赖它 - 用函数封装导出项(
export const getAValue = () => bValue + 10),延迟求值,避开初始化时机问题 - 在构建工具中启用循环依赖警告(如 Webpack 的
module.noParse或 ESLint 的import/no-cycle规则) - 优先使用动态
import()拆解强耦合,让依赖变为按需、异步、可中断
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南










