前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
首页程序员面试题库ESM 循环依赖处理机制
JavaScript模块与语言机制

ESM 遇到循环依赖是怎么处理的?和 CommonJS 拿到半成品对象有什么不同?

ESM 分两步:先把所有模块的导入导出连成实时绑定,再按依赖顺序执行,遇到已经在执行中的模块就跳过,不会死循环。CommonJS 的 require 边执行边导出,循环时拿到的是对方还没填完的 exports 对象,所以会出现 undefined。ESM 也不是万能的,在对方初始化前读 let 或 const 导出,会直接抛 ReferenceError。

前端进阶之旅 · 一题精讲更新于 2026.09.29
JavaScript#模块与语言机制
先看核心答案读代码示例
理解线索

两种模块系统遇到循环时拿到什么

  1. CommonJSrequire 返回对方当前的 exports 对象,没执行到的属性是 undefined
  2. ESM 实时绑定导入名直接指向导出方的变量,对方赋值后这边立刻能读到
  3. 暂时性死区对方的 let 或 const 还没执行到声明,读取就抛 ReferenceError

ESM 的好处是错误暴露得早:CommonJS 静默给你 undefined,可能到很后面才出问题;ESM 在读的那一行直接报错,定位更快。

核心回答

先记住这个答案

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 和 ESM 的对比JavaScript
// ===== 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 里、路径可以拼接,只有执行到才知道依赖谁,没法事先链接。

从一道题,走向一组知识

把知识连起来

模块与语言机制

循环依赖里,对方导出的是函数声明,我能在顶层直接调用吗?

循环依赖里导出函数声明为什么能提前调用,是本题机制最常见的一个应用。

模块与语言机制

循环依赖里,一个模块在顶层读另一方导出的 let 变量,会发生什么?

专门讲循环依赖里提前读 let 导出为什么会抛 ReferenceError。

参考资料

  • JavaScript:模块与语言机制参考资料

示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。

本题目录
  1. 先记住这个答案
  2. 同一个循环,两种结果
  3. 为什么 ESM 能读到后来的值
  4. 项目里怎么处理循环依赖
  5. 容易答错的地方
  6. 面试官还会怎么问
  7. 把知识连起来
读懂,再试着讲出来

先看核心答案,再读代码。最后展开追问,检查自己有没有遗漏边界。

试着回答追问
浏览全部面试题理解原理,也关注真实的使用场景。回到顶部 ↑