创见博客
ES Module 循环依赖
七崽爱吃小饼干2026/09/15阅读 0

ES Module 的循环依赖不会像 CommonJS 那样安静地给你一个 undefined,它会直接抛出 ReferenceError: Cannot access 'a' before initialization。

下面这段代码,很多人第一反应会认为 a、b 最终都是 undefined。真实结果不是。

js
// moduleA.js
import { b } from './moduleB.js'
export let a = b

// moduleB.js
import { a } from './moduleA.js'
export let b = a

在 Node 里跑一下:

text
➜  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 的执行流程拆开。规范里它分成三步:

  1. 构建(Construction):从入口出发,深度优先解析所有 import,把整个模块图搭出来。遇到已经加载中的模块不会重复加载,只是记录下来,环就停在这里。
  2. 实例化(Instantiation):为每个模块创建环境记录,把 export 的绑定提前准备好。注意,这一步只建立绑定,不算值。let / const 的绑定此刻处于「未初始化」状态(也就是大家常说的暂时性死区 TDZ)。
  3. 求值(Evaluation):按依赖顺序真正执行每个模块的顶层代码,这时候绑定才被赋上真正的值。

重点:绑定在实例化阶段就建好了,值要到求值阶段才有。 这两段时间差,就是循环依赖所有怪异现象的源头。

而 import { b } 拿到的不是一份拷贝,而是指向 moduleB 里那个 b 绑定的引用。这就是 live binding。

二、逐步复盘:为什么是 ReferenceError

把上面那段代码按阶段走一遍:

  1. 入口开始加载 moduleA,构建阶段发现它依赖 moduleB。
  2. moduleB 又依赖 moduleA。此时 moduleA 已经在「加载中」,不再重复加载,环闭合。
  3. 进入实例化:moduleA 的 a、moduleB 的 b 两个绑定都建立了,但都是未初始化。
  4. 进入求值,因为 moduleB 依赖已经在求值栈里、没有其他前置依赖,所以先执行 moduleB 的顶层代码:export let b = a。
  5. 这一步要读取 a。a 的绑定确实存在,但 moduleA 还没开始求值,绑定仍处于未初始化状态。
  6. 读取一个未初始化的 let 绑定 → ReferenceError。整个模块图求值中断。

所以,循环依赖里「顶层直接读取对方变量」这件事,对 let / const 来说是直接报错,而不是拿到 undefined。

三、那什么时候才会得到 undefined?

答案是:当你用 var 的时候。

js
// 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)

跑一下:

text
➜  test node moduleA.js
b undefined
a undefined

原因很直接:var 声明在实例化阶段就会被初始化为 undefined(变量提升),不存在 TDZ。所以读到的是 undefined,而不是报错。

function 声明同理,甚至更早就能用:

js
// 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)求值前绑定未初始化
varundefined实例化阶段就被初始化为 undefined
function正常拿到函数实例化阶段就完成初始化

核心一句话:undefined 是 var 的行为,不是 ES Module 循环依赖的通病。 把 let 换成 var 能「不报错」,但那只是把问题藏起来了。

四、live binding 真正生效的时机

那什么时候能看到 live binding 的威力?模块求值全部完成之后,再通过函数去修改变量。

js
// 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 混为一谈,其实是两种完全不同的模型。

js
// 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)

输出是:

text
undefined A-later undefined

同时 Node 还会给一句警告:

text
Warning: Accessing non-existent property 'a' of module exports inside circular dependency

CommonJS 的特征是:

  • 模块边加载边执行,module.exports 是一个普通对象。
  • require 返回的是这个对象的引用,但在循环依赖里,对方还没执行到赋值那一行,所以对象上是没有这个属性的。
  • 解构 const { a } = require(...) 相当于按当前时刻的值做了一次拷贝,之后对方再赋值,也同步不过来。
  • 访问一个还不存在的导出属性,拿到的是 undefined,同时给出 warning。

对比一下:

维度ES ModuleCommonJS
绑定方式live binding,变量引用module.exports 对象属性
顶层互相读取let/const 抛 ReferenceError,var 为 undefined未赋值属性为 undefined,附 warning
执行时机先全部实例化,再按序求值边加载边执行
后赋值能否同步能,引用方实时看到新值解构拷贝不能,直接取属性可以

六、实践建议

循环依赖能不能避免,取决于架构,但有几条是确定的:

  1. 优先消除环。 把互相依赖的部分抽成一个第三方模块,让双方都依赖它,是最干净的解法。
  2. 不要在模块顶层读取对方。 如果环确实躲不掉,把读取动作放进函数里,延迟到求值之后再执行。
  3. 统一用 let / const,不要靠 var 掩盖问题。 var 只是把 TDZ 报错换成了 undefined,bug 会更隐蔽。
  4. 接入静态检查。 用 madge --circular src 或 ESLint 的 import/no-cycle 规则,在 CI 里直接拦住新引入的环。
  5. 理解报错信息。 看到 Cannot access 'x' before initialization 时,先怀疑循环依赖,而不是变量名写错。

七、总结

  1. ES Module 循环依赖不是一律拿到 undefined;let / const 在顶层互相读取会直接抛 ReferenceError(TDZ)。
  2. ES Module 分构建、实例化、求值三个阶段,绑定在实例化时建立,值在求值时才产生,时间差就是问题根源。
  3. var 因为变量提升被初始化为 undefined,function 在实例化阶段即可用,所以它们不会触发 TDZ。
  4. import 是 live binding,拿到的是引用;模块求值完成后再修改变量,所有引用方会同步更新。
  5. CommonJS 是值拷贝 + module.exports 对象,循环依赖表现为属性为 undefined 加一条 warning。
  6. 最佳实践:消除环,或把跨模块读取延迟到函数调用里执行。

想亲手验证,最简单的办法是把上面的例子保存成 .mjs 文件,直接 node entry.mjs 就能看到报错和 live binding 的实时效果。

评论
0/100