三个月前,我们把一个 B 端 SaaS 平台从 Webpack 5 的 Module Federation 1.0 迁到了 2.0。主应用加 6 个远程团队的子应用,涉及 React 18、antd 5.x、三套不同版本的 lodash,外加一个用了 moment 死活不肯迁 dayjs 的老团队。
迁完当天,线上白屏了。
控制台报了一个极其隐晦的错:Shared module is not available for eager consumption。查了两个小时才定位到根因------两个子应用对 react-dom 的版本协商结果不一致,一个拿到了 18.2.0,另一个拿到了 18.3.1。而 18.3.1 那个由于加载顺序的问题,在协商窗口关闭后才注册上来,直接被跳过了。
这篇文章是那次翻车之后,团队花三周搞出来的调优方案和诊断工具链的复盘。
MF 2.0 的共享运行时到底在干什么
协商窗口------最容易翻车的地方
关键问题来了:什么时候协商?
MF 2.0 有一个隐式的"协商窗口"概念。主应用调用 init() 初始化共享作用域后,会等待所有已知的远程容器注册完它们的共享模块,然后进入消费阶段。一旦某个模块开始消费某个共享依赖,协商窗口就关闭了------后来者注册的版本不会被纳入考量。
用时间线表示会更直观:
kotlin
init() → 注册窗口打开
Remote A 注册 react@18.2.0
Remote B 注册 react@18.3.1
Remote A 消费 react → 协商:选 18.3.1
════════ 协商窗口关闭 ════════
Remote C 注册 react@18.2.0 来晚了
Remote C 消费 react → 拿到 18.3.1
(如果 C 配了 ~18.2.0 + strictVersion: true → 直接报错)
我们线上白屏就是这个时序问题。Remote C 是一个懒加载的子应用,用户点击菜单才加载,注册得晚,但它的 react-dom 配了 strictVersion: true,协商结果不满足它的版本要求,页面直接崩了。
版本协商算法:不只是 semver 匹配
默认协商策略的三层逻辑
MF 2.0 的版本协商不是简单的"找最新版",而是一个三层决策逻辑。
第一层是 singleton 模式判断。如果某个包被标记为 singleton,全局只保留一个版本,取已注册版本中最高的那个。这时如果同时配了 strictVersion,而最高版本不满足消费者的 requiredVersion,就会直接抛错;不配 strictVersion 的话,即使版本不匹配也硬上。
第二层是 semver 范围匹配。在所有已注册版本中,筛选出满足消费者 requiredVersion 范围的,取其中最高的。
第三层是兜底。没有满足条件的版本时,先尝试消费者自带的 fallback 版本;fallback 也没有的话,看 strictVersion ------配了就报错,没配就拿最高版本硬上,赌一把兼容性。
这三层逻辑在文档里分散在好几个地方,拼起来才能看到全貌。实际运行时还要考虑 eager 标记的影响------eager: true 的模块会在 init() 阶段就被加载,直接跳过协商窗口。
singleton 的隐式降级------我们踩过最阴的坑
react 和 react-dom 几乎所有人都会配 singleton: true,这没问题。
我们有一个子应用依赖了 React 18.3.1 新增的 useFormStatus hook,但全局协商结果是 18.2.0(主应用锁了 18.2.0 且最先注册)。子应用拿到了 18.2.0 的 React,调用 useFormStatus 时直接 undefined is not a function。
排查这个问题花了大半天,因为没有任何 warning。看配置,一切正常;看网络请求,React 确实加载了;看版本号------这一步才意识到拿到的不是预期版本。教训很明确:
ts
// 只写 singleton → 版本不匹配时静默降级,运行时才爆炸
react: { singleton: true, requiredVersion: '^18.3.0' }
// 加上 strictVersion → 至少报一个明确的错误
react: { singleton: true, strictVersion: true, requiredVersion: '^18.3.0' }
两行配置的差别,决定了你是花 10 分钟看报错信息定位问题,还是花半天在毫无线索的情况下大海捞针。
远程模块热更新:比想象中复杂得多
静态远程 vs 动态远程
MF 2.0 支持两种远程模块加载方式。静态远程在构建时确定入口 URL,动态远程在运行时决定从哪里加载。热更新的难度完全不同。静态远程的"热更新"其实是个伪命题------URL 不变,浏览器缓存不失效,用户刷新页面才能拿到新版本。
动态远程才有真正的热更新能力。核心流程是:从配置中心拿到最新的远程入口 URL(带版本 hash),动态初始化远程容器,让新容器和当前的共享作用域完成握手,再获取模块工厂。
热更新的真正难点:共享依赖的状态一致性
假设 Team B 发布了子应用新版本,主应用通过动态远程加载了新的 remoteEntry.js,新版本把 antd 从 5.12 升到了 5.15。
问题在于:旧版本的子应用已经通过共享作用域拿到了 antd@5.12,全局样式和 ConfigProvider 的上下文状态都已经注入到 DOM 里了。新版本注册了 antd@5.15,但共享作用域里 antd 早就被消费过了,协商窗口关了。结果就是新子应用用的还是 5.12 的 antd,但代码是按 5.15 的 API 写的------该有的方法不存在,该变的行为没变。
我们的方案:分代共享作用域
最终我们搞了一个"分代"机制。每次有远程模块热更新时,不在原来的共享作用域上修修补补,而是创建一个新的作用域"代",让新版本的模块在新代里协商。新代会继承上一代已锁定的共享模块(除了需要升级的部分),新版本的远程模块在新代中注册和解析,旧版本继续用旧代,互不干扰。代价是内存占用增加------同一个依赖可能在不同代里各加载一份。
分代方案不是万能的。有几种情况我们选择直接强制整页刷新:
react/react-dom版本变了------这俩 singleton 没法分代,React 的内部状态是全局的- 共享的状态管理库(zustand、redux)大版本变了------store 结构不兼容
- CSS-in-JS 运行时(styled-components、emotion)版本变了------样式上下文会出问题
分代方案的其他代价
调试变得更复杂了。多代并存意味着同一个 antd Button 组件可能在页面上有两个版本同时渲染,样式不一致。我们的解法是在分代切换时对旧代组件做一次强制 unmount + remount,但这会导致短暂的 UI 闪烁。
GC 也是个问题。
诊断工具链:从"猜"到"看见"
共享作用域可视化面板
排查共享依赖问题最痛苦的地方是看不见。
我们做了一个 Chrome DevTools 面板插件。核心逻辑不复杂:遍历 __webpack_share_scopes__ 的全部条目,提取包名、版本号、注册来源、是否 eager、是否已被消费等信息,外加我们通过 monkey-patch 注入的注册时间戳和消费时间戳,结构化成扁平的数据数组。
面板 UI 分两个视图。表格视图列出所有共享包及其版本,标记出哪些是协商胜出的、哪些是 fallback、哪些被跳过了。时间线视图展示每个远程容器的注册和消费顺序------时序问题在这个视图里一眼就能看出来。
版本协商模拟器
线上出了问题再排查太晚了,我们需要在 CI 阶段就发现潜在冲突。
思路是:构建阶段收集所有子应用的 shared 配置,模拟运行时的协商过程,提前暴露不兼容。模拟器遍历每个应用注册的包版本,对每个消费者的 requiredVersion 做匹配检查。两种情况会标红:一是 strictVersion 配了但没有满足条件的版本,二是 singleton 模式下最高版本不满足某个消费者的 requiredVersion------也就是说该消费者运行时会拿到一个不兼容的版本。
我们把这个 checker 集成到了 GitLab CI 的 merge request 流程里。每次有子应用提交 MR,CI 会从 federation registry 服务拉取所有其他子应用当前的 shared 配置,跑一遍模拟协商。有冲突的话 MR 直接标红,强制人工 review。上线两个月,这个 checker 在 CI 阶段拦截了 14 次潜在的版本冲突,其中 3 次如果上了线上就是白屏级别的故障。
运行时依赖图谱追踪
最后一个工具解决的是:页面上真出了共享依赖相关的 bug,怎么快速定位到是哪条依赖链路出了问题。
我们对 MF 的 __webpack_init_sharing__ 和远程容器的 init / get 方法做了 monkey-patch,记录每一次共享依赖的注册、协商和消费事件,包括时间戳、来源容器名、注册了哪些共享模块等。trace 数据导出为 JSON 后,扔到可视化面板里就能画出完整的依赖图谱和时间线。排查问题时,不用再在控制台里一层层展开对象了。