ES Module 循环依赖时,为什么会读到未初始化的值?

先理解原理和示例,再练习完整口述与追问。

ESM 的导入是活绑定,模块实例只执行一次。循环依赖时,后开始执行的模块会先执行;此时先开始的模块已经有绑定,但 const 可能还在暂时性死区,顶层读取会抛 ReferenceError。把读取放进函数,等两边都执行完再调用,就能读到最终值。

加载 ESM 先解析出模块图,再按依赖深度优先执行。a 顶层导入 b,b 又导入 a 时:先开始执行 a,遇到 b 就转去执行 b;b 再导入 a 时发现 a 已在执行,不会再次执行,而是使用已经建立的活绑定。然后 b 的顶层继续执行,最后才回到 a 剩下的顶层。

活绑定意味着导入方读到的是导出方变量当前的值,不是执行到 import 那一刻的拷贝。但 const 和 let 在初始化语句执行前处于暂时性死区。b 的顶层如果直接读 a 的 const,而 a 的初始化写在导入 b 之后,读的时候初始化还没执行,就抛 ReferenceError。改成 export function getA(),在两个模块都执行完之后再调用,函数体读取时初始化已经完成。

CommonJS 的 require 返回的是 exports 对象的当前状态,循环时经常拿到不完整对象,缺的是还没赋上去的属性,表现成 undefined,一般不抛暂时性死区。两套模块系统的循环依赖都不能靠“碰巧能跑”来设计,模块边界应该是单向的。

js
// a.mjs
import { b } from './b.mjs'
export const a = 1
console.log('a 看到', b)
// b.mjs
import { a } from './a.mjs'
export const b = 2
console.log('b 看到', a)

执行 a 时先进入 b。b 的顶层读取 a,此时 a 还停在导入语句、没有执行到 export const a = 1,抛 ReferenceError,后面的日志都不会出现。如果 b 只导出 export const readA = () => a,主程序等两个文件加载完再调用 readA(),得到 1;a 的顶层日志能看到已经初始化的 b === 2。

不能说“ESM 循环依赖永远报错”或“永远安全”。顶层读 let/const 可能抛错,函数里晚一点读通常正常。构建工具把模块打包进同一个文件后,执行顺序仍要遵守依赖顺序,不能用打包消除错误的分层。

ESM 先建模块图再执行,导入是活绑定,模块只执行一次。循环时被导入的一方会先执行完自己的顶层,另一方的 const 可能还在暂时性死区,这时顶层直接读取就会 ReferenceError。读操作放到函数里,等两边初始化完成再调用,就能拿到最终值。CommonJS 循环依赖更多表现为 exports 上暂时没有某个属性。无论哪种,循环都说明模块边界该拆开。