Webpack 5 正式引入模块联邦之后,社区里出现不少基于该能力的项目组织方案。Fated Universe 注定宇宙并不是 Webpack 官方文档里的标准术语,而是对一种确定的远程依赖组织方式的形象概括:每个子应用像宇宙中的星体,构建阶段就通过配置把各自的依赖关系固定下来,运行阶段则按契约动态连接。它的核心价值不是让构建变快,而是让多个独立部署的前端应用能够共享代码,同时避免把公共依赖硬塞进一个巨大的包里。

要理解 Fated Universe,先要理解 ModuleFederationPlugin 的两个关键字段:exposes 和 remotes。exposes 负责把当前构建中的模块公开出去,remotes 负责声明当前应用要消费哪些远端模块。Fated Universe 的要点在于提前约定好这些暴露与消费关系,而不是在运行时随意拼接。下面是一段最常见的基础配置。
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index',
mode: 'development',
devServer: {
port: 3001,
},
plugins: [
new ModuleFederationPlugin({
name: 'app_shell',
filename: 'remoteEntry.js',
remotes: {
app_products: 'app_products@http://localhost:3002/remoteEntry.js',
},
exposes: {
'./ShellHeader': './src/components/ShellHeader',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
在这个配置里,name 是当前容器的全局唯一标识,filename 是远端入口文件名,宿主应用通过 remotes 中的地址加载该文件。shared 则声明了 react 和 react-dom 需要被多个应用共享。singleton 设为 true 表示全局只能存在一个 react 实例,如果多个远端带来的版本不一致,构建阶段就会给出警告,运行时会优先选择宿主提供的版本。这个配置是 Fated Universe 依赖确定性的基础。
从 exposes 到 remotes:定制模块边界
很多模块联邦配置失败,问题并不是插件本身,而是暴露的边界没有设计清楚。Fated Universe 强调构建前先明确哪些模块可以跨应用使用,哪些模块只能内部引用。把整个 src 目录都暴露出去看似方便,实际上会让依赖关系迅速失控。远端一旦依赖宿主内部变量,或者宿主改了组件参数,其他团队的应用就可能报错。
推荐的做法是每个子应用只暴露稳定的公共组件和工具函数,例如设计系统里的按钮、表单控件,或者经过版本沉淀的 API 封装。文件路径和导出名要保持稳定,避免频繁变更。下面示例演示了一个商品子应用暴露 ProductList 和 ProductCard 两个模块。
// products/webpack.config.js
new ModuleFederationPlugin({
name: 'app_products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductCard': './src/components/ProductCard',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
axios: { singleton: true, requiredVersion: '^1.0.0' },
},
});
宿主应用消费时,通过动态 import 加载远端模块。Webpack 会把 remoteEntry.js 作为入口,根据 exposes 映射找到对应模块,再下载实际 chunk。这个过程是异步的,所以组件需要配合懒加载。下面用 React 举例。
// shell/src/App.js
import React, { lazy, Suspense } from 'react';
const ProductList = lazy(() =>
import('app_products/ProductList').then((m) => ({ default: m.ProductList }))
);
export default function App() {
return (
<Suspense fallback={<div>Loading products...</div>}>
<ProductList />
</Suspense>
);
}
需要注意的是,动态 import 的字符串必须是静态可分析的,Webpack 构建时才能生成正确的远程模块请求。这里的 JSX 代码中如果直接写 <Suspense>,在浏览器端会被正常渲染,但在文章里我们用转义形式展示。实际项目里除了懒加载,还要给远端模块包上错误边界,避免远端应用挂掉后影响宿主主流程。
共享依赖如何避免版本冲突
Fated Universe 的另一个关键点是 shared 的版本协商机制。多个子应用可能依赖不同版本的 lodash、moment 或者 UI 库,如果各自打包一份,最后页面上会出现重复代码,甚至因为实例不共享导致状态异常。shared 配置可以让 Webpack 在运行时优先复用已经存在的模块,而不是无条件下载自己的副本。
举例来说,宿主应用与商品子应用都依赖 axios。宿主配置了 axios: { singleton: true, requiredVersion: '^1.0.0' },商品子应用也配置相同的版本范围,那么运行时只需要加载一次 axios。如果某个子应用声明的是 axios: '^0.27.0',与 requiredVersion 不兼容,Webpack 会回退到加载该子应用自带的版本,同时输出警告。这种方式兼顾了共享效率和向后兼容。
// shared 依赖的常见配置
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
strictVersion: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
},
lodash: {
singleton: false,
requiredVersion: '^4.17.21',
},
}
这里 strictVersion 设为 false 表示允许在一定范围内使用不同小版本,设为 true 则强约束。singleton 为 false 时,Webpack 会根据版本范围决定是否共享,如果不匹配就分别加载。共享依赖配置得过猛会导致运行时模块解析变得复杂,过松又会失去共享价值。实践中通常只对 react、react-dom、vue、axios 这类基础依赖做 singleton,业务组件库则使用普通共享或者干脆不共享。
还有一点容易被忽略:版本号中的插入符和波浪符由 Webpack 的 semver 解析完成,不能只写 latest。因为 latest 在构建时不具备确定性,会导致不同时间构建出的容器行为不一致。Fated Universe 强调依赖图谱的确定性,所以 requiredVersion 必须写清楚明确的版本范围。
运行时加载、容错与性能优化
模块联邦的远程加载发生在浏览器运行时,remoteEntry.js 的获取、远端模块的下载都会受到网络状态影响。宿主应用如果没有容错机制,远端服务不可用时可能直接白屏。Fated Universe 模式下应该对每个远端模块设置加载超时和错误边界。React 里可以结合 ErrorBoundary 组件,也可以在动态 import 的 Promise 里做 catch 兜底。
远程模块加载成功后,Webpack 会把模块注册到全局共享作用域,后续再次 import 同一远程模块时会直接命中缓存。为了减少首屏压力,建议把非首屏组件做成独立 chunk,通过动态 import 触发加载。远程模块的 chunkName 可以通过 Webpack 的 output.chunkFilename 和 magic comment 控制,例如 import(/* webpackChunkName: "product-list" */ 'app_products/ProductList')。这样生成的文件名可读性更好,也方便配合 CDN 缓存策略。
// 远程模块加载超时示例
function loadRemoteModule(url) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Remote module timeout'));
}, 10000);
import(/* webpackChunkName: "remote-product" */ 'app_products/ProductList')
.then((module) => {
clearTimeout(timer);
resolve(module);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
性能方面还要关注 publicPath 和跨域配置。remoteEntry.js 会从 remotes 指定的完整地址加载,但后续 chunk 的路径默认继承当前应用的 publicPath。如果远端应用部署在独立域名下,必须把 output.publicPath 设置为绝对地址,否则 chunk 会请求到宿主域名的同路径,导致 404。开发环境下可以通过 devServer 的 headers 配置允许跨域。
另外,Fated Universe 并不是适合所有场景。如果团队规模较小、应用之间没有独立部署需求,模块联邦带来的运行时复杂度可能超过收益。传统 npm 包发布仍然适合强版本控制和内部组件统一升级的场景。模块联邦的价值在于让不同团队的应用各自发布、各自部署,同时共享基础库和业务组件,把发布耦合降到最低。判断是否引入,关键是看组织的发布节奏是否已经解耦。
常见问题与调试思路
接入模块联邦后最常见的报错是 Shared module is not available for eager consumption。这通常是因为 shared 依赖配置了 singleton,但宿主入口使用了同步 import。解决方式是把消费共享依赖的代码放到异步边界里,或者将 shared 的 eager 设为 true。下面是一个解决问题的配置片段。
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: true,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: true,
},
}
另一个高频问题是远端路径写错或者端口无法访问。可以先用浏览器直接打开 remoteEntry.js 地址,确认能返回 JavaScript 文件。再检查宿主 remotes 中的 key 是否与远端 name 一致。大小写不一致会导致模块解析失败。构建时如果出现版本冲突警告,不要直接忽略,应该根据 requiredVersion 对应的依赖树找到冲突来源,统一版本范围后再构建。
最终,Fated Universe 更像是一种构建契约:每个子应用在发布时明确自己能提供什么,在消费时声明自己需要什么,共享依赖的版本范围又为这种契约增加了约束。它不是银弹,但能让 Webpack 5 的模块联邦能力从能跑通变成可维护。理解了 exposes、remotes、shared 和运行时加载这四个层面,基本就掌握了这套思路的核心。