ESM 循环依赖:live binding 为什么能救
本节目标
- 理解 ESM 的「实时绑定(live binding)」是什么
- 用代码对比 ESM 循环依赖比 CJS 友好在哪
- 知道 live binding 的「坑」仍在(函数 vs 值)
ESM 导出不是「值的拷贝」,而是「绑定」——导入方读到的永远是导出方当前的值。这正是它能在循环依赖下比 CJS 稳的根本原因,但要用对姿势。
先给一个能真正跑通的循环依赖例子(入口是 a.mjs):
// 运行环境:Node.js 16+,入口命令 node a.mjs
// a.mjs(入口)
export let count = 0
export function inc() { count++ }
// 引入 b:会先去执行 b.mjs。b 只在函数里引用 count,顶层不碰,所以安全
import { reportFromB } from './b.mjs'
console.log('a: 初始化完成,count =', count) // 0
inc()
console.log('a: inc 后,count =', count) // 1
reportFromB() // b 通过 live binding 看到 1,还能把它推到 2// b.mjs
import { count, inc } from './a.mjs'
// 关键:b 顶层不直接读 count。因为 a 先 import 了 b,b 会在 a 的 let count=0 执行前被求值,
// 那时 count 还在 TDZ(暂时性死区),顶层访问会直接抛 ReferenceError。
// 所以把「读取 a 的状态」放进函数,等 a 初始化完再调用。
export function reportFromB() {
console.log('b: 看到 a.count =', count) // 1(live binding,实时值)
inc() // b 也能改 a 的 count
console.log('b: inc 后 a.count =', count) // 2
}运行 node a.mjs 输出:
a: 初始化完成,count = 0
a: inc 后,count = 1
b: 看到 a.count = 1
b: inc 后 a.count = 2为什么能跑通:a 是入口,ESM 先解析并求值它的 import(也就是 b)。b 只「定义」了 reportFromB,没有在顶层碰 count,平安度过 TDZ;等 a 跑完 let count = 0、又 inc() 把 count 改成 1 之后,再调 reportFromB(),b 读到的就是「实时值 1」,甚至还能继续 inc() 推到 2——这就是 live binding 的威力。
与 CJS 对比:CJS 的 require 拿到的是 module.exports 那一刻的「快照」,a 改了 b 看不到;ESM 是「活的链接」,a 改 b 实时看到。所以循环依赖下 ESM 几乎不会出现 CJS 那种「半成品 undefined」。
仍有坑:live binding 救了「值同步」,但「执行顺序」仍是后序——如果 b 在**自己初始化阶段(顶层)**就急用 a 的某个「还没执行到的导出」,照样 TDZ 抛错(上面 b.mjs 的注释就是踩坑点)。所以「跨模块取值延迟到函数里」仍是铁律,跟 CJS 循环依赖的最佳实践一致。
名词解释
实时绑定(Live Binding):ESM 导入的变量不是「值的副本」,而是「指向导出方内存的链接」。导出方改了,导入方下次读自动是最新值——像共享同一块内存。这正是 ESM 循环依赖比 CJS(值拷贝)稳的核心:双方始终看到彼此的实时状态,不会被「拷贝时的旧值」坑。
值拷贝 vs 绑定:CJS require 拿到的是 module.exports 那一刻的快照(之后对方改了你看不到);ESM import 拿到的是活的链接(对方改你实时看到)。一句话区别:「CJS 拍照,ESM 连监控」。
课后练习
练习 1:ESM 下「循环依赖」就绝对安全吗?
答案:比 CJS 安全,但不绝对。live binding 保证「值实时同步」,但「执行顺序」仍是后序——如果模块顶层(初始化阶段)就急用对方「尚未执行到的导出」,仍可能读到未初始化值。所以「跨模块读取放进函数、延迟调用」依然是最佳实践。
练习 2:为什么 ESM 里 import { count } from './a' 之后,a 里 count++ 能在 b 看到?
答案:因为
count在 b 里是「指向 a 的 count 的 live binding」,不是拷贝。a 执行count++改的是那块共享内存,b 下次读自然是最新值。CJS 则拷贝了旧值,看不到变化。
总结
live binding 是 ESM 最优雅的设计,也是它「循环依赖比 CJS 友好」的根本原因。我的观点:但别因此就觉得「ESM 循环依赖随便写」——live binding 解决的是「值同步」,没解决「执行顺序」。如果你在模块顶层就急吼吼用对方的导出,而对方还没初始化到那一行,照样踩雷。所以黄金法则不变:模块顶层只做导出和无依赖初始化,跨模块的取值留给函数。ESM 把循环依赖从「必炸」降级为「小心就稳」,但好的架构仍然是「尽量让依赖单向」。工具帮你兜底,不等于你可以乱来。