前端进阶之旅前端进阶之旅
  • 基础篇HTML/CSS/JS 打底
  • 进阶篇原理与工程化
  • 高频篇面试最常问的那批
  • 精选篇按模块收敛的总结
  • 手写篇常考代码手写实现
  • 面经篇真实面试问题复盘
  • AI 篇NEWAI 时代的前端考点
  • 历年面经NEW按年份追踪真实考点
  • 每日一题每天一道,攒手感
  • 专项自测100 题快速查漏
  • 小程序题库小程序专项刷题
  • 算法题库NEW在线编码即时判题
  • 知识卡片NEW碎片时间过考点
  • 面试题大全常见问题解析
  • AI 答疑NEW随时提问,即时解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • AI 定制路线NEW按你的简历现排
  • AI 知识地图NEW串起全站知识点
  • 原理篇React / Vue 源码拆解
  • HTTP从报文一路讲到 HTTPS
  • 浏览器渲染、事件循环、进程
  • 计算机基础Linux、网络、操作系统
  • 设计模式23 种模式怎么用
  • Node学习指南从环境搭建到服务端
  • NPM工作流script、依赖与发布
  • Docker容器化部署上手
  • Canvas图形与动画实战
  • 前端系统进阶学习大型项目工程化
  • 前端综合文章长期沉淀的实践文
  • 思维导图知识点全景图
  • 学习路线按图索骥不跑偏
  • AI 热点NEWAI 每日动态
  • 公众号动态公众号历史文章
  • 博客动态站长的技术博客
  • 开发者导航常用工具与文档站
  • 基础篇HTML/CSS/JS 打底
  • 进阶篇原理与工程化
  • 高频篇面试最常问的那批
  • 精选篇按模块收敛的总结
  • 手写篇常考代码手写实现
  • 面经篇真实面试问题复盘
  • AI 篇NEWAI 时代的前端考点
  • 历年面经NEW按年份追踪真实考点
  • 每日一题每天一道,攒手感
  • 专项自测100 题快速查漏
  • 小程序题库小程序专项刷题
  • 算法题库NEW在线编码即时判题
  • 知识卡片NEW碎片时间过考点
  • 面试题大全常见问题解析
  • AI 答疑NEW随时提问,即时解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • AI 定制路线NEW按你的简历现排
  • AI 知识地图NEW串起全站知识点
  • 原理篇React / Vue 源码拆解
  • HTTP从报文一路讲到 HTTPS
  • 浏览器渲染、事件循环、进程
  • 计算机基础Linux、网络、操作系统
  • 设计模式23 种模式怎么用
  • Node学习指南从环境搭建到服务端
  • NPM工作流script、依赖与发布
  • Docker容器化部署上手
  • Canvas图形与动画实战
  • 前端系统进阶学习大型项目工程化
  • 前端综合文章长期沉淀的实践文
  • 思维导图知识点全景图
  • 学习路线按图索骥不跑偏
  • AI 热点NEWAI 每日动态
  • 公众号动态公众号历史文章
  • 博客动态站长的技术博客
  • 开发者导航常用工具与文档站
首页程序员面试题库Node.js CommonJS 循环依赖 require cache
NoNode.js模块与工具链

CommonJS 循环依赖为什么会读到尚未初始化完成的导出?

循环依赖最难排查的地方,是拿到了一个对象,却误以为这个对象背后的模块已经执行完毕。

前端进阶之旅 · 一题精讲更新于 2026.09.06
Node.js#模块与工具链#缓存
先看核心答案读代码示例
理解线索

沿着一次加载过程追踪对象

  1. 开始 A先创建导出并执行 A 的同步初始化
  2. 进入 BB 回头请求 A 时接收其当前导出
  3. 返回 A后续写属性只影响仍共享该对象的读取

这是 CommonJS 的具体加载行为;ESM 的绑定和初始化规则不同,不能把这个半成品对象模型直接套过去。

核心回答

先记住这个答案

CommonJS 首次加载模块时,会在执行完成前让它进入缓存。若 A 加载 B、B 又加载仍在执行的 A,Node 会把 A 当时的导出交给 B,避免无限递归;这份导出可能尚未初始化完成。B 立即复制的属性值不会在 A 完成后自动改写,保留同一个对象引用则可能读到后续属性更新。若 A 又把 module.exports 换成另一个对象,旧引用仍可能指向早先对象。修复时应梳理初始化时序,提取共同依赖或显式注入,而不是依赖偶然的入口加载顺序。

  • 进入缓存不代表模块初始化已经完成
  • 复制属性值与保留对象引用会得到不同观察
  • 重排 require 不能替代消除初始化环

先构造一个有可见状态的环

示例让 A 在加载 B 前写入起始状态,再在 B 返回后改成完成状态。B 同时保存一次属性读取结果和一个稍后读取的函数,这样能清楚看到值复制与共享对象的区别。

三个文件必须放在同一目录,并以 main.cjs 为进程入口运行。测试工具若预先加载了其中一个模块,缓存和入口路径会影响初始化顺序,因此排查时需要一个干净进程。

a.cjs、b.cjs 与 main.cjsText
// file: a.cjs
exports.phase = 'a:starting';
const b = require('./b.cjs');
exports.capturedByB = b.captured;
exports.readLater = b.readLater;
exports.phase = 'a:ready';

// file: b.cjs
const a = require('./a.cjs');
exports.captured = a.phase;
exports.readLater = () => a.phase;

// file: main.cjs
const a = require('./a.cjs');
console.log(a.phase);
console.log(a.capturedByB);
console.log(a.readLater());

运行 node main.cjs 会依次得到 a:ready、a:starting、a:ready。B 的 captured 保存旧字符串,readLater 则在 A 执行完成后读取同一导出对象的当前属性。

从报错位置回溯到初始化依赖

业务中常见两个服务相互引入,并在文件顶层立即创建实例或注册回调。报错可能发生在某个方法不存在的位置,但根因是方法赋值尚未执行,而不是文件路径找不到或类型声明写错。

可以把连接、日志或配置等共同能力下沉为第三个模块,再由入口组装两个服务。若对象确实互相协作,先创建对象再显式注入依赖,比在模块顶层互相拉取实例更容易表达生命周期。

检查对象替换和缓存操作的副作用

exports.foo 赋值通常修改当前导出对象,module.exports 重新赋值则改变后续加载拿到的值。环中已经收到旧对象的消费者不会自动切换引用,因此仅把赋值移到文件末尾可能让问题更隐蔽。

删 require.cache 再加载会重新执行模块副作用,可能留下重复监听器和连接,也无法修复其他模块手里的旧引用。热更新或测试隔离需要专门管理资源,不应把清缓存当作生产初始化方案。

回答前,多想一步

容易答错的地方

用延迟一毫秒掩盖初始化顺序
定时器可能让当前例子偶然成功,却把构造依赖变成隐式时间约定。负载、入口或测试环境变化后仍会出错;应把需要等待的条件写成明确初始化步骤或拆开模块依赖。
认为有缓存就一定是完整单例
缓存解决重复加载和循环递归,不保证消费者在任何时刻都看到完成状态。单例还可能被不同解析路径或独立进程隔开,应分别确认模块身份、对象引用和初始化完成条件。
试着用自己的话回答

面试官还会怎么问?

解构 require 的属性会持续更新吗?

普通解构就是当时的属性读取和变量赋值。若得到一个字符串,后续导出对象改写该属性不会更新这个变量;若得到对象引用,其内部属性变化另当别论,需要按具体引用关系分析。

为什么换一个启动文件后故障消失?

环的进入点改变后,谁先执行到导出赋值可能不同,于是未完成状态恰好不再被读取。这说明系统依赖隐含的加载顺序,应使用不同入口复现并整理依赖,而不能把换入口当作完整修复。

延迟 require 是否完全不允许?

在函数调用时再加载可以延后读取,某些场景有实际用途,但仍需要明确调用发生在初始化完成之后。若两个组件在构造时必然互相需要,单纯延迟加载只是把同一个环移到另一处。

从一道题,走向一组知识

把知识连起来

模块与工具链

Node.js 怎样判断 .js 文件是 CommonJS 还是 ESM?

先确认文件到底采用哪套模块加载与初始化规则。

模块与工具链

ESM 导入 CommonJS 时,为什么默认导入可用而命名导入报错?

继续区分互操作时的属性复制与模块绑定更新。

参考资料

  • Node.js 22:CommonJS 缓存与循环依赖

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

本题目录
  1. 先记住这个答案
  2. 先构造一个有可见状态的环
  3. 从报错位置回溯到初始化依赖
  4. 检查对象替换和缓存操作的副作用
  5. 容易答错的地方
  6. 面试官还会怎么问
  7. 把知识连起来
读懂,再试着讲出来

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

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