前端开发中的模块联邦实战:在中型项目中落地微前端架构的拆分、共享与部署策略
在很多团队里,微前端不是“为了先进而先进”,而是项目长到一定阶段后,被现实逼出来的工程选择。
我自己第一次在中型项目里推动模块联邦(Module Federation)时,最直观的痛点不是技术,而是协作:一个仓库里塞了越来越多业务模块,发版互相等待、样式互相污染、依赖版本拉扯,最后谁都觉得“改个按钮像做开颅手术”。这类场景里,模块联邦确实能解决一部分问题,但前提是:拆得对、共享得稳、部署得住。
这篇文章我会从中型项目落地的角度来讲,不只说概念,而是带你走完一条常见实施路径:为什么拆、怎么拆、共享什么、不共享什么、怎么上线、出问题怎么排查。
背景与问题
先说一个典型中型前端项目的状态:
- 团队 10~30 人,前端按业务线分组
- 主应用已经很重,构建时间明显上升
- 某些模块改动频繁,但每次都要跟主站一起发布
- UI 库、状态管理、路由方案开始出现“多版本共存”的风险
- 一些页面是新技术栈,一些页面是历史包袱,难以整体重构
这时大家往往会考虑几种方案:
- 继续维护单体前端
- 改造为 Monorepo + 包级拆分
- 使用 iframe 型微前端
- 使用模块联邦型微前端
为什么是模块联邦,而不是 iframe?
iframe 的隔离性最好,但用户体验通常最差:
- 路由同步麻烦
- 样式统一麻烦
- 通信复杂
- 首屏体验不自然
- SEO 和埋点也经常要特殊处理
模块联邦的思路更像是:运行时动态加载别的构建产物,并像本地模块一样消费它们。这意味着你既能拆分独立交付,又不至于割裂到像嵌套站点。
中型项目里最常见的三个误区
误区 1:按页面拆,不按边界拆
“订单页一个子应用,用户页一个子应用”看起来合理,但如果用户中心和订单中心大量共享组件、状态和权限逻辑,硬拆后反而增加耦合。
误区 2:什么都共享
React、UI 库、工具函数、业务 SDK、埋点模块、公共 hooks……全共享。结果是任何一个子应用升级依赖都容易牵一发而动全身。
误区 3:拆完才想部署策略
模块联邦不是只改 webpack 配置。你得提前想清楚:
- remoteEntry 放哪
- 是否允许独立发布
- 缓存策略怎么做
- 回滚怎么做
- 宿主应用如何容错
这些不想明白,线上稳定性会很脆弱。
方案对比与取舍分析
在架构设计阶段,我建议先做“轻量微前端”评估,而不是一上来全面模块联邦化。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单体前端 | 简单直接,调试容易 | 构建慢、协作冲突大、发版耦合 | 小团队或业务稳定阶段 |
| Monorepo 包拆分 | 复用性好、工程统一 | 仍然常常需要整体发布 | 强调工程治理但不急需独立部署 |
| iframe 微前端 | 隔离最强 | 体验割裂、通信复杂 | 强隔离、异构技术栈极多 |
| 模块联邦 | 运行时集成、可独立部署、体验自然 | 依赖共享和运行时问题更复杂 | 中型项目、多团队协作、需要独立发布 |
我对中型项目的建议
如果满足下面 4 条中的 3 条,可以认真考虑模块联邦:
- 不同业务线需要独立发版
- 有明确可拆分的业务域
- 团队已有一定前端工程化基础
- 能接受运行时装配带来的复杂度
如果只是“包太大、构建太慢”,优先考虑:
- 路由级按需加载
- 构建缓存
- Monorepo 包拆分
- 公共库抽离
不要把模块联邦当成性能银弹。
核心原理
模块联邦的关键点,可以概括成三件事:
- Expose:把本应用的模块暴露给外部使用
- Remote:把别的应用当远程模块引入
- Shared:多个应用共享依赖,避免重复加载或版本冲突
一个最小认知模型
- Host(宿主应用):主壳,负责路由、布局、导航、容错
- Remote(远程应用):按业务域拆分的子应用
- remoteEntry.js:远程应用暴露模块的入口清单
- shared scope:运行时共享依赖池
flowchart LR
A[Host 宿主应用] --> B[加载 remoteEntry.js]
B --> C[订单子应用 Remote]
B --> D[用户子应用 Remote]
A --> E[共享依赖池]
C --> E
D --> E
运行时加载过程
当宿主应用访问某个远程模块时,大致流程是:
- 浏览器先请求远程应用的
remoteEntry.js - 远程容器注册自己能提供哪些模块
- 宿主与远程协商 shared 依赖
- 实际业务模块再被异步拉取并执行
- 组件挂载到宿主应用中
sequenceDiagram
participant U as 用户浏览器
participant H as Host
participant R as RemoteEntry
participant M as 远程业务模块
U->>H: 打开宿主页面
H->>R: 请求 remoteEntry.js
R-->>H: 返回暴露模块清单
H->>H: 初始化 shared scope
H->>M: 请求具体远程 chunk
M-->>H: 返回组件代码
H-->>U: 渲染远程页面
Shared 的本质
Shared 并不是“自动帮你解决所有版本冲突”,它只是提供一种运行时共享机制。常见配置项的含义:
singleton: true:只允许一个实例,适合 React/Vue 这类运行时核心库requiredVersion:声明需要的版本范围eager: true:提前加载,谨慎使用,容易放大首屏体积
经验上:
react/react-dom适合共享为 singleton- 大型 UI 库视情况共享
- 业务工具库不要轻易共享,版本变化快时更危险
- “看起来公共”的东西,不一定适合 shared
拆分策略:从业务域而不是从代码目录下手
模块联邦最关键的不是配置,而是边界设计。
推荐拆分原则
1. 以业务域拆分
比如中台系统:
shell:宿主壳应用user-app:用户中心order-app:订单中心report-app:报表中心
这种拆法通常优于:
table-appform-appmodal-app
因为后者是按技术组件拆,业务上下文会被切碎,通信成本会很高。
2. 宿主只保留“壳能力”
宿主应用应该尽量薄,只负责:
- 一级路由
- 菜单导航
- 登录态校验
- 权限守卫
- 通用布局
- 异常兜底
不要把大块业务又偷偷塞回宿主,不然最后还是“伪微前端”。
3. 跨应用共享要克制
共享的东西越多,独立性越差。中型项目里,我通常建议分三层:
- 必须共享:React、ReactDOM
- 可选共享:设计系统、埋点 SDK、国际化基础包
- 尽量不共享:业务 hooks、业务 utils、接口封装层
一个推荐的目录组织
apps/
shell/
user-app/
order-app/
packages/
design-system/
eslint-config/
ts-config/
这里的思路是:
- 应用层用模块联邦装配
- 工程基础设施层用 Monorepo 统一管理
这是中型项目里比较稳的一种组合,而不是“所有问题都交给模块联邦”。
实战代码(可运行)
下面给一个简化但可运行的示例。技术栈使用 Webpack 5 + React。
目标效果:
shell作为宿主应用user-app作为远程应用- 宿主应用通过模块联邦动态加载远程组件
目录结构
mf-demo/
shell/
src/
App.jsx
bootstrap.jsx
index.js
webpack.config.js
package.json
user-app/
src/
UserCard.jsx
index.js
webpack.config.js
package.json
远程应用:user-app
user-app/package.json
{
"name": "user-app",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --config webpack.config.js --port 3001"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0",
"@babel/preset-react": "^7.24.7",
"babel-loader": "^9.1.3",
"html-webpack-plugin": "^5.6.0",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
}
user-app/src/UserCard.jsx
import React from "react";
export default function UserCard() {
return (
<div style={{ padding: 16, border: "1px solid #ddd", borderRadius: 8 }}>
<h3>用户中心远程组件</h3>
<p>这是从 user-app 暴露出来的模块。</p>
</div>
);
}
user-app/src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import UserCard from "./UserCard";
const rootEl = document.getElementById("root");
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(<UserCard />);
}
user-app/webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
mode: "development",
entry: "./src/index.js",
devServer: {
port: 3001,
headers: {
"Access-Control-Allow-Origin": "*"
}
},
output: {
publicPath: "auto"
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"]
}
}
}
]
},
resolve: {
extensions: [".js", ".jsx"]
},
plugins: [
new ModuleFederationPlugin({
name: "userApp",
filename: "remoteEntry.js",
exposes: {
"./UserCard": "./src/UserCard.jsx"
},
shared: {
react: {
singleton: true,
requiredVersion: "^18.2.0"
},
"react-dom": {
singleton: true,
requiredVersion: "^18.2.0"
}
}
}),
new HtmlWebpackPlugin({
templateContent: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>user-app</title></head>
<body><div id="root"></div></body>
</html>
`
})
]
};
宿主应用:shell
shell/package.json
{
"name": "shell",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --config webpack.config.js --port 3000"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0",
"@babel/preset-react": "^7.24.7",
"babel-loader": "^9.1.3",
"html-webpack-plugin": "^5.6.0",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
}
shell/src/App.jsx
import React, { Suspense } from "react";
const RemoteUserCard = React.lazy(() => import("userApp/UserCard"));
export default function App() {
return (
<div style={{ padding: 24 }}>
<h1>Shell 宿主应用</h1>
<p>下面的卡片来自远程应用 user-app:</p>
<Suspense fallback={<div>远程模块加载中...</div>}>
<RemoteUserCard />
</Suspense>
</div>
);
}
shell/src/bootstrap.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
shell/src/index.js
import("./bootstrap");
shell/webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
mode: "development",
entry: "./src/index.js",
devServer: {
port: 3000
},
output: {
publicPath: "auto"
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"]
}
}
}
]
},
resolve: {
extensions: [".js", ".jsx"]
},
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
userApp: "userApp@http://localhost:3001/remoteEntry.js"
},
shared: {
react: {
singleton: true,
requiredVersion: "^18.2.0"
},
"react-dom": {
singleton: true,
requiredVersion: "^18.2.0"
}
}
}),
new HtmlWebpackPlugin({
templateContent: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>shell</title></head>
<body><div id="root"></div></body>
</html>
`
})
]
};
运行步骤
先启动远程应用:
cd user-app
npm install
npm run start
再启动宿主应用:
cd shell
npm install
npm run start
然后访问:
http://localhost:3000
你会看到宿主页面中渲染了来自 user-app 的组件。
在真实项目中如何扩展这套示例
上面的例子只是“组件级接入”。真实项目一般会继续扩展成下面几种模式:
模式 1:页面级暴露
远程应用直接暴露路由页面组件,例如:
exposes: {
"./UserRoutes": "./src/routes"
}
宿主统一注册一级路由,子应用自己维护二级路由。
模式 2:挂载函数暴露
有些团队会暴露 mount/unmount 函数,而不是 React 组件,这对异构框架更友好:
exposes: {
"./mount": "./src/mount"
}
这种方式更接近“微应用接入协议”,适合未来可能接入 Vue、React 混合系统。
模式 3:配置中心驱动 remote 地址
不要把线上地址硬编码在 webpack 里。实际项目常见做法是:
- 测试环境 remote 指向测试 CDN
- 预发环境指向预发 CDN
- 生产环境由配置中心下发版本与地址
部署策略:真正决定你能不能稳定上线
很多文章讲到这里就停了,但线上可用性其实更多取决于部署设计。
推荐部署原则
1. 宿主与子应用独立部署
shell独立发布user-app独立发布order-app独立发布
这样业务线可以按需上线,不互相阻塞。
2. 远程入口稳定,业务资源带 hash
一个很实用的策略:
remoteEntry.js使用稳定访问路径- 远程业务 chunk 使用内容 hash 文件名
这样宿主只需要拿到最新入口清单,而静态资源仍可长期缓存。
3. 给 remoteEntry 设置短缓存
因为 remoteEntry.js 相当于模块映射表,不能长时间缓存过期版本。通常我会建议:
remoteEntry.js:短缓存或 no-cache- 业务 chunk:强缓存 + hash
flowchart TD
A[发布子应用] --> B[生成新的 chunk hash]
B --> C[上传静态资源到 CDN]
C --> D[更新 remoteEntry.js]
D --> E[宿主应用下次访问拉取新映射]
回滚策略要提前设计
线上最怕的不是出 bug,而是远程应用发布后宿主全部白屏。
建议至少准备两层回滚:
- CDN 层快速回滚 remoteEntry.js
- 宿主层降级容错,远程加载失败时显示兜底页
比如:
import React, { Suspense } from "react";
const RemoteUserCard = React.lazy(() => import("userApp/UserCard"));
function ErrorFallback() {
return <div>用户模块暂时不可用,请稍后重试。</div>;
}
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <ErrorFallback />;
}
return this.props.children;
}
}
export default function App() {
return (
<ErrorBoundary>
<Suspense fallback={<div>加载中...</div>}>
<RemoteUserCard />
</Suspense>
</ErrorBoundary>
);
}
容量估算与架构边界
中型项目里,模块联邦也不是拆得越多越好。
一个经验判断
如果一个团队维护的子应用数量已经超过 8~10 个,且每个子应用都需要:
- 独立 CI/CD
- 独立监控
- 独立权限与埋点
- 独立版本治理
那么运维和协作成本会明显上升。
我建议的边界
对中型项目来说,通常控制在:
- 1 个宿主壳
- 2~5 个核心远程应用
比较容易管理。
再往上扩时,最好引入:
- 统一接入规范
- 统一监控 SDK
- 统一错误码和日志规范
- 统一 remote 注册中心
否则“架构先进”,但团队会越来越累。
常见坑与排查
这一部分很重要。我把实战里最常见的问题和定位思路整理成一个“排障顺序”。
1. 远程模块加载失败
现象
浏览器控制台报错:
Loading script failed.
或者:
Cannot find module 'userApp/UserCard'
常见原因
remoteEntry.js地址配置错了- 远程应用没启动或没部署成功
- 跨域头没配
name和remotes中的容器名不一致exposes路径写错
排查顺序
- 直接在浏览器打开
http://localhost:3001/remoteEntry.js - 检查
ModuleFederationPlugin中的name - 检查宿主里的
userApp@...是否和远程名字一致 - 检查跨域头是否正确返回
2. React Hooks 异常或上下文失效
现象
报错类似:
Invalid hook call
或者 Context、Redux store 表现异常。
常见原因
通常是 React 被加载了多个实例。
解决方式
确保 react 和 react-dom 都配置为:
shared: {
react: {
singleton: true,
requiredVersion: "^18.2.0"
},
"react-dom": {
singleton: true,
requiredVersion: "^18.2.0"
}
}
如果还是有问题,检查:
- lock 文件是否拉出了多版本
- 某个远程应用是否把 React 打进了自己的 bundle
- 包管理器是否存在链接依赖导致重复实例
3. publicPath 不对,静态资源 404
现象
remoteEntry.js 能加载,但后续 chunk 404。
原因
远程应用在加载自己的异步 chunk 时,拼接出来的资源前缀不对。
解决建议
多数情况下直接配置:
output: {
publicPath: "auto"
}
这是我在联调时最常修的一个点,尤其是部署到 CDN 子路径时。
4. 样式污染
现象
某个子应用上线后,宿主或其他子应用样式突然变了。
原因
- 全局样式覆盖
- reset.css 重复注入
- CSS 类名不隔离
解决建议
- 优先使用 CSS Modules 或 CSS-in-JS
- 将全局 reset 收敛到宿主壳
- 约定设计系统的样式前缀
- 不要在子应用中随意改
body、html样式
5. 路由互相抢占
现象
进入子应用页面后刷新,404;或浏览器回退异常。
原因
宿主和子应用都在处理 history 路由,但边界没有划清。
建议做法
- 宿主管一级路由
- 子应用管理自己域内路由
- 明确 basename
- 后端网关统一做 history fallback
stateDiagram-v2
[*] --> ShellRoute
ShellRoute --> UserAppRoute: /user/*
ShellRoute --> OrderAppRoute: /order/*
UserAppRoute --> UserAppRoute: 子路由跳转
OrderAppRoute --> OrderAppRoute: 子路由跳转
安全/性能最佳实践
模块联邦是运行时加载远程代码,所以安全和性能都不能只看“能跑”。
安全最佳实践
1. 只加载可信来源的远程资源
远程模块本质上就是执行一段远端 JS。生产环境中一定要:
- 限定 remote 域名白名单
- 配合 HTTPS
- 不允许随意拼接第三方地址
2. 配置 CSP
使用内容安全策略,限制脚本来源,避免 remote 被劫持后造成更大风险。
一个简化示例:
Content-Security-Policy: script-src 'self' https://cdn.example.com;
3. 对 remote 配置做中心化治理
不要让每个前端项目自己维护一套 remote 地址映射。建议通过:
- 配置平台
- 灰度发布平台
- 服务端下发配置
来统一管理。
4. 避免在共享层暴露敏感逻辑
不要把鉴权密钥、签名逻辑、内部调试接口直接做成 shared 公共模块。共享层应该是基础能力,不该承载敏感实现。
性能最佳实践
1. 不要为拆而拆
模块联邦会增加运行时请求和初始化成本。拆分应优先服务于:
- 独立发布
- 团队协作
- 业务边界清晰
而不是只为了“让包看起来更小”。
2. 远程模块按路由懒加载
不要在首屏一次性加载所有 remote。把远程模块挂到真正需要访问的路由上。
const UserPage = React.lazy(() => import("userApp/UserPage"));
const OrderPage = React.lazy(() => import("orderApp/OrderPage"));
3. 关键依赖共享,但不要过度共享
共享依赖太多,初始化协商成本也会上升,还会增加版本联动风险。
4. 做好监控
至少埋下面几类指标:
- remoteEntry 加载耗时
- 远程 chunk 加载耗时
- 加载失败率
- 远程模块渲染耗时
- 兜底页触发次数
这是判断模块联邦是否“真的跑稳了”的依据。
5. 宿主应用要有超时与降级
远程模块如果长时间没返回,不应该拖死整个页面。
function loadRemoteWithTimeout(loader, timeout = 5000) {
return Promise.race([
loader(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("remote load timeout")), timeout)
)
]);
}
配合 React.lazy 时可以做一层包装,失败就走兜底 UI。
一套更稳的落地建议
如果你正准备在中型项目里引入模块联邦,我会建议按下面节奏推进:
第一步:先做单点试验
选一个边界清晰、变更频繁、与主站耦合适中的业务模块试点。不要一上来拆核心首页。
第二步:先统一规范,再扩展应用数
至少先约定:
- 子应用命名规范
- 路由边界规范
- shared 白名单
- 错误兜底规范
- 发布和回滚流程
第三步:把“工程治理”放在“业务拆分”前面
比如提前建设:
- CI/CD 模板
- 静态资源发布规范
- 监控告警
- 灰度能力
- 版本回溯能力
第四步:持续评估边界是否合理
拆分不是一劳永逸。某些业务域后续可能:
- 继续独立演进
- 合并回主应用
- 抽成共享包
这都很正常,别把边界神圣化。
总结
模块联邦非常适合中型前端项目里那种“业务已复杂,但还没复杂到要全面平台化”的阶段。它最有价值的地方,不是炫技,而是三件事:
- 让业务域真正独立交付
- 让主壳与子业务解耦
- 在不牺牲体验的前提下引入微前端能力
但我想强调一个实际结论:模块联邦是架构工具,不是性能工具,也不是组织问题的万能解药。
落地时最值得优先做好的,不是 webpack 配置,而是这 5 件事:
- 按业务域拆分,而不是按组件目录拆分
- 只共享真正稳定且必须共享的依赖
- 宿主应用保持“壳化”,别回流业务逻辑
- 提前设计部署、缓存、回滚和降级策略
- 用监控数据验证收益,而不是凭感觉判断成功
如果你的项目还处在“一个前端团队、一个版本节奏、业务边界不清”的阶段,那先别急着上模块联邦;但如果你已经明显感受到多团队协作和独立发布的压力,它就很值得认真试一次。