理解模块联邦的关键,在于分清它和传统打包方式的差异。过去多个前端应用想要共享代码,通常只能把公共逻辑抽成 npm 包发布到私有仓库,或者把组件库构建成 UMD 文件挂到 CDN 上。这些方案的共同问题是:共享单元一旦更新,所有依赖方都必须重新安装、重新构建、重新发布,任何一个子应用没有跟上节奏,线上就会同时存在多个版本的公共代码。模块联邦的思路完全不同,它允许应用在构建时只声明自己暴露哪些模块,真正的模块加载和依赖协商被推迟到浏览器运行时。宿主应用加载一个远程入口文件,就能直接使用远程应用提供的最新模块,无需提前安装依赖,也无需把代码复制到本地。

模块联邦的核心机制与配置入口
要实现模块联邦,最常用的工具是 Webpack 5 内置的 ModuleFederationPlugin。它通过容器机制把应用拆分成可组合的运行时单元。一个远程应用需要设置 name、filename 和 exposes 三个关键字段。name 是远程应用的唯一标识,filename 用来指定远程入口文件的名称,通常叫 remoteEntry.js,exposes 则声明哪些模块可以被外部访问。下面是一个远程应用的典型配置:
const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./utils': './src/utils/format'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
在这个配置中,exposes 的键是远程模块的公开路径,值是本地文件的相对路径。外部应用通过 remoteApp/Button 这样的标识符来加载对应的本地组件,就像它本来就在自己的项目里一样。这种方式的价值在于:远程应用的内部实现可以随时修改,只要暴露路径和导出契约不变,宿主应用不需要做任何改动。
宿主应用的配置则通过 remotes 字段声明远程地址。地址格式为「远程名称@远程入口文件地址」,远程入口文件地址通常指向远程应用部署后的静态资源 URL。这种声明方式将远程应用的运行时位置从代码中解耦出来,构建时并不需要远程应用实际存在。也就是说,宿主应用可以独立构建,只要部署时远程入口文件可用,运行时就能完成模块加载。
宿主应用如何加载和使用远程模块
宿主应用声明远程模块之后,使用方式与本地动态导入几乎一致。远程模块的加载本质上是一个异步过程,因为浏览器需要先请求远程入口文件,再在入口文件中找到对应的模块定义。因此,通常使用动态 import() 语法或者框架提供的懒加载能力来消费远程模块。以 React 为例,可以这样使用远程按钮组件:
import React, { Suspense } from 'react';
const RemoteButton = React.lazy(() => import('remoteApp/Button'));
function App() {
return (
<Suspense fallback="加载远程组件...">
<RemoteButton text="点击我" />
</Suspense>
);
}
这里 import('remoteApp/Button') 并不是一个普通的本地模块路径,而是一个特殊的远程模块引用。Webpack 在构建宿主应用时,会识别这个引用并生成对应的运行时加载逻辑。加载流程大致分为三步:先请求远程应用的 remoteEntry.js,然后在该入口中查找 Button 模块的定义,最后执行模块代码并把导出结果返回给宿主应用。整个过程对业务代码透明,但开发者必须清楚这是异步操作,因此 React.lazy 和 Suspense 的使用是必要的。
远程模块加载还有个容易忽略的细节:远程入口文件的地址在构建后被固化为字符串。如果远程应用部署在不同环境,比如测试环境、生产环境,就需要在构建时传入不同的远程地址。当然,也可以在运行时动态创建 script 标签加载远程入口,再通过自定义方式注册容器,不过这会增加复杂度。对于大多数场景,使用环境变量注入远程地址是更稳妥的选择。
共享依赖的版本协商与单例策略
模块联邦最容易被低估的配置是 shared。如果宿主应用和远程应用都使用 React,且没有做共享处理,那么页面中就会加载两份 React 实例。对于 React 这类依赖自身状态和上下文机制的库来说,这会导致严重的运行时错误,比如 Hook 调用异常、Context 丢失或者组件渲染空白。模块联邦提供的 shared 配置让不同应用在运行时协商使用同一份依赖,从而避免重复加载。
共享配置可以指定 singleton、requiredVersion、eager 等选项。singleton: true 表示整个页面中只允许存在一个该依赖实例,如果宿主和远程都提供了这个依赖,模块联邦会通过版本协商选择其中一个;requiredVersion 用来声明期望的版本范围,当远程依赖版本不匹配时,Webpack 会给出警告;eager 决定依赖是否在加载入口时就立即初始化。下面的配置展示了如何共享 React 和 ReactDOM:
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js'
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: false
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: false
}
}
})
版本协商的规则并不复杂:当多个应用同时提供同一个共享依赖时,Webpack 会比较已注册版本号和当前模块请求的版本范围。如果版本满足范围要求,优先复用已经加载的实例;如果不满足,模块联邦会根据 singleton 配置决定是否允许加载第二份。设置为 singleton: true 时,即使版本不匹配,通常也会强制复用现有实例并发出警告,以保证实例唯一性。这种策略在微前端项目中非常重要,因为它把版本冲突问题暴露在开发阶段,而不是等到线上出现难以排查的 React 错误。
实际项目中的代码共享落地与避坑
模块联邦解决了代码共享的加载问题,但真正在微前端项目中使用时,还需要处理样式隔离、错误兜底和部署缓存等问题。远程模块加载失败是常见场景,可能是远程入口文件不存在、网络超时或者远程应用更新导致入口文件暂时不可用。宿主应用应该为远程模块提供错误边界,避免一个子应用故障拖垮整个页面。一个简单的 React 错误边界可以这样实现:
class RemoteModuleErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error) {
console.error('远程模块加载失败:', error);
}
render() {
if (this.state.hasError) {
return <div>远程模块暂时不可用,请稍后重试</div>;
}
return this.props.children;
}
}
样式隔离是另一个需要关注的环节。远程模块通常自带自己的样式文件,这些样式最终会注入到宿主页面的全局样式表中。如果不同团队使用了相同的类名,就会出现样式互相覆盖的问题。解决方案包括使用 CSS Modules、CSS-in-JS 方案,或者为每个远程应用设置统一的类名前缀。模块联邦本身并不负责样式隔离,它只负责 JavaScript 模块的加载,因此样式治理必须在架构设计阶段提前规划。
部署层面的缓存控制同样不可忽视。remoteEntry.js 是远程模块的入口文件,如果它的缓存策略设置不当,宿主应用可能会加载到旧的入口文件,从而无法获取远程应用的最新版本。建议为 remoteEntry.js 设置较短的缓存时间或者使用文件名哈希,同时在远程应用发版后确保旧的入口文件不会长期驻留缓存。另外,远程入口文件地址一旦发生变化,所有宿主应用都需要同步更新,因此可以考虑通过统一的配置中心下发远程地址,降低跨团队沟通成本。
模块联邦并不是万能的微前端解决方案,它更擅长处理 JavaScript 模块级别的共享,对于应用级的路由、状态管理、权限控制等还需要配合其他机制。但它在代码共享方面的设计非常优雅:把构建时的耦合推迟到运行时,让多个团队可以按照自己的节奏独立发布,同时保持模块边界的清晰。当你真正理解远程入口文件、共享依赖协商和异步加载边界这三件事之后,微前端中的代码共享就不再是一个需要反复权衡的技术难题。