引言
一个真实场景:公司有三个前端团队,共享一个 React 巨型仓库。A 团队改了一下顶部导航栏,B 团队负责的订单页突然报错;一次发版要三个团队轮流回归三天;CI 构建一次四十分钟。最终团队得出共识——"必须拆了",但拆到一半发现比想象中难得多。
微前端(Micro-frontend)解决的从来不是"代码太多"这种技术问题,而是"几个团队要独立往前走,却被迫绑在同一辆车上"的组织问题。本文不讲概念空话,直接讲怎么落地:三条技术路线怎么选、应用怎么拆、依赖怎么共享、路由和通信怎么做、怎么独立部署,以及最关键的——什么时候根本不该用。
一、为什么需要微前端:问题在团队,不在代码
康威定律(Conway's Law)说得很直白:系统架构会复制组织的沟通结构。如果你有五个互相独立的团队,却维护一个单体前端,架构和组织的错配迟早会以"发布排队""互相踩脚""无法独立演进"的形式爆发。
单体前端的三个真实症状:
- 发布耦合——任何人的改动都要走同一条流水线,一个人阻塞所有人。
- 构建变慢——代码无限膨胀,热更新从秒级退化到分钟级。
- 技术栈锁死——整个前端被钉在一个框架版本上,想升级 React 或引入新方案,等于重写全部。
微前端给的是这三件事的解药:独立开发、独立部署、独立技术栈。每个子应用由对应团队端到端负责,团队边界就是应用边界,技术选型、发版节奏、代码规范全部下放。
它适合的组织形态,一张表说清:
| 团队结构 | 适合的方案 | 理由 |
|---|---|---|
| 一个团队、一个应用 | 单体前端 | 微前端纯属负担 |
| 多个团队、业务域清晰 | 微前端 | 边界天然对应业务域 |
| 多个团队、业务域耦合 | 先梳理领域边界 | 硬拆只会把耦合搬进运行时 |
| 需要渐进式改造遗留系统 | qiankun 类 | HTML 入口,老系统可零改造接入 |
核心判断标准只有一条:如果两个团队因为彼此的工作而频繁互相等待,它们就应该被拆到不同的应用里。
二、三条主流路线:Module Federation / single-spa / qiankun
市面上方案很多,但真正站得住脚的是这三条,它们代表三种完全不同的哲学。
**Module Federation(模块联邦)**是构建层面的能力,来自 Webpack 5 / Rspack。它让一个应用在运行时"远程加载"另一个应用暴露出来的模块,就像加载本地模块一样。哲学是"共享模块,而非共享页面"。
// host/webpack.config.js —— 宿主应用
const { ModuleFederationPlugin } = require('webpack').container
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
products: 'products@http://localhost:3001/remoteEntry.js',
cart: 'cart@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
}// products/webpack.config.js —— 子应用暴露模块
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: { './ProductList': './src/ProductList' },
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})single-spa 是纯运行时调度框架,框架无关,它只负责"根据当前 URL 决定挂载/卸载哪些应用"。它不关心子应用用什么框架、从哪加载,全靠 registerApplication + start 声明式调度。
// 根配置
import { registerApplication, start } from 'single-spa'
registerApplication({
name: '@org/products',
app: () => System.import('@org/products'),
activeWhen: ['/products'],
})
registerApplication({
name: '@org/cart',
app: () => System.import('@org/cart'),
activeWhen: ['/cart'],
})
start()qiankun 是国内团队最熟的方案,它基于 single-spa 封装,最大的杀手锏是"HTML 入口"——直接把一个子应用的完整 HTML 当入口,配上 JS 沙箱和 CSS 隔离,让老项目几乎零改造就能接入。子应用只需导出三个生命周期。
// 宿主
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{ name: 'products', entry: '//localhost:3001', container: '#subapp-container', activeRule: '/products' },
{ name: 'cart', entry: '//localhost:3002', container: '#subapp-container', activeRule: '/cart' },
])
start()// 子应用入口需要导出生命周期
export async function bootstrap() {}
export async function mount(props) {
ReactDOM.createRoot(props.container.querySelector('#root')).render(<App />)
}
export async function unmount() {}三者对比:
| 维度 | Module Federation | single-spa | qiankun |
|---|---|---|---|
| 定位 | 构建期共享运行时的机制 | 运行时调度框架 | single-spa 的封装 |
| 入口方式 | 远程模块 remoteEntry.js | JS 入口(SystemJS / import map) | HTML 入口 |
| JS 沙箱 / CSS 隔离 | 无,靠 singleton 去重 | 无,需自行处理 | 内置 |
| 上手成本 | 中(要懂构建配置) | 高(要理解运行时与加载器) | 低(封装完善) |
| 适用场景 | 大型组织、构建敏感、需精细控制 | 极致框架无关、运行时高度自定义 | 遗留系统改造、快速接入 |
一句话选型:要精细控制构建产物和依赖去重 → Module Federation;要最大自由度自己搭运行时 → single-spa;要最快把一堆老系统拼起来 → qiankun。
三、怎么拆应用:按业务域垂直切,别按技术层横切
拆分最常犯的错,是按技术层横切——"组件团队""页面团队""工具团队"。这样切出来的应用之间互相依赖,等于把单体搬进了运行时。
正确做法是按业务域垂直切:每个子应用是"一个业务域完整的、从 UI 到数据请求的端到端切片"。
✗ 横切(错误): ✓ 纵切(正确):
components/ products/ —— 商品浏览、详情、搜索
pages/ cart/ —— 购物车、结算
utils/ account/ —— 登录、订单、个人中心
hooks/拆分落地时守三条原则:
- 边界内的数据归子应用自己管,父应用不越权读写子应用的内部状态。
- 子应用之间不直接 import 对方代码,只通过路由、事件、URL 通信。
- 每个子应用能独立
npm run dev、独立构建、独立部署,做不到就是边界没划干净。
一个规范的子应用目录,就是标准的独立前端工程:
products/
├── src/
│ ├── main.tsx # 入口:同时支持独立运行和被宿主加载
│ ├── App.tsx
│ └── api/
├── package.json
├── webpack.config.js
└── tsconfig.json关键在 main.tsx 支持双模式:独立开发时自己挂载,被宿主加载时导出生命周期。
// products/src/main.tsx —— 双模式入口
import { createRoot, Root } from 'react-dom/client'
import App from './App'
let root: Root | null = null
// 独立运行模式(本地开发 / 独立部署)
if (!(window as any).__POWERED_BY_QIANKUN__) {
root = createRoot(document.getElementById('root')!)
root.render(<App />)
}
// 被宿主加载时导出生命周期
export async function bootstrap() {}
export async function mount(props: any) {
root = createRoot(props.container.querySelector('#root'))
root.render(<App />)
}
export async function unmount() {
root?.unmount()
root = null
}四、共享依赖:singleton 是命门
微前端最容易翻车的地方不在路由,在依赖重复加载。如果一个页面里同时挂了三个子应用,每个都打包了一份 React,你会得到三份 React 实例——useContext 跨应用失效、组件树断裂、内存和首屏体积翻倍。
Module Federation 用 shared + singleton: true 解决:告诉构建器"这个依赖全应用只允许有一份实例"。
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'react-router-dom': { singleton: true },
}singleton: true 的语义是:如果宿主已经加载了 React,子应用就复用宿主的,不再下载自己的副本;版本不匹配时按 requiredVersion 协商,协商失败则在控制台告警。
qiankun 走的是另一条路——externals 外置:把 React、ReactDOM 等大依赖从子应用构建里排除,改由宿主通过 CDN 或 <script> 统一提供,子应用直接用全局变量。
// 子应用 webpack 配置:把 React 外置
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
}两条路殊途同归,原则一致:大而基础、又要求"全应用唯一实例"的依赖,必须由宿主统一持有。业务自己的库、UI 组件库则可以各自打包,互不干扰。
五、路由:宿主统一调度,子应用各管自己内部
路由策略推荐"两级路由":
- 一级路由(宿主):决定"当前该激活哪个子应用",通常是
/products、/cart这样的前缀。 - 二级路由(子应用):子应用被激活后,内部的路由(如
/products/:id)完全由自己处理。
// 宿主:一级路由决定激活哪个子应用
registerMicroApps([
{ name: 'products', entry: '//localhost:3001', container: '#subapp-container', activeRule: '/products' },
{ name: 'cart', entry: '//localhost:3002', container: '#subapp-container', activeRule: '/cart' },
])子应用内部照常用自己的路由库,但要以 activeRule 的前缀作为 base,否则切子应用时路径会错乱。
// 子应用内部:路由 base 对齐宿主的前缀
import { BrowserRouter, Routes, Route } from 'react-router-dom'
export default function App() {
return (
<BrowserRouter basename="/products">
<Routes>
<Route path="/" element={<ProductList />} />
<Route path="/:id" element={<ProductDetail />} />
</Routes>
</BrowserRouter>
)
}这样宿主只关心"切没切域",子应用内部的跳转、参数、嵌套都不必上报,边界干净。
六、应用间通信:能少则少,按成本从低到高选
微前端通信的黄金法则是尽量减少跨应用状态。每多一个跨应用共享状态,就多一处耦合。真有需要时,按成本从低到高依次选:
第一层:URL——最廉价、天然可分享、可回退。能用 URL 传的(商品 ID、tab、筛选条件)绝不用别的。
// 跨应用跳转时把状态放进 URL
const productId = '1234'
window.history.pushState(null, '', `/products/${productId}`)第二层:props 下发——宿主把共享数据(当前用户、主题、令牌)和回调注入子应用的生命周期。
// 宿主:把共享状态和回调作为 props 传给子应用
const user = useUser()
<MicroApp name="cart" user={user} onCheckout={handleCheckout} />// 子应用:从生命周期 props 里取
export async function mount(props) {
const { user, onCheckout } = props
root.render(<App user={user} onCheckout={onCheckout} />)
}第三层:事件总线——松耦合的发布/订阅,适合"购物车加购后通知角标刷新"这类跨应用通知。由宿主创建并注入,避免每个子应用各自造一个。
// 宿主提供的极简事件总线
export function createBus() {
const listeners = new Map()
return {
on(type, fn) {
if (!listeners.has(type)) listeners.set(type, new Set())
listeners.get(type).add(fn)
return () => listeners.get(type).delete(fn)
},
emit(type, payload) {
listeners.get(type)?.forEach((fn) => fn(payload))
},
}
}
// 购物车加购后广播
bus.emit('cart:updated', { count: 3 })
// 导航栏订阅角标
bus.on('cart:updated', ({ count }) => setBadge(count))第四层:全局状态库——最重的方案,只有真正的全局数据(如用户登录态)才值得进全局 store。优先用宿主提供的单例 store,子应用只读,避免多份实例分叉。
七、部署策略:独立发布是微前端唯一的硬收益
如果部署还是"全量一起发",那微前端就白拆了。独立部署才是整套架构的意义所在:A 团队发版不碰 B 团队,一个子应用回滚不影响全局。
落地要处理三个现实问题:
1. 独立构建、独立上传。每个子应用产出一个可独立发布的产物(CDN 上的一个 JS 包或一个 HTML),各团队各自走流水线。
# 每个子应用独立发布,互不影响
products/ → build → upload → cdn.example.com/products/1.4.0/
cart/ → build → upload → cdn.example.com/cart/2.1.0/2. 用 import map 做版本切换和灰度。宿主通过 import map 决定"这次加载哪个版本",升级子应用时只改这一行,不用重新构建宿主。
<script type="importmap">
{
"imports": {
"@org/products": "https://cdn.example.com/products/1.4.0/products.js",
"@org/cart": "https://cdn.example.com/cart/2.1.0/cart.js"
}
}
</script>3. 处理版本漂移。子应用可以各自升级,但宿主和子应用之间、子应用和子应用之间会出现版本不兼容。对策是:共享依赖定契约(semver + singleton 协商)、跨应用 API 保持向后兼容、灰度发布 + 快速回滚,而不是强求全栈锁版本。
回滚尤其要快:因为子应用独立部署,出问题只需把 import map 或 CDN 指向回退到上一个版本,宿主和其他子应用纹丝不动。
八、什么时候坚决不要用微前端
微前端不是免费的。它带来的成本是实打实的:额外的运行时开销、更复杂的构建与调试、跨应用一致性的维护负担、以及一旦边界划错反而加剧的耦合。下列场景,直接用单体前端就好:
| 场景 | 原因 |
|---|---|
| 只有一个团队,或团队间没有发布冲突 | 微前端只解决组织问题,没有问题就别引入问题 |
| 应用本身很小(几个页面) | 拆分的成本远超收益 |
| 子应用之间高度耦合、频繁同步状态 | 硬拆等于把耦合搬进运行时,更难调 |
| 需要全局一致的体验和共享组件库 | 多应用很难保证像素级一致 |
| 团队还不具备独立部署的工程能力 | 没有独立发布,微前端就是空壳 |
| 追求极致首屏性能 | 运行时调度、多份框架副本都有额外开销 |
一个反例值得警惕:为了"技术先进"而拆。如果拆完之后,发布还是要排队、状态还是要跨应用同步、Bug 还是分不清归谁,那这次拆分只是把单体换了个更贵的形式。
判断要不要拆,回到开头那句话:拆,是因为两个团队在互相等待;不拆,是因为它们本就不该分开。
结语
微前端是组织结构的投影,不是性能优化手段。选对工具(Module Federation 控依赖、single-spa 控运行时、qiankun 控迁移成本)、按业务域垂直切、共享依赖用 singleton、通信从 URL 往全局状态层层升级、部署做到真正独立——这五件事做对了,微前端才能兑现"团队各自往前走"的承诺。做不对,它就是一个更贵、更难调的单体。
落地之前,先问自己一句:我到底是在解决团队边界问题,还是在追逐一个听起来很酷的架构? 答不上来,就先用单体。