先记住这个答案
ESM 加载分两步。第一步是链接:把所有模块的导入和导出连起来,import { a } 里的 a 直接指向导出方的那个变量,这叫实时绑定。第二步才是执行:从依赖图最深处开始,每个模块执行一次,遇到正在执行的模块就跳过,所以循环不会死循环。CommonJS 没有链接这一步,require 执行到一半的模块时,只能拿到它当前的 exports 对象,还没赋值的属性自然是 undefined。
要注意题目的前提并不完全对。用 let、const 导出时 ESM 确实不会给你 undefined,但如果在对方初始化之前读它的 let 或 const 导出,会直接抛 ReferenceError。
- ESM 先链接后执行,导入的是对方变量本身
- CommonJS 循环时拿到半成品
exports,缺的属性是undefined - ESM 提前读
let或const导出会抛ReferenceError - 函数声明会提升,循环里提前调用通常没问题
同一个循环,两种结果
a 导入 b,b 又导入 a,入口是 a。b 在自己的顶层读 a 的导出。
// ===== CommonJS 版本 =====
// a.cjs(入口)
exports.x = 1
require('./b.cjs')
exports.y = 2
// b.cjs
const a = require('./a.cjs') // a 执行到一半,拿到的是半成品 exports
console.log(a.x, a.y) // 1 undefined
// ===== ESM 版本 =====
// a.mjs(入口)
import { b } from './b.mjs'
export let a = 'A'
// b.mjs
import { a } from './a.mjs'
try {
console.log(a)
} catch (e) {
console.log(e.message) // Cannot access 'a' before initialization
}
setTimeout(() => console.log(a)) // 'A',等 a.mjs 执行完再读就正常
export const b = 'B'CommonJS 那边,a.cjs 执行到 require 时停下来去执行 b.cjs。b.cjs 再 require('./a.cjs'),Node.js 发现 a.cjs 已在缓存里,直接返回它当前的 exports,这时只有 x。Node.js 还会打一条警告,说在循环依赖里访问了不存在的属性。
ESM 那边,a.mjs 的 import 会先执行 b.mjs。b.mjs 读 a 时,a.mjs 还没执行到 let a = 'A',a 处于暂时性死区,所以抛出 ReferenceError。放到 setTimeout 里晚一点读,a.mjs 已经执行完,读到的就是 'A'。
为什么 ESM 能读到后来的值
关键在「导入的是什么」。CommonJS 的 require 返回一个对象,你拿到的是对象当时的状态。如果还把属性解构出来 const { y } = require('./a.cjs'),那更是把 undefined 永久存进了自己的变量,对方后来赋值也看不到。
ESM 的导入名不是拷贝,是一个指向对方变量的引用。对方变量的值变了,这边读到的就变了。所以只要读取发生在对方初始化之后,比如在函数里、在事件回调里读,循环依赖在 ESM 里就能正常工作。
执行顺序也是确定的:深度优先,先执行依赖再执行自己,一个模块只执行一次。入口是 a 时,b 先执行完,a 后执行。换 b 当入口,顺序就反过来。所以循环依赖的代码能不能跑,有时还取决于谁是入口。
项目里怎么处理循环依赖
能用的原则只有一条:模块顶层不要立刻使用循环另一方的导出,只在函数里用。函数调用时对方通常已经执行完了。
如果顶层确实要用,说明两个模块职责缠在一起了。常见修法是把双方都依赖的那部分抽到第三个模块,让循环变成两个模块共同依赖一个底层模块。项目里可以用 madge --circular 这类工具扫一遍依赖图,找出循环。
容易答错的地方
- 说 ESM 循环依赖完全不会出问题
- 不会出现
undefined,但会出现ReferenceError。let、const、class导出在声明执行前都读不到,只有函数声明因为提升能提前用。 - 以为用
var导出就能避免报错 var没有暂时性死区,提前读不会报错,但读到的是undefined,又回到了 CommonJS 的问题,只是换了个表现。- 以为循环依赖会导致死循环
- 两种模块系统都会缓存正在加载的模块,第二次遇到它时直接返回,不会重新执行,所以不会无限递归。问题只在于拿到的值是不是已经初始化。
面试官还会怎么问?
Webpack 打包后的循环依赖还是 ESM 的行为吗?
大体一致。Webpack 会把 ESM 导出编译成 getter,导入方每次访问都去读对方模块里的变量,模拟实时绑定,提前读 let 或 const 同样会报错。例外是代码先被 Babel 降级成了 var,这时暂时性死区没了,提前读拿到的就是 undefined,表现和 CommonJS 一样。
怎么在 CommonJS 里避免拿到 undefined?
不要在模块顶层解构 require 的结果,保留整个对象,在函数里用到时再读 a.y,那时对方已经执行完。另一个办法是把 require 挪到函数内部,延迟到真正需要时再加载。
为什么 ESM 能做这种提前链接,CommonJS 不行?
ESM 的 import 和 export 是静态语法,必须写在顶层、路径是字符串常量,引擎不执行代码就能知道每个模块导入导出了什么。CommonJS 的 require 是普通函数调用,可以写在 if 里、路径可以拼接,只有执行到才知道依赖谁,没法事先链接。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。