Webpack 模块联邦详解
模块联邦解决的是「运行时共享代码」:多个独立构建的应用,可以在运行时互相加载对方的模块。
一、它是运行时共享,不是框架
先把边界划清楚:模块联邦解决的是运行时代码共享这一件事。它不负责路由分发、不负责样式隔离、不负责应用生命周期——那些是微前端框架的事。
所以:
- 它能作为微前端的底层;
- 但它不等于微前端。
判断要不要上联邦,就问一句:我要的是「共享代码」还是「集成应用」?前者用联邦就够了,后者还需要一整套微前端方案。
二、host 与 remote 的接法
五个字段要理解准确:
name—— 当前构建的容器名,其它应用通过它引用你;filename—— 对外暴露的入口文件名(通常叫remoteEntry.js),它必须能被稳定访问到;exposes—— 我对外提供哪些模块;remotes—— 我要从哪些远程加载模块,指向它们的入口文件地址;shared—— 哪些依赖要共享,以及共享的策略。
// 提供方
new ModuleFederationPlugin({
name: 'shop',
filename: 'remoteEntry.js',
exposes: { './ProductCard': './src/components/ProductCard' },
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
// 消费方
new ModuleFederationPlugin({
name: 'host',
remotes: { shop: 'shop@https://cdn.example.com/shop/remoteEntry.js' },
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
一个容易忽略的时序问题:远程入口是运行时才加载的。消费方启动时并不知道远程模块长什么样,类型与版本兼容全靠约定。这意味着远程改了导出结构,消费方可能在运行时才炸——类型安全在这里是缺失的,需要靠契约测试或版本约定来补。
三、单例依赖怎么不打架
shared 是最容易出事的配置。
为什么 React 必须单例? 因为 React 内部维护着 hooks 的调度状态。如果主应用和远程各持有一份 React 实例,跨边界渲染时会直接报 hooks 错误。所以 React、React DOM 这类带内部状态的库必须设 singleton: true。
版本不匹配时会发生什么? 联邦会按 requiredVersion 与共享作用域做协商:拿不到兼容版本就告警,并退回各自的本地副本。结果就是产物里其实存在两份依赖——体积和行为都和你的预期不同,而且不一定报错。
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
}
实践建议:共享的依赖要少而稳。共享越多,版本耦合越紧,独立部署的价值就越低。一个常见做法是只共享框架层与真正的基础设施(React、设计系统、请求层),业务库各自打包。
四、加载失败与样式隔离
- 多实例 —— 忘了设 singleton,出现两个 React 或两个状态库实例,表现为 hooks 报错或状态不共享;
- 样式冲突 —— 联邦不管样式隔离。多个应用的 CSS 会互相影响,需要靠命名空间、CSS Modules 或 Shadow DOM 自己解决;
- 远程加载失败 —— 远程服务挂了,消费方可能整页白屏。必须加失败降级(加载失败时渲染兜底内容);
- 循环依赖 —— A 引用 B、B 又引用 A,会在运行时出问题;
- 类型与契约 —— 远程导出结构变了消费方才知道,需要契约测试或版本约定兜底。
共享依赖到底有没有真的只加载一份,配置写上不算数,运行时验一下:
// 在两个应用里各放一次,只有一份时第二次不会执行
window.__loaded ??= new Set()
if (window.__loaded.has('react')) console.error('react 被加载了两次')
window.__loaded.add('react')
// 更直接的方式:看 Network 里同一个包是否出现两个 chunk
五、和发 npm 包怎么分工
本质区别是依赖确定的时机:
| 维度 | npm 包 | 模块联邦 |
|---|---|---|
| 确定时机 | 构建期 | 运行时 |
| 升级方式 | 改版本 + 重新构建发布 | 远程更新即可生效 |
| 版本耦合 | 显式声明,可控 | 靠 singleton 与版本协商 |
| 风险 | 低,构建期可验证 | 高,运行时才暴露 |
| 适用 | 稳定、可版本化的能力 | 需要独立部署、频繁变动的模块 |
一句话:npm 包换来确定性,联邦换来部署独立性。 想要独立部署就要接受运行时风险,这两者是同一笔交易的两面。
// 远程组件加载失败时用兜底渲染,避免整页白屏
function RemoteProductCard() {
const Comp = React.use(import('shop/ProductCard')) // 失败时抛错,由外层 Error Boundary 捕获
return Comp ? <Comp /> : <LocalProductCard />
}
// 更稳妥:在外层包 Error Boundary,远程服务挂掉时整体降级到本地组件
远程模块是运行时才拿到的,类型必须在构建期单独补:
// remotes.d.ts —— 声明远程模块的类型,否则 TS 只认到 any
declare module 'remoteApp/Button' {
import type { ComponentType } from 'react'
const Button: ComponentType<{ size?: 'sm' | 'md'; onClick?: () => void }>
export default Button
}
六、和发包方案怎么分工
模块联邦不等于微前端。它只解决运行时代码共享,路由、样式隔离、生命周期这些要另外解决。
共享依赖也不意味着最终只有一份——版本协商失败时各用各的,必须检查产物才能确认。
远程加载失败的影响面也比想象中大:没有降级方案时,整个页面可能白屏。
有了联邦也不该放弃发 npm 包:稳定、可版本化的能力用包更可靠,联邦适合需要独立部署的那部分。
模块联邦换来的独立部署能力,代价是运行时依赖——共享什么、谁来兜底,都要在配置阶段想清楚。
模块联邦的配置项与运行时 API 现在集中在 module-federation.io,版本差异较大,按文档对应版本看。