先记住这个答案
CommonJS 首次加载模块时,会在执行完成前让它进入缓存。若 A 加载 B、B 又加载仍在执行的 A,Node 会把 A 当时的导出交给 B,避免无限递归;这份导出可能尚未初始化完成。B 立即复制的属性值不会在 A 完成后自动改写,保留同一个对象引用则可能读到后续属性更新。若 A 又把 module.exports 换成另一个对象,旧引用仍可能指向早先对象。修复时应梳理初始化时序,提取共同依赖或显式注入,而不是依赖偶然的入口加载顺序。
- 进入缓存不代表模块初始化已经完成
- 复制属性值与保留对象引用会得到不同观察
- 重排 require 不能替代消除初始化环
先构造一个有可见状态的环
示例让 A 在加载 B 前写入起始状态,再在 B 返回后改成完成状态。B 同时保存一次属性读取结果和一个稍后读取的函数,这样能清楚看到值复制与共享对象的区别。
三个文件必须放在同一目录,并以 main.cjs 为进程入口运行。测试工具若预先加载了其中一个模块,缓存和入口路径会影响初始化顺序,因此排查时需要一个干净进程。
// 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 是否完全不允许?
在函数调用时再加载可以延后读取,某些场景有实际用途,但仍需要明确调用发生在初始化完成之后。若两个组件在构造时必然互相需要,单纯延迟加载只是把同一个环移到另一处。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。