Loop Universe 这个说法并不是 Webpack 5 官方文档里的标准术语,它更像社区对循环依赖问题的一种形象概括:模块 A 依赖模块 B,模块 B 又依赖模块 A,形成一个闭合的引用环,就像一个小型宇宙一样自转。Webpack 作为打包工具,必须为每个模块生成独立的执行函数,并按照依赖图的拓扑顺序依次初始化。一旦出现循环,拓扑排序就不再是简单的线性顺序,模块的导出值会在初始化中途被访问到,于是出现 undefined、空对象或者函数声明提升带来的假正常。Webpack 5 在模块 ID 稳定化、持久化缓存以及按需加载方面的改进,虽然没有直接消除循环依赖,却让排查和规避这种问题有了更多手段。

循环依赖产生的底层原因
在 CommonJS 环境下,require 是同步执行的,Node.js 以及 Webpack 的模块包装函数都采用缓存优先的策略。当模块 A 第一次被加载时,它会在模块缓存中先放置一个空对象 exports,然后开始执行模块体。如果此时模块 A 内部 require('./b'),而模块 B 又 require('./a'),那么模块 B 拿到的将是模块 A 尚未填充完成的导出对象。对于直接给 exports 添加属性的写法,后续在模块 A 执行完毕后,B 中持有的引用会看到更新后的值;但如果 A 使用 module.exports = something 整体替换,那么 B 中拿到的仍然是旧的空对象,这是循环依赖中最容易踩坑的地方。
Webpack 的模块系统本质上也是模拟 CommonJS 行为,只不过每个模块被包裹在类似 __webpack_require__ 的函数里。官方开发者工具在打包后生成的源码可以清晰看到:每个模块都被表示为 __webpack_require__(/*! ./a */ "./src/a.js") 这样的形式,模块的定义体被放进一个数组或对象,由运行时统一调度。循环依赖在浏览器端的表现和 Node.js 类似,但因为 Webpack 会在构建阶段做模块合并和 tree shaking,有时循环引用会被部分消除,有时则会因为副作用判断不准确而保留下来,导致运行时出现难以复现的 bug。
ESM 的循环依赖行为与 CommonJS 有本质区别。ESM 的导入是绑定关系,不是值的拷贝,模块在解析阶段就确定了导出名,执行阶段遇到循环时,会先返回一个未初始化的绑定,如果在模块体顶层立即访问该绑定,就会抛出 ReferenceError。Webpack 5 对 ESM 的支持更加完善,在编译时会尽量遵循原生 ESM 的语义,但对于循环依赖,仍建议在代码层面避免在顶层直接使用被导入的变量,而改用函数作用域内的延迟访问。
Webpack 5 在循环依赖场景下的关键变化
Webpack 5 引入持久化缓存后,模块的编译结果和依赖关系可以被序列化到磁盘。在增量构建时,只有发生变化的模块会重新编译,其余模块直接复用缓存。这个特性对循环依赖的影响在于:模块 ID 的生成不再依赖构建顺序的随机性。Webpack 5 默认使用 deterministic 类型的模块 ID,根据模块路径和内容生成稳定的数字 ID,而不是像 Webpack 4 那样默认按出现顺序自增。循环依赖的模块往往会被打包到同一个 chunk 中,稳定的 ID 可以保证每次构建产出的模块引用关系一致,这对于用 sourcemap 定位循环依赖帮助很大。
另外,Webpack 5 在代码分割上做了更细粒度的控制。splitChunks 的默认配置会将公共依赖提取为单独 chunk,但循环依赖的模块之间如果强行拆分,可能会导致运行时加载顺序错乱。Webpack 5 在优化循环依赖模块的 chunk 分配时,会优先保证它们处于同一个 chunk 中,避免异步加载时出现一个模块尚未初始化就被另一个模块访问的情况。下面是一个简单的配置示例,通过显式指定缓存组来减少循环模块被拆散的风险:
module.exports = {
mode: 'production',
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
},
optimization: {
moduleIds: 'deterministic',
splitChunks: {
cacheGroups: {
shared: {
test: /[\\/]src[\\/]shared[\\/]/,
name: 'shared',
chunks: 'all',
minChunks: 2,
enforce: true
}
}
}
}
};
在上面的配置中,cache.type 设置为 filesystem 会启用持久化缓存,moduleIds 使用 deterministic 保证模块 ID 稳定。对于存在循环引用的公共模块,通过 enforce: true 把它们强制合并到同一个 chunk,可以避免因异步加载顺序导致的初始化不完整。需要注意的是,强制合并并不解决循环依赖本身,只是降低运行时问题的出现概率,真正的修复还是要从代码结构入手。
Webpack 5 还改进了模块联邦机制。在微前端架构中,多个应用之间共享模块时,循环依赖可能跨越应用边界。模块联邦允许一个应用暴露自己的模块供其他应用远程加载,如果远程模块又反向依赖宿主应用的模块,就会形成跨应用的循环。Webpack 5 的模块联邦运行时对这类情况有一定的容错,会在远程模块初始化时注入宿主模块的引用,但由于网络异步加载的特性,跨应用循环比单应用内的循环更危险,建议通过契约设计明确依赖方向,避免双向共享。
如何检测和治理循环依赖
循环依赖在大型项目里往往不是一次写出来的,而是经过多次迭代后不知不觉引入的。靠肉眼检查 import 关系效率很低,借助构建插件可以自动扫描模块图。常用的工具是 circular-dependency-plugin,它可以在 Webpack 构建过程中分析每个模块的依赖关系,发现环状结构时输出警告或直接报错。配置方式如下:
const CircularDependencyPlugin = require('circular-dependency-plugin');
module.exports = {
plugins: [
new CircularDependencyPlugin({
exclude: /node_modules/,
include: /src/,
failOnError: true,
allowAsyncCycles: false,
cwd: process.cwd()
})
]
};
这个插件会在每次构建时检查模块图,如果发现类似 A -> B -> C -> A 的循环,就会根据 failOnError 的配置决定是中断构建还是仅输出警告。把 failOnError 设为 true 可以强制开发者在提交前修复循环依赖,但初期可能会因为历史遗留问题而频繁失败,建议先在 CI 环境中输出警告,逐步清理后再升级为错误。另一个检查维度是使用 ESLint 的 import/no-cycle 规则,它基于静态分析,可以在编码阶段给出提示,但误报率相对较高,适合作为辅助手段。
治理循环依赖最有效的方式是调整代码结构。对于两个类互相引用的场景,可以引入第三个模块作为接口层,让 A 和 B 都依赖接口模块,而不是直接依赖对方。例如在 TypeScript 项目中定义 ServiceInterface,A 类实现接口并注册到服务容器,B 类通过容器获取 A 的实例,这样模块之间的依赖方向就变成单向的。对于函数循环调用的情况,则可以考虑把被循环调用的函数提取到独立模块,或者使用回调注入的方式打破直接依赖。
另一种常见手法是延迟加载。把 require 或 import 从模块顶层移到函数内部,这样在模块初始化阶段不会触发对另一方的请求,循环环就被打断。但要注意,延迟加载只是把问题推迟到运行时,并没有从根本上消除循环,如果调用时机不当仍然会出错。更适合的做法是利用 Webpack 的异步 chunk 功能,通过动态 import 让循环依赖变成异步加载,虽然会增加网络请求,但在某些场景下可以接受。不过动态 import 也会带来额外的代码分割和运行时开销,需要根据项目实际情况权衡。
总结与工程实践建议
Webpack 5 并没有一个叫做 Loop Universe 的官方特性,但循环依赖问题在工程实践中确实被广泛讨论。Webpack 5 的持久化缓存、确定性模块 ID 和精细的 chunk 控制,为分析循环依赖提供了更稳定的构建环境,让问题更容易复现和定位。相比 Webpack 4 时期构建顺序随机导致的模块 ID 抖动,Webpack 5 在这方面的进步是实实在在的,很多过去只在生产环境偶发的循环依赖问题,现在在开发阶段就能暴露出来。
前端工程的模块依赖图本质上是一个有向图,循环依赖就是图中出现了环。理论上可以通过拓扑排序检测环的存在,但 Webpack 在打包时并不会直接报错,因为 CommonJS 的运行时语义允许循环出现。这意味着开发者需要建立自己的防线:在构建阶段使用检测插件,在编码阶段使用 ESLint 规则,在代码评审阶段关注新增的 import 关系。三者结合才能有效控制循环依赖的增长。
对于已经存在循环依赖的旧项目,不建议一次性全部修复,可以按照模块使用频率和影响范围排序,优先处理核心模块。修改时尽量保证每次提交只解决一个循环环,并用插件验证构建结果。如果循环依赖涉及第三方库或框架内部,通常无法直接修改,可以通过配置 resolve.alias 将相关模块替换为自己的适配版本,或者用依赖注入容器隔离第三方模块与业务代码。
最终目标是让模块依赖图保持清晰的有向无环结构,即使偶尔出现环,也要控制在可控范围内,并留下明确的检测记录。Webpack 5 为这种工程化治理提供了更好的基础设施,但真正的主动权始终掌握在开发者的代码设计手里。