Webpack 5 发布以来,前端构建领域围绕它的讨论从未停止。除了广为人知的持久化缓存和更好的 Tree Shaking,Webpack 5 在模块图(Module Graph)层面也做了大量重构,其中一个容易被忽视但对大型项目影响深远的点,就是循环依赖的处理策略。很多团队把项目从 Webpack 4 迁移到 Webpack 5 后会发现:原来能跑的代码突然报 undefined,或者构建警告里出现了一堆 circular dependency 的提示。这背后正是模块解析机制变化带来的影响。

循环依赖到底是怎么回事
循环依赖指的是模块 A 引用了模块 B,而模块 B 又直接或间接引用了模块 A,形成一个闭环。在 JavaScript 的模块系统中,这并不是语法错误,构建工具也不会直接阻止你打包,但它会在运行时产生一系列难以排查的问题,比如导入的值是 undefined、类还没被定义就被继承、配置对象在初始化时缺少某些字段等。
p>理解循环依赖的关键在于理解模块的加载时序。以 CommonJS 为例,模块的导出值是在运行时逐个赋值的。当模块 A 先被执行,执行到require('./b') 时,引擎会立即执行模块 B 的代码,而此时模块 A 还没有执行完,B 拿到的只是一个未填充完成的 A 模块导出对象。
// a.js
console.log('a 开始执行');
const b = require('./b');
console.log('a 中拿到 b的值:', b.value);
module.exports = { value: 'from-a' };
// b.js
console.log('b 开始执行');
const a = require('./a'); // 此时 a 还没执行完,a.value 是 undefined
console.log('b 中拿到 a 的值:', a.value); // undefined
module.exports = { value: 'from-b' };
而 ES Module 采用的是静态声明式的导出绑定,模块在实例化阶段就确定了导出关系,执行阶段只是填充值。这意味着即使存在循环,B 拿到的是 A 的导出变量的引用,等 A 执行完成后再去读取,就能拿到正确的值。Webpack 在打包时会把 ESM 编译成类似 CommonJS 的运行时模块,因此循环依赖的行为最终取决于源码里用的是哪种模块语法。
Webpack 5 在模块图层面的改进
Webpack 4 的模块图是基于 chunk 的分裂结构维护的,模块之间的依赖关系分散在不同的数据结构中,循环依赖的分析能力有限。Webpack 5 重构为统一的 ModuleGraph,所有模块、导出、导入关系都收敛到一张图里。这不仅提升了构建性能,也让 Webpack 能够更精确地识别循环引用,并在开发模式下给出更有价值的警告信息。
配合增强的 Tree Shaking,Webpack 5 可以做模块连接优化:当检测到 ESM 模块之间只存在简单的重导出关系时,会尝试将它们合并处理,减少中间层的跳转。对于循环依赖,Webpack 5 在分析导出时会更准确地标记哪些导出在循环路径上被使用了,从而避免误删看似没用但实际上被循环引用到的代码。
另一个相关改进是 optimization.usedExports 与 optimization.providedExports 的配合更加智能。在循环场景下,Webpack 4 有时会错误地认为某个导出未被使用而将其裁剪掉,导致运行时取值为 undefined。Webpack 5 通过对模块图的遍历顺序调整,显著减少了这类误判。如果你的项目迁移后出现取值异常,可以先把 optimization.usedExports 关掉来验证是否是裁剪导致的问题。
// webpack.config.js
module.exports = {
mode: 'development',
optimization: {
usedExports: true,
// 循环依赖排查期间可临时关闭副作用分析
sideEffects: true,
},
// 开启循环依赖警告
plugins: []
};
需要注意的是,Webpack 本身默认不会对循环依赖报错,只会输出警告。如果想把循环依赖当作构建失败来处理,可以使用 circular-dependency-plugin 或社区的检测工具,在 CI 流水线中强制拦截,防止循环规模越滚越大。
持久化缓存与 Module Federation 带来的变化
持久化缓存是 Webpack 5 最受关注的特性之一。通过配置 cache: { type: 'filesystem' },构建产物和模块依赖关系会被缓存到磁盘,二次构建速度可以提升一个数量级。而缓存能够安全复用的前提,正是模块图对依赖关系的精确记录,包括循环依赖的边。这意味着即使项目里存在循环引用,缓存失效的粒度也能控制在最小范围内,不会因为一个模块改动导致整个循环链路全部重新构建。
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
// 配置文件变化时让缓存失效
config: [__filename],
},
cacheDirectory: path.resolve(__dirname, '.webpack_cache'),
},
};
Module Federation 则从架构层面提供了解决循环依赖的新思路。当一个巨型单仓库中多个应用、多个公共库相互引用形成蛛网般的循环时,可以把公共部分抽出来,通过 exposes 和 remotes 声明为远程模块,由宿主应用统一协调加载。这样源码层面的循环被拆成了运行时的按需加载关系,模块之间的耦合从代码依赖变成了契约依赖,结构上清晰得多。
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
sharedLib: 'sharedLib@http://cdn.ipipp.com/remoteEntry.js',
},
shared: { react: { singleton: true } },
}),
],
};
不过要提醒的是,联邦模块本身也可能引入新的循环:宿主引用远程,远程又通过 shared 依赖宿主提供的内容。配置 singleton: true 共享关键依赖,可以有效避免这类隐性循环导致的重复实例和状态不一致问题。
如何排查和拆解项目中的循环依赖
无论工具多智能,循环依赖本身都是一种坏味道,长期来看还是要在代码结构上解决。排查的第一步是可视化,可以使用 madge 工具生成模块依赖图并高亮循环路径:
# 安装并检测循环依赖 npm install -g madge madge --circular --extensions js,jsx src/ # 生成可视化依赖图 madge --image dependency.svg src/
找到循环后,常见的拆解手段有三种。第一种是提取公共部分:如果 A 和 B 相互引用是因为都依赖某个共享逻辑,那就把这部分抽到独立的 C 模块中,循环自然解开。第二种是延迟引用:把顶层的 import 改为函数内部的动态 import,让依赖在调用时才建立,切断加载期的闭环。第三种是事件解耦:用事件总线或回调把反向通知从直接调用中剥离出来,特别适合状态管理和 UI 组件之间的循环。
另外建议在项目规范中明确模块的分层原则:底层工具模块不得引用上层业务模块,跨层引用必须通过接口抽象。配合 ESLint 的 import/no-cycle 规则,可以在编码阶段就阻止新的循环依赖进入代码库。
// .eslintrc.js
module.exports = {
rules: {
'import/no-cycle': ['error', { maxDepth: 'infinity' }],
},
};
总的来说,Webpack 5 的模块图重构让循环依赖的识别和分析能力上了一个台阶,配合持久化缓存和模块联邦,工程侧已经具备了应对复杂依赖网络的基础设施。但工具只能缓解症状,保持模块职责单一、依赖方向清晰,才是避免陷入循环泥潭的根本办法。
Webpack 5循环依赖Module Federation修改时间:2026-09-01 22:34:42