前端开发中的模块联邦实战:在中型项目中落地微前端架构的拆分、共享与部署策略
在中型前端项目里,团队最容易遇到的不是“技术不会”,而是“系统开始变重”。代码仓库越来越大、构建越来越慢、团队之间互相等待上线窗口、公共组件改一下要联动测试一大片。这个阶段,如果继续靠“把项目再分几个目录”来维持秩序,通常撑不了太久。
模块联邦(Module Federation)是 Webpack 5 提供的一种运行时共享模块机制。它很适合解决这样一类问题:多个前端应用需要独立开发、独立部署,但又必须共享一部分能力和 UI。如果你正好处在一个中型项目阶段——团队不算大到需要极重的平台化,也不小到可以把所有人塞进一个仓库里共建——那么模块联邦往往是一个很现实的折中方案。
这篇文章我会从“架构落地”的角度讲,不只讲配置项,还会讲拆分策略、共享边界、部署设计,以及我自己在项目里踩过的坑。
背景与问题
先看一个典型场景。
假设你有一个业务平台,包含这些模块:
- 主站壳应用:负责导航、登录态、路由和基础布局
- 商品子系统
- 订单子系统
- 营销子系统
- 公共组件与权限能力
在项目早期,大家可能都在一个 React/Vue 单体应用里写,目录大概像这样:
src/
pages/
product/
order/
marketing/
components/
services/
stores/
随着业务增长,问题开始集中出现:
-
构建时间持续变长
修改订单页面,也要重新打整个主应用。 -
发布互相阻塞
营销模块想紧急上线,结果得跟商品、订单一起走完整回归。 -
技术债被放大
某个子团队想升级依赖,但会影响整个工程。 -
公共能力耦合过深
组件库、权限 SDK、埋点逻辑全都在一个仓库里,谁都能改,谁都得背锅。 -
团队协作成本提高
一开始觉得“都在一起改很方便”,后面往往变成“谁都不敢轻易动”。
这时我们往往会考虑几条路:
- 单体继续优化:拆包、按路由懒加载、提速构建
- Monorepo:把代码组织好,但运行时还是一个应用
- 微前端:在工程和运行时上真正拆开
模块联邦正好落在中间:它不是简单的代码仓管理方案,也不是完全隔离的 iframe 微前端,而是运行时级别的模块拼装机制。
先做判断:你的项目真的适合模块联邦吗?
这个问题很关键。模块联邦不是“更高级的代码分包”,它是一个架构选择,有成本。
适合的场景
- 有 2~5 个相对独立的前端业务域
- 子团队需要独立开发和部署
- 页面之间需要共享 UI、工具库、登录态或上下文
- 能接受一定的运行时复杂度换取组织效率
不太适合的场景
- 项目很小,1~2 人维护
- 团队没有稳定的工程规范
- 子应用边界并不清晰,模块之间频繁互调内部状态
- 对首屏性能极度敏感,但又没有做远程资源治理的能力
我自己的经验是:中型项目最容易从模块联邦获益,但前提是“业务边界明确”。如果边界没想清楚,模块联邦只会把混乱放大。
方案对比与取舍分析
在开始实战前,先把几个常见方案摆在一起比较。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单体应用 + 路由懒加载 | 简单、调试直观 | 发布耦合、团队互相影响 | 小型项目 |
| Monorepo + 多包管理 | 依赖治理清晰、共享代码方便 | 运行时仍可能耦合 | 多团队协作但统一发布 |
| iframe 微前端 | 隔离强、技术栈自由 | 通信复杂、体验割裂、样式与路由整合弱 | 强隔离后台系统 |
| 模块联邦 | 共享灵活、独立部署、用户体验好 | 运行时依赖复杂、版本治理难 | 中型业务平台 |
| 纯组件库复用 | 维护简单 | 只能复用静态能力,不能动态装载业务模块 | 共性 UI 抽取 |
一个很实用的判断标准是:
如果你只是想“共享代码”,优先考虑 Monorepo/组件库;
如果你还想“独立部署并在运行时组合”,再考虑模块联邦。
核心原理
模块联邦的核心不是“把代码拆开”,而是:
- Host(宿主应用)在运行时加载 Remote(远程应用)
- Remote 暴露模块,Host 按需消费
- 多个应用可以共享依赖,比如 React、Vue、antd
- 共享依赖可以配置成单例、版本约束、按需加载
一句话概括:
模块联邦把“应用集成”从构建时,推迟到了运行时。
一个最小心智模型
host:主应用,负责路由、菜单、布局remote-product:商品子应用remote-order:订单子应用shared:React、状态管理、设计系统
flowchart LR
A[浏览器] --> B[Host 主应用]
B --> C[加载 remoteEntry.js]
C --> D[商品子应用 Remote]
C --> E[订单子应用 Remote]
B --> F[共享依赖 React/组件库]
D --> F
E --> F
关键配置概念
1. exposes
Remote 对外暴露什么模块。
比如:
./ProductApp./routes./Widget
2. remotes
Host 从哪里加载远程模块。
比如:
productApp@http://localhost:3001/remoteEntry.js
3. shared
哪些依赖需要共享。
典型是:
reactreact-domvue- 状态库
- 设计系统组件库
加载过程可以这样理解
sequenceDiagram
participant U as 用户
participant H as Host
participant R as RemoteEntry
participant M as 远程模块
participant S as Shared Scope
U->>H: 访问 /product
H->>R: 拉取 remoteEntry.js
R->>S: 初始化共享依赖
H->>M: 请求 ProductApp 模块
M-->>H: 返回组件工厂
H-->>U: 渲染商品页面
为什么共享依赖很重要?
如果 Host 和 Remote 各自打包一份 React,常见问题会立刻出现:
- hooks 报错
- context 不通
- 包体积膨胀
- 运行时行为不一致
所以大多数时候,react 和 react-dom 都应该设置为单例。
拆分策略:中型项目里怎么拆最稳
这部分往往比配置更重要。很多失败案例不是 webpack 写错了,而是拆分方式不对。
1. 按业务域拆,不要按技术层硬拆
推荐这样拆:
- 商品域
- 订单域
- 营销域
- 用户中心域
不太推荐这样拆:
- 所有表格页面一个应用
- 所有弹窗一个应用
- 所有 hooks 一个应用
原因很简单:技术层拆分会造成高频跨应用协作,业务链路被切碎。
2. 壳应用只做“薄编排”
Host 应该主要负责:
- 路由入口
- 导航与菜单
- 权限校验
- 全局主题
- 登录态注入
- 错误兜底
不要把大量业务逻辑继续留在 Host 里,否则所谓“微前端”会退化成“主应用 + 一堆挂件”。
3. 公共能力分层共享
建议把共享内容分三层:
- 基础共享:React、UI 框架、工具库
- 平台共享:权限、埋点、国际化、请求封装
- 业务共享:慎用,只共享稳定领域模型或低频变动组件
我踩过的一个坑是:把半成熟的业务 hooks 也做成 shared,结果每个子应用都被它绑住,升级时非常痛苦。业务共享越多,架构边界越模糊。
4. 路由归属要提前定
有两种常见模式:
- Host 管总路由,Remote 提供页面组件
- Remote 自带子路由,Host 只挂载入口
中型项目里,我更推荐第二种:Host 管一级路由,Remote 管自己内部子路由。这样职责更清楚。
flowchart TD
A[一级路由 Host] --> B[/product/*]
A --> C[/order/*]
B --> D[商品 Remote 内部子路由]
C --> E[订单 Remote 内部子路由]
实战代码(可运行)
下面用一个最小 React + Webpack 5 的例子演示:
host:主应用,端口 3000product-app:商品远程应用,端口 3001
代码是可运行骨架,适合你先验证链路,再扩展到真实项目。
目录结构
mf-demo/
host/
package.json
webpack.config.js
public/index.html
src/index.js
src/bootstrap.js
src/App.jsx
product-app/
package.json
webpack.config.js
public/index.html
src/index.js
src/bootstrap.js
src/ProductApp.jsx
1)product-app:远程应用
product-app/package.json
{
"name": "product-app",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --config webpack.config.js",
"build": "webpack --config webpack.config.js"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.22.0",
"@babel/preset-env": "^7.22.0",
"@babel/preset-react": "^7.22.0",
"babel-loader": "^9.1.2",
"html-webpack-plugin": "^5.5.1",
"webpack": "^5.88.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.0"
}
}
product-app/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const deps = require('./package.json').dependencies;
module.exports = {
mode: 'development',
entry: './src/index.js',
devServer: {
port: 3001,
historyApiFallback: true,
headers: {
'Access-Control-Allow-Origin': '*'
}
},
output: {
publicPath: 'http://localhost:3001/',
path: path.resolve(__dirname, 'dist'),
clean: true
},
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'productApp',
filename: 'remoteEntry.js',
exposes: {
'./ProductApp': './src/ProductApp.jsx'
},
shared: {
react: {
singleton: true,
requiredVersion: deps.react
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom']
}
}
}),
new HtmlWebpackPlugin({
template: './public/index.html'
})
]
};
product-app/public/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Product App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
product-app/src/ProductApp.jsx
import React from 'react';
export default function ProductApp() {
return (
<div style={{ padding: 16, border: '1px solid #ddd', borderRadius: 8 }}>
<h2>商品子应用</h2>
<p>这是通过模块联邦从远程应用加载的页面模块。</p>
</div>
);
}
product-app/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import ProductApp from './ProductApp';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<ProductApp />);
product-app/src/index.js
import('./bootstrap');
2)host:宿主应用
host/package.json
{
"name": "host",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --config webpack.config.js",
"build": "webpack --config webpack.config.js"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.22.0",
"@babel/preset-env": "^7.22.0",
"@babel/preset-react": "^7.22.0",
"babel-loader": "^9.1.2",
"html-webpack-plugin": "^5.5.1",
"webpack": "^5.88.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.0"
}
}
host/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const deps = require('./package.json').dependencies;
module.exports = {
mode: 'development',
entry: './src/index.js',
devServer: {
port: 3000,
historyApiFallback: true
},
output: {
publicPath: 'http://localhost:3000/',
path: path.resolve(__dirname, 'dist'),
clean: true
},
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
productApp: 'productApp@http://localhost:3001/remoteEntry.js'
},
shared: {
react: {
singleton: true,
requiredVersion: deps.react
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom']
}
}
}),
new HtmlWebpackPlugin({
template: './public/index.html'
})
]
};
host/public/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Host App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
host/src/App.jsx
import React, { Suspense, lazy } from 'react';
const RemoteProductApp = lazy(() => import('productApp/ProductApp'));
export default function App() {
return (
<div style={{ padding: 24 }}>
<h1>Host 主应用</h1>
<p>下面的内容来自远程子应用:</p>
<Suspense fallback={<div>远程模块加载中...</div>}>
<RemoteProductApp />
</Suspense>
</div>
);
}
host/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
host/src/index.js
import('./bootstrap');
3)如何运行
先分别安装依赖:
cd product-app && npm install
cd ../host && npm install
启动远程应用:
cd product-app
npm run start
再启动宿主应用:
cd host
npm run start
打开:
http://localhost:3000
你会看到 Host 页面中嵌入了 product-app 暴露的组件。
4)把它扩展到真实项目
上面的示例只证明一件事:远程模块可以被加载。
但真实项目里,你还要加上这些能力:
- 路由级懒加载
- 错误边界
- 超时与降级
- 远程地址环境化
- 共享基础库治理
- 样式隔离策略
- 监控与日志埋点
举个更接近生产的远程加载包装:
host/src/RemoteProductPage.jsx
import React, { Suspense, lazy } from 'react';
const ProductPage = lazy(() => import('productApp/ProductApp'));
function ErrorFallback() {
return <div>商品模块暂时不可用,请稍后重试。</div>;
}
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error) {
console.error('remote module load failed:', error);
}
render() {
if (this.state.hasError) {
return <ErrorFallback />;
}
return this.props.children;
}
}
export default function RemoteProductPage() {
return (
<ErrorBoundary>
<Suspense fallback={<div>商品模块加载中...</div>}>
<ProductPage />
</Suspense>
</ErrorBoundary>
);
}
这类包装在生产环境非常必要。因为远程模块失败,不应该把整个主应用拖死。
部署策略:如何做到独立发布又不失控
模块联邦的真正价值,通常体现在部署阶段。
一种常见部署模型
- Host 独立部署到
app.example.com - 商品 Remote 部署到
product.example.com - 订单 Remote 部署到
order.example.com - 静态资源通过 CDN 分发
remoteEntry.js由 Host 在运行时拉取
flowchart LR
A[用户访问 app.example.com] --> B[Host HTML/JS]
B --> C[读取远程配置]
C --> D[CDN: product remoteEntry.js]
C --> E[CDN: order remoteEntry.js]
D --> F[商品 chunk]
E --> G[订单 chunk]
远程地址不要写死
开发环境里写死 localhost 没问题,但生产环境必须环境化。常见方式有两种:
方式一:构建时注入
remotes: {
productApp: `productApp@${process.env.PRODUCT_REMOTE_URL}`
}
方式二:运行时注入远程清单
例如在页面启动时读取:
window.__REMOTE_CONFIG__ = {
productApp: 'productApp@https://cdn.example.com/product/remoteEntry.js'
};
这种方式的好处是:Host 不重新构建,也能切换 Remote 地址。
对灰度、回滚、多环境联调都很有帮助。
版本发布建议
在中型项目里,建议至少做到这几点:
remoteEntry.js可缓存,但要有版本控制策略- chunk 文件名带 contenthash
- 保留最近若干版本静态资源,支持快速回滚
- Host 和 Remote 建立兼容矩阵,别只靠“大家口头约定”
容量估算与成本边界
模块联邦不是免费午餐。你需要预留这些成本:
- 多应用构建链路维护成本
- 远程资源可用性治理
- 版本兼容测试
- 监控与告警链路建设
如果你的团队连基础 CI/CD 都还没稳定,直接上模块联邦,成功率不会太高。
先把工程纪律打稳,再上运行时编排。
常见坑与排查
这部分很重要,我尽量按真实问题来讲。
1. Shared module is not available for eager consumption
这类问题常出现在入口加载顺序不对,或者共享模块初始化时机异常。
典型处理方式
确保入口使用异步启动:
import('./bootstrap');
而不是直接在 index.js 里同步渲染。
排查思路
- 看 Host 和 Remote 是否都用了异步 bootstrap
- 看 shared 配置是否一致
- 看是否启用了
eager导致行为变化
2. React hooks 报错或 context 不生效
常见报错包括:
- Invalid hook call
- useContext 取不到值
这通常意味着:Host 和 Remote 没有真正共享同一个 React 实例。
检查点
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
如果版本差距过大,也可能导致共享失败。
建议
- 核心框架尽量统一主版本
- 对 React/Vue 这类运行时依赖一律单例
- 在 CI 里做依赖版本检查
3. 远程模块偶发 404
这是生产中非常常见的问题。原因一般有:
- 发布时
remoteEntry.js已更新,但其依赖 chunk 还没同步 - CDN 缓存不一致
- 删除了旧版本静态资源,老页面还在引用
止血建议
- 原子化发布静态资源
- 先上传资源,再切换入口
- 保留旧版本资源一段时间
remoteEntry.js缓存策略单独设计
4. 样式互相污染
模块联邦不是天然样式隔离。子应用加载进来后,CSS 还是在同一个 DOM 环境里。
解决思路
- 统一 CSS 命名规范,如 BEM
- 使用 CSS Modules
- 使用 CSS-in-JS 并控制注入顺序
- 对高风险区域使用 Shadow DOM(有成本)
如果团队样式纪律比较弱,我会优先推荐 CSS Modules + 设计系统约束,比强上 Shadow DOM 更现实。
5. 路由跳转冲突
Host 和 Remote 都想管路由时,最容易出现这些问题:
- 子应用跳转把主应用 history 搞乱
- 刷新后 404
- 菜单高亮状态不一致
建议
- Host 只管理一级路由
- Remote 管理内部子路由
- 明确 basename
- 历史模式统一约定
6. 本地联调很痛苦
常见现象:
- 一个子应用没启动,Host 就白屏
- 大家端口不统一
- 本地环境配置一堆手工步骤
建议
- 统一端口约定
- 做本地启动脚本
- Host 在开发环境支持 mock remote
- remote 不可用时提供本地降级页面
安全/性能最佳实践
模块联邦除了“能跑”,还要考虑“跑得稳、跑得快、别出事”。
安全最佳实践
1. 不要信任任意远程地址
Remote 本质上是远程执行的前端代码。
如果远程地址可被任意篡改,风险很大。
建议:
- 远程域名走白名单
- 配置中心变更要审计
- 生产环境只允许受信 CDN 域名
2. 控制共享模块暴露面
不是所有公共代码都适合暴露。
尽量避免暴露:
- 带敏感逻辑的内部 SDK
- 未稳定的业务状态管理模块
- 可被滥用的权限判断实现
3. 加 CSP 与子资源治理
如果条件允许,至少做这些:
- 配置 Content Security Policy
- 限制 script-src
- 静态资源开启 HTTPS
- 关键资源走完整性校验策略
性能最佳实践
1. 不要把模块联邦当成“自动优化器”
它解决的是组织与部署问题,不是天然让包更小。
如果拆分不合理,反而会增加:
- 首次请求数
- 远程加载等待
- 共享依赖协商成本
2. 远程模块按路由懒加载
别在首页一股脑把所有 remote 都拉下来。
最常见的收益点其实很朴素:访问哪个业务,再加载哪个业务。
3. shared 列表要克制
不是所有库都应该 shared。
适合 shared 的:
- React/Vue
- 设计系统
- 路由库
- 状态库(谨慎)
不适合滥 shared 的:
- 变化很快的业务包
- 小体积工具库
- 版本冲突频繁的依赖
4. 做远程模块失败降级
性能不只是“快”,也是“失败时还能用”。
建议至少做到:
- 加载超时提示
- 错误边界兜底
- 关键流程可回退到静态页或旧版本入口
5. 建立监控指标
我比较建议重点监控这些:
remoteEntry.js加载耗时- 远程 chunk 加载失败率
- Host 页面白屏率
- 子应用渲染耗时
- 版本分布与回滚次数
一套比较稳的落地建议
如果你准备在中型项目里落地模块联邦,我建议按这个顺序推进:
第一步:先选一个边界清晰的子系统试点
比如订单中心、营销页后台,而不是首页这种全站核心链路。
第二步:只做“页面级接入”
先让 Host 能挂载 Remote 页面,不急着共享太多业务模块。
第三步:收敛共享依赖
第一批共享通常只放:
- React / Vue
- UI 框架
- 埋点 SDK
- 请求基础层
第四步:补齐工程能力
包括:
- 独立 CI/CD
- 远程地址管理
- 监控告警
- 回滚策略
- 兼容测试
第五步:再考虑更细颗粒度复用
比如共享 Widget、表格组件、筛选面板。
别一开始就把所有东西都 remote 化,那样很容易复杂度失控。
总结
模块联邦在中型项目里的价值,核心不是“炫技”,而是三件事:
- 把团队协作从单体发布中解耦
- 把业务边界通过运行时模块真正落地
- 在保持统一用户体验的前提下支持独立演进
但它也有明确边界:
- 如果业务边界不清,先别上
- 如果工程基础不稳,先补 CI/CD 和监控
- 如果只是想共享代码,优先考虑 Monorepo 和组件库
最后给几个能直接执行的建议:
- 先按业务域拆,不要按技术层拆
- Host 做薄壳,Remote 自治
- React/Vue 这类核心依赖必须单例共享
- 远程地址必须环境化,最好支持运行时切换
- 一开始先做页面级接入,再逐步细化共享
- 上线前一定补齐错误边界、降级和回滚机制
如果你把模块联邦当成“更灵活的发布架构”,它会非常有价值;
如果你把它当成“万能解耦工具”,大概率会失望。
说到底,模块联邦解决的是合适边界上的协作问题。边界清楚,它是利器;边界混乱,它只是把复杂度换了个地方继续存在。