ES Module 的循环依赖不会像 CommonJS 那样安静地给你一个 undefined,它会直接抛出 ReferenceError: Cannot access 'a' before initialization。
下面这段代码,很多人第一反应会认为 a、b 最终都是 undefined。真实结果不是。
// moduleA.js
import { b } from './moduleB.js'
export let a = b
// moduleB.js
import { a } from './moduleA.js'
export let b = a
在 Node 里跑一下:
➜ test node moduleA.js
file:///Users/ljm/Desktop/code/test/moduleB.js:3
export let b = a
^
ReferenceError: Cannot access 'a' before initialization
at file:///Users/ljm/Desktop/code/test/moduleB.js:3:16
at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
at async node:internal/modules/esm/loader:643:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.18.1
不是 undefined,是报错。原因出在 ES Module 是 live binding(实时绑定),以及它把「加载」拆成了三个阶段,而不是像 CommonJS 那样边加载边执行。
一、关键前提:ES Module 的三个阶段
要理解循环依赖,先把 ES Module 的执行流程拆开。规范里它分成三步:
- 构建(Construction):从入口出发,深度优先解析所有
import,把整个模块图搭出来。遇到已经加载中的模块不会重复加载,只是记录下来,环就停在这里。 - 实例化(Instantiation):为每个模块创建环境记录,把
export的绑定提前准备好。注意,这一步只建立绑定,不算值。let/const的绑定此刻处于「未初始化」状态(也就是大家常说的暂时性死区 TDZ)。 - 求值(Evaluation):按依赖顺序真正执行每个模块的顶层代码,这时候绑定才被赋上真正的值。
重点:绑定在实例化阶段就建好了,值要到求值阶段才有。 这两段时间差,就是循环依赖所有怪异现象的源头。
而 import { b } 拿到的不是一份拷贝,而是指向 moduleB 里那个 b 绑定的引用。这就是 live binding。
二、逐步复盘:为什么是 ReferenceError
把上面那段代码按阶段走一遍:
- 入口开始加载
moduleA,构建阶段发现它依赖moduleB。 moduleB又依赖moduleA。此时moduleA已经在「加载中」,不再重复加载,环闭合。- 进入实例化:
moduleA的a、moduleB的b两个绑定都建立了,但都是未初始化。 - 进入求值,因为
moduleB依赖已经在求值栈里、没有其他前置依赖,所以先执行moduleB的顶层代码:export let b = a。 - 这一步要读取
a。a的绑定确实存在,但moduleA还没开始求值,绑定仍处于未初始化状态。 - 读取一个未初始化的
let绑定 →ReferenceError。整个模块图求值中断。
所以,循环依赖里「顶层直接读取对方变量」这件事,对 let / const 来说是直接报错,而不是拿到 undefined。
三、那什么时候才会得到 undefined?
答案是:当你用 var 的时候。
// moduleA.js
import { b } from './moduleB.js'
export var a = b
console.log('a', a)
// moduleB.js
import { a } from './moduleA.js'
export var b = a
console.log('b', b)
跑一下:
➜ test node moduleA.js
b undefined
a undefined
原因很直接:var 声明在实例化阶段就会被初始化为 undefined(变量提升),不存在 TDZ。所以读到的是 undefined,而不是报错。
function 声明同理,甚至更早就能用:
// moduleA.js
import { getB } from './moduleB.js'
export function getA() { return getB() }
// moduleB.js
import { getA } from './fA.js'
export function getB() { return 'B' }
函数声明在实例化阶段就被初始化成函数对象,所以函数之间互相调用完全没问题。
三种声明方式的差异:
| 导出方式 | 循环依赖顶层读取的结果 | 原因 |
|---|---|---|
let / const | 抛 ReferenceError(TDZ) | 求值前绑定未初始化 |
var | undefined | 实例化阶段就被初始化为 undefined |
function | 正常拿到函数 | 实例化阶段就完成初始化 |
核心一句话:undefined 是 var 的行为,不是 ES Module 循环依赖的通病。 把 let 换成 var 能「不报错」,但那只是把问题藏起来了。
四、live binding 真正生效的时机
那什么时候能看到 live binding 的威力?模块求值全部完成之后,再通过函数去修改变量。
// moduleA.js
import { b } from './moduleB.js'
export let a
export function setA(v) { a = v }
// moduleB.js
import { a } from './moduleA.js'
export let b
export function setB() { b = a }
// entry.js
import { a, setA } from './moduleA.js'
import { b, setB } from './moduleB.js'
setA(100)
setB()
console.log(a, b) // 100 100
这里顶层没有互相读取,所以不会触发 TDZ。等所有模块求值完成后,setA 改的是 moduleA 的绑定,setB 读到的是同一个绑定的最新值,entry 里 import 进来的 a、b 也随之更新。
这就是 live binding 和值拷贝的分水岭:import 拿到的是变量的引用,不是拍下来的快照。 只要修改发生在求值之后,所有引用方看到的值都是同步的。
五、和 CommonJS 的本质区别
很多人把 ES Module 的循环依赖和 CommonJS 混为一谈,其实是两种完全不同的模型。
// cA.cjs
const { b } = require('./cB.cjs')
let a = b
module.exports.a = a
module.exports.later = 'A-later'
// cB.cjs
const { a, later } = require('./cA.cjs')
let b = a
module.exports.b = b
// cEntry.cjs
const A = require('./cA.cjs')
const B = require('./cB.cjs')
console.log(A.a, A.later, B.b)
输出是:
undefined A-later undefined
同时 Node 还会给一句警告:
Warning: Accessing non-existent property 'a' of module exports inside circular dependency
CommonJS 的特征是:
- 模块边加载边执行,
module.exports是一个普通对象。 require返回的是这个对象的引用,但在循环依赖里,对方还没执行到赋值那一行,所以对象上是没有这个属性的。- 解构
const { a } = require(...)相当于按当前时刻的值做了一次拷贝,之后对方再赋值,也同步不过来。 - 访问一个还不存在的导出属性,拿到的是
undefined,同时给出 warning。
对比一下:
| 维度 | ES Module | CommonJS |
|---|---|---|
| 绑定方式 | live binding,变量引用 | module.exports 对象属性 |
| 顶层互相读取 | let/const 抛 ReferenceError,var 为 undefined | 未赋值属性为 undefined,附 warning |
| 执行时机 | 先全部实例化,再按序求值 | 边加载边执行 |
| 后赋值能否同步 | 能,引用方实时看到新值 | 解构拷贝不能,直接取属性可以 |
六、实践建议
循环依赖能不能避免,取决于架构,但有几条是确定的:
- 优先消除环。 把互相依赖的部分抽成一个第三方模块,让双方都依赖它,是最干净的解法。
- 不要在模块顶层读取对方。 如果环确实躲不掉,把读取动作放进函数里,延迟到求值之后再执行。
- 统一用
let/const,不要靠var掩盖问题。var只是把 TDZ 报错换成了undefined,bug 会更隐蔽。 - 接入静态检查。 用
madge --circular src或 ESLint 的import/no-cycle规则,在 CI 里直接拦住新引入的环。 - 理解报错信息。 看到
Cannot access 'x' before initialization时,先怀疑循环依赖,而不是变量名写错。
七、总结
- ES Module 循环依赖不是一律拿到
undefined;let/const在顶层互相读取会直接抛ReferenceError(TDZ)。 - ES Module 分构建、实例化、求值三个阶段,绑定在实例化时建立,值在求值时才产生,时间差就是问题根源。
var因为变量提升被初始化为undefined,function在实例化阶段即可用,所以它们不会触发 TDZ。- import 是 live binding,拿到的是引用;模块求值完成后再修改变量,所有引用方会同步更新。
- CommonJS 是值拷贝 +
module.exports对象,循环依赖表现为属性为undefined加一条 warning。 - 最佳实践:消除环,或把跨模块读取延迟到函数调用里执行。
想亲手验证,最简单的办法是把上面的例子保存成 .mjs 文件,直接 node entry.mjs 就能看到报错和 live binding 的实时效果。