微前端方案中,团队往往在 iframe 的通信隔离、single-spa 的生命周期接管、公共依赖重复打包之间反复调整。Webpack 5 内置的 Module Federation 插件让不同仓库产出的构建结果,可以在浏览器里直接加载并共享模块,这相当于把应用的边界从构建期移到了运行期。远程应用暴露的组件被宿主按需拉取,共享依赖也能通过配置避免一份 React 被打进每个子应用。

一、模块联邦解决的核心问题
传统微前端方案里,iframe 虽然隔离彻底,但路由同步、弹窗层级、登录态传递都比较繁琐,单页应用之间的交互体验也容易割裂。single-spa 虽然能统一管理子应用生命周期,但开发者在接入前仍要处理公共依赖的提取、应用间通信以及子应用样式冲突。更要紧的是,多个子应用如果各自打包 React、ReactDOM、组件库等公共资源,首屏加载体积会成倍增加。
Module Federation 的思路是把模块的加载从构建时推迟到运行时。每个独立构建的应用既可以作为远程应用暴露组件,也可以作为宿主引用远端模块。Webpack 在构建时并不把远端模块打进产物,而是生成一个运行时容器,浏览器加载宿主页面后,再根据配置去请求远端入口文件 remoteEntry.js,并动态执行其中的模块定义。这样远程组件的更新不必重新构建宿主,依赖也可以共享同一份 React 实例。
这种模式下,团队可以继续保留各自的仓库、构建流水线和发布节奏,只需要在 Webpack 配置中声明暴露和引用的边界。对于大型中后台、多业务线集成、组件平台等场景,模块联邦能显著降低接入成本。
二、远程应用如何暴露模块
远程应用的改造一般从 Webpack 配置开始。核心是使用 ModuleFederationPlugin 并设置 name、filename 和 exposes。其中 name 是构建产物的全局唯一标识,filename 是远程入口文件名,通常保持 remoteEntry.js 不变,便于部署时统一寻址。exposes 声明哪些本地模块可以被外部加载,键是暴露给宿主的模块路径,值是本地文件路径。
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
entry: './src/index.js',
output: {
publicPath: 'http://localhost:3001/'
},
plugins: [
new ModuleFederationPlugin({
name: 'app_products',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./ProductList': './src/pages/ProductList'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
上面这段配置把 Button 和 ProductList 两个组件暴露出去。宿主只需要知道模块名 app_products/Button,无需关心远程应用的目录结构。需要注意的是,publicPath 要指向远程资源的实际部署地址,如果配置成相对路径或者 CDN 地址,加载 remoteEntry.js 时会拼接错误。
远程组件依然使用普通 React 组件写法,不需要额外封装生命周期。构建命令和普通 Webpack 项目一致,只是多了一个 remoteEntry.js 产物。部署时把构建目录整体上传到静态服务器,保证 remoteEntry.js 和其依赖的 chunk 文件在同一目录可访问即可。
三、宿主应用如何加载远程模块
宿主侧同样需要配置 ModuleFederationPlugin,不同的是使用 remotes 字段声明要引用的远程应用。键是本地使用的命名空间,值是远程应用 name 加上远程入口地址,中间用 @ 分隔。配置完成后,可以在代码里通过动态 import 加载远端组件,配合 React.lazy 实现懒加载。
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
app_products: 'app_products@http://localhost:3001/remoteEntry.js'
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' }
}
})
]
};
动态加载远程组件时,可以这样写:const RemoteButton = React.lazy(function() { return import('app_products/Button'); });。在 React 组件中通过 Suspense 包裹并给出 fallback,远程组件加载完成前展示占位内容。远程应用发布新版本后,只要 remoteEntry.js 地址不变,宿主无需重新构建即可拉取到最新组件。
实际项目中更推荐使用 ErrorBoundary 包裹远程组件。因为远程应用可能因网络、部署路径、版本不兼容等原因加载失败,没有错误边界会让整个宿主页面白屏。错误边界可以在远程模块加载异常时显示降级 UI,并记录加载失败的模块名,方便后续排查。
四、共享依赖的单例策略与版本冲突
共享依赖是模块联邦最容易踩坑的部分。多个应用如果都声明了 shared,Webpack 会尝试复用已经加载的依赖,而不是各自下载一份。对于 React、ReactDOM 这类要求单实例的库,必须设置 singleton: true,否则可能出现多个 React 副本,导致 hooks 报错。
shared: {
react: {
singleton: true,
eager: true,
requiredVersion: '^18.2.0'
},
'react-dom': {
singleton: true,
eager: true,
requiredVersion: '^18.2.0'
}
}
singleton 表示全局只加载一个版本。requiredVersion 声明期望的版本范围,如果远程应用依赖的版本不匹配,Webpack 会加载远程自己的版本并给出警告。eager 用于宿主自身直接使用共享依赖时,需要在初始化阶段立即加载,避免运行时才去请求。
版本冲突的表现通常是控制台出现 Unsatisfied version 提示,或者页面上多个 React 实例导致组件状态异常。解决思路是统一仓库内关键依赖版本,例如在 monorepo 中通过根 package.json 锁定 React 版本,或者在 CI 构建时检查 shared 依赖的版本一致性。若无法完全统一,可以把版本敏感库设为 singleton 并允许远程回退自己的版本,但要谨慎处理双实例带来的样式和状态问题。
五、微前端落地中的部署与优化
模块联邦落地到生产环境,首先要关注 remoteEntry.js 的缓存策略。通常建议给 remoteEntry.js 设置较短的强缓存或协商缓存,例如 Cache-Control: no-cache,确保宿主能及时获取远程模块的最新地址映射。而远程应用的业务 chunk 文件带有内容哈希,可以放心使用长缓存。这样既能保证更新及时,又不会牺牲静态资源的缓存效率。
样式隔离是另一个需要提前规划的问题。远程组件自带的 CSS 会插入到宿主页面,容易和宿主样式互相污染。可以采用 CSS Modules、BEM 命名、Shadow DOM 或者 CSS-in-JS 等方式做样式作用域隔离。若使用 Ant Design 等组件库,建议把组件库也放入 shared 并设置 singleton,防止多次注册组件样式导致覆盖顺序不确定。
此外,还要考虑多环境部署。远程入口地址在 development、test、production 环境中可能不同,可以在 Webpack 配置中通过环境变量注入。比如在构建宿主时读取 process.env.REMOTE_PRODUCTS_URL,避免把本地地址写死到代码或配置中。对于多个远程应用,可以封装一个注册表模块,统一管理所有远程入口,方便动态扩展和灰度发布。
模块联邦并不是微前端的唯一答案,但它把模块共享和运行时加载做得足够轻量,适合已经使用 Webpack 5 的团队逐步迁移。先从一个业务组件或公共工具模块开始试点,再逐步拆分完整子应用,通常能获得比较平稳的落地体验。