ESM 循环依赖:live binding 为什么能救

本节目标

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 把循环依赖从「必炸」降级为「小心就稳」,但好的架构仍然是「尽量让依赖单向」。工具帮你兜底,不等于你可以乱来。