一、模块联邦如何支撑顶峰宇宙的运行模型
传统微前端方案往往借助 iframe 或者统一注册中心的方式拼装子应用,Webpack 5 的模块联邦则在构建层面提供了一种更轻量的运行时共享机制。它不需要把所有子应用打包成一个大文件,也不要求每个子应用先发布成 npm 包再让宿主安装。相反,每个子应用单独构建时,可以通过 ModuleFederationPlugin 声明自己要暴露哪些模块,同时告诉构建系统它需要从其他应用加载哪些远程模块。

Summit Universe 顶峰宇宙这个说法虽然听起来像产品代号,但本质上描述的是这种架构的宏观形态。每个远程子应用被独立部署到不同域名或路径下,宿主应用在运行时根据路由或业务状态动态拉取这些远程入口。由于远程模块的加载基于 Webpack 的运行时,浏览器不需要维护多个完全隔离的 JavaScript 执行环境,组件和公共库可以更直接地复用,避免了 iframe 常见的样式隔离成本与通信复杂度。
模块联邦最核心的两个角色是宿主和远程。宿主负责初始化页面、创建 React 或 Vue 的根节点,并在需要时加载名为 remoteEntry.js 的远程入口文件。远程应用则将自己的部分模块暴露为公共 API,例如暴露一个按钮组件、一个数据列表,甚至一个完整的路由页面。只要双方约定的共享依赖一致,这些远程模块就能像本地模块一样被导入和使用。
这种模型改变了团队协作方式。过去一个大型前端应用的所有子模块必须共享同一套构建配置、处在同一个仓库,上线节奏也会相互阻塞。有了模块联邦,不同业务团队可以维护自己的仓库和部署流水线,宿主应用只在运行时获取最新代码。顶峰宇宙强调的是独立星球的自治性,每个子应用可以自己升级、回滚,宿主只需要保持远程模块的契约稳定。
二、远程模块暴露与宿主加载的基础配置
先看远程应用的配置。假设一个名为 user_dashboard 的微前端子应用需要暴露一个 UserProfile 组件和一个 getUserInfo 工具函数。在 webpack.config.js 中,使用 ModuleFederationPlugin 的 exposes 字段声明模块名与模块路径:
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
output: {
publicPath: 'https://cdn.ipipp.com/user-dashboard/',
uniqueName: 'user_dashboard'
},
plugins: [
new ModuleFederationPlugin({
name: 'user_dashboard',
filename: 'remoteEntry.js',
exposes: {
'./UserProfile': './src/components/UserProfile.js',
'./getUserInfo': './src/utils/getUserInfo.js'
},
shared: {
react: { singleton: true, eager: false },
'react-dom': { singleton: true, eager: false }
}
})
]
};
这个配置里,name 是远程应用在模块联邦体系中的唯一标识,filename 指定远程入口文件的名称,通常保持为 remoteEntry.js。暴露出来的模块路径前面带有 ./,这是模块联邦的约定,宿主加载远程模块时也需要使用这种带斜杠的模块标识。
宿主应用的配置则通过 remotes 字段声明自己依赖哪些远程应用。例如,主应用需要加载上面的用户仪表盘子应用,可以这样写:
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host_app',
remotes: {
userDashboard: 'user_dashboard@https://cdn.ipipp.com/user-dashboard/remoteEntry.js'
},
shared: {
react: { singleton: true, eager: false },
'react-dom': { singleton: true, eager: false }
}
})
]
};
宿主代码中就可以动态导入远程模块:
import React, { lazy, Suspense } from 'react';
const UserProfile = lazy(() =>
import('userDashboard/UserProfile').then(module => ({ default: module.UserProfile }))
);
function App() {
return (
<Suspense fallback={<div>加载用户信息中...</div>}>
<UserProfile />
</Suspense>
);
}
这里 import('userDashboard/UserProfile') 使用的就是宿主配置中 remotes 的键名加远程模块暴露的路径。Webpack 会在构建时识别这种远程导入,在运行时先加载对应的 remoteEntry.js,再获取远程模块的代码。注意,远程模块的名字在远程应用的 exposes 中已经定义为 ./UserProfile,宿主侧引用时写成 userDashboard/UserProfile,不带前面的点号。
这种动态导入让代码分割和按需加载变得非常自然。宿主启动时不需要下载用户仪表盘的所有代码,只有当路由切到用户中心时才会发起网络请求。每个远程应用仍然可以独立部署,修改用户资料组件后重新构建并上传到 CDN,宿主下一次加载时就会拿到新版本,不需要宿主重新发布。
三、共享依赖的版本控制与冲突规避
模块联邦最微妙的地方在于 shared 配置。由于宿主和多个远程子应用可能都使用 React、Vue、axios 等公共库,如果每个应用都打包一份自己的依赖,会造成包体积膨胀,而且多个 React 实例同时存在会破坏 hooks 的执行上下文。通过 shared 字段,Webpack 可以协调这些依赖的加载方式。
singleton: true 表示整个运行时中只允许一个版本的该依赖存在。如果宿主和远程应用都配置了 React 且版本兼容,它们会共享同一份 React 实例;如果版本不兼容,Webpack 会回退到各自打包的副本,并给出警告。将 eager 设为 false 意味着宿主应用不会立即加载共享依赖,而是等到远程模块真正需要时再从远程入口或宿主缓存中获取,这样可以减少首屏资源的阻塞。
一个常见的坑是共享依赖没有配置 requiredVersion 或者版本范围过宽,导致运行时加载了不兼容的版本。例如宿主使用 React 17,某个远程子应用使用 React 18,虽然都配置了 singleton: true,但版本不同会导致 Webpack 选择其中一份,另一个应用可能出现运行时错误。为了减少这种情况,可以在共享配置中明确版本范围:
shared: {
react: {
singleton: true,
eager: false,
requiredVersion: '^17.0.0'
},
'react-dom': {
singleton: true,
eager: false,
requiredVersion: '^17.0.0'
}
}
这样当远程应用声明依赖 React 18 时,构建阶段就能提前发现版本契约不匹配,而不是等到线上出问题。对于业务中常用的工具库,比如 lodash、dayjs 等,如果不需要单例约束,可以不设置 singleton,只设置 shared 的包名,让 Webpack 根据版本范围自动决定共享策略。关键是团队需要维护一份公共依赖清单,并在每次升级时同步到所有子应用。
另一个值得注意的细节是共享依赖的加载时机。如果多个远程应用都从同一个 CDN 地址加载 remoteEntry.js,而每个远程入口又声明了相同的共享依赖,Webpack 运行时只会加载一次。因此,在部署远程应用时,建议将 remoteEntry.js 与业务代码分开放置,并设置合理的 HTTP 缓存策略。对于变动频繁的 remoteEntry,可以使用协商缓存或短时缓存,对于 chunk 文件使用内容哈希的强缓存,这样既能保证更新及时,又能命中缓存。
四、顶峰宇宙架构下的构建优化与部署边界
Summit Universe 顶层架构在构建阶段会面对一个现实问题:当子应用数量增多,宿主的远程依赖列表变长,构建时需要解析的远程入口也更多。如果所有远程入口都集中在一个 remotes 配置里,宿主构建时间可能增加,而且某个远程应用暂时不可用时,构建虽然不会失败,但运行时会出现加载错误。因此,建议在开发环境使用本地远程地址,生产环境再替换为 CDN 地址,并对远程加载失败给出降级方案。
错误边界是远程模块加载中必不可少的一环。因为远程应用可能因为网络、CDN 故障或版本不兼容而加载失败,宿主应用不能让整个页面白屏。可以在 Suspense 外层再包一个错误边界组件,捕获动态导入的异常,给用户友好的提示。例如:
import React from 'react';
class RemoteErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>当前模块暂时无法加载,请稍后再试。</div>;
}
return this.props.children;
}
}
export default RemoteErrorBoundary;
构建产物方面,模块联邦并不会把远程模块打进宿主的 bundle 中,而是通过网络按需加载。这使得宿主的初始包体更小,但也要求远程应用的 remoteEntry.js 必须保持相对稳定。远程应用内部的业务代码可以自由拆分 chunk,只要 remoteEntry.js 暴露的模块路径不变,宿主无需任何改动。若确实需要修改暴露路径,比如把 ./UserProfile 改成 ./Profile,则必须同步修改所有引用该远程模块的宿主配置,否则运行时会出现模块找不到的错误。
对于跨域部署的场景,远程应用的 publicPath 必须设置为远程入口和 chunk 文件所在的前缀。如果远程入口部署在 https://cdn.ipipp.com/user-dashboard/,那么加载时,Webpack 会基于这个 publicPath 拼接后续的 chunk 请求。如果 publicPath 设置错误,远程入口能加载,但业务代码 chunk 会 404。这是模块联邦上线时最容易踩的坑之一,需要在构建部署流水线中做好环境变量注入和校验。
从系统设计角度看,顶峰宇宙并不适合所有项目。对于小型单页应用,模块联邦带来的配置复杂度和运行时协调成本可能超过收益;对于跨团队、多业务线的大型前端产品,它能够有效降低部署耦合,让各团队独立交付。至少在模块联邦刚出现时,很多团队用它在不改变用户体验的情况下逐步拆分老旧的单体前端,把高内聚的业务模块迁移成远程应用,逐步演进到微前端架构。