微前端实践:拆分边界、路由与样式隔离的取舍
本文目录
微前端不是技术升级,是组织结构在代码上的投影:多个团队、多个技术栈、独立发布的节奏,共同挤在一个仓库或一个 SPA 里,冲突越来越多,才需要"把整块的东西拆开"。这篇文章按决策顺序展开:什么时候需要微前端、技术方案对比、通信、路由、隔离、公共依赖、灰度与回滚,最后是一份诚实成本清单。
先亮观点:微前端把"团队间的协调成本"从"开会"转移到了"运维与协议",总量通常不减反增。它解决的是"多团队并行演进一个大型前端"的协同问题,不解决代码质量、性能、复用这些单体里就该解决的问题。
什么时候需要微前端
判断标准是组织信号,不是技术信号:
| 信号 | 需要微前端 | 不需要微前端 |
|---|---|---|
| 团队数量 | 3+ 个团队长期并行开发同一前端 | 1-2 个团队,能坐在一起对齐 |
| 发布节奏 | 各团队需要独立按自己的节奏上线 | 统一版本统一发 |
| 技术栈 | 存量 React/Vue/老 jQuery 必须共存 | 单一框架,重构比共存便宜 |
| 代码体积 | 构建 10 分钟起、改一行全量回归 | 构建分钟级,全量回归可控 |
| 原子性 | 各业务域之间天然低耦合 | 页面之间高度共享状态和组件 |
一个务实的自测:如果你回答不出"拆完之后哪两个团队的发布可以彻底互不影响",说明边界还没想清楚,微前端只会把单体里的循环依赖变成跨应用的远程依赖——更难查。
上图是典型的基座结构:基座负责壳与路由,子应用按业务域拆,共享层只收敛"跨应用都一样"的东西(登录态、主题、埋点 SDK)。箭头方向就是依赖方向——子应用不直接互相依赖,这是微前端能拆干净的前提。
四种技术方案对比
| 方案 | 隔离强度 | 通信成本 | 生态/成熟度 | 主要代价 |
|---|---|---|---|---|
| iframe | 最强(天然进程级) | 高(只能 postMessage) | 浏览器原生,最稳 | 体验割裂:弹窗、URL、键盘、性能 |
| qiankun | 较强(JS 沙箱 + 样式隔离可配) | 中(props + 事件总线) | 国内大量生产使用 | 约束子应用导出生命周期,调试复杂 |
| Module Federation | 弱(共享运行时,同框架最佳) | 低(像普通模块引用) | webpack 5 内置,构建工具支持参差 | 强绑定构建链路,跨框架共享受限 |
| Web Components | 组件级(Shadow DOM) | 低(DOM 事件) | 浏览器标准,长期方向 | 只解决组件层,不是应用级拆分 |
四者的定位其实不同层:iframe 和 qiankun 是"应用级"拆分,Module Federation 是"模块级"共享,Web Components 是"组件级"跨框架。下面给出前两者的核心代码形态。
qiankun:加载与生命周期托管
// 基座:注册子应用,声明路由规则与挂载点
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'orders',
entry: 'https://orders.example.com',
container: '#subapp-viewport',
activeRule: '/orders',
props: {
// 只传必要的上下文,不要把全局权限或用户信息整包下发
getToken: () => authStore.token,
onRouteChange: (to) => router.push(to),
},
},
]);
start({ prefetch: 'all', sandbox: { experimentalStyleIsolation: true } });
子应用需要导出 bootstrap / mount / unmount 生命周期,构建产物允许跨域访问(entry 返回 HTML,JS 走 CORS)。qiankun 的价值在于替你做了加载、沙箱、样式隔离这些脏活,代价是子应用的构建和调试都要顺着它的约定走,出问题时排查链路比单体长。
Module Federation:像引本地模块一样引远程模块
// 子应用 webpack 5 配置(Rspack 同款 API;Vite 生态有社区插件,成熟度需自行评估)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'orders',
filename: 'remoteEntry.js',
exposes: { './OrderList': './src/components/OrderList' },
shared: {
react: { singleton: true, requiredVersion: '^18' },
'react-dom': { singleton: true, requiredVersion: '^18' },
},
}),
],
};
消费端用 import('orders/OrderList') 就像引本地模块,shared 声明让宿主与远程复用同一个 React 实例(避免 hooks 报"两个 React 实例"的经典错误)。它最同构于"一个团队拆代码",不适合异构技术栈;把整个应用经 MF 暴露出去做成"类微前端",隔离和独立部署能力都明显弱于 iframe/qiankun。
Web Components 与 iframe 的位置
Web Components 解决的是"跨框架复用组件"(设计系统、业务组件库),适合作为各子应用之间的共享组件载体,而不是应用拆分方案——它在本文的定位是配角,详见配套的 Web Components 实战。iframe 则仍是隔离要求最高场景(嵌入第三方页面、安全域完全不同)的下限选择,它的一切缺点(弹窗、路由、通信)都源于它的一切优点(隔离彻底)。
应用间通信:安全边界优先于便利性
通信是微前端里最容易失控的部分——起步时两个接口,一年后变成没有约束的全局消息网络。先立规矩再写代码。
| 通信方式 | 适合内容 | 安全边界 |
|---|---|---|
| 基座 → 子应用 props | 登录态、主题、环境配置 | 只读快照 + getter 函数,避免传可变引用 |
| 事件总线 | 通知类消息(登录失效、主题切换) | 命名空间 + 类型契约 + 卸载时解绑 |
| URL 参数 | 页面间跳转的可序列化状态 | 不放敏感数据,长度可控 |
| 共享全局状态 | 只有"全站确实一样"的状态(当前用户) | 只读字段,写权限归基座 |
| 服务端 | 业务数据 | 通信全部经过 API,本来就是规范 |
// 一个带类型契约的事件总线:消息是联合类型,禁止裸字符串满天飞
type AppEvent =
| { type: 'auth:expired' }
| { type: 'theme:changed'; payload: { theme: 'light' | 'dark' } }
| { type: 'locale:changed'; payload: { locale: string } };
const bus = new EventTarget();
export function emit(event: AppEvent): void {
bus.dispatchEvent(new CustomEvent(event.type, { detail: 'payload' in event ? event.payload : null }));
}
export function on(
type: AppEvent['type'],
handler: (payload: unknown) => void
): () => void {
const listener = (e: Event) => handler((e as CustomEvent).detail);
bus.addEventListener(type, listener);
return () => bus.removeEventListener(type, listener); // 子应用 unmount 必须调用
}
两条安全边界必须写进规范:事件名带命名空间(auth:、theme:),新增事件要走契约评审,防止"改名了三个应用不知道";回调/监听的生命周期与子应用一致,qiankun 卸载不干净最常见的原因就是事件监听器和定时器没在 unmount 里清理,泄漏到下一个应用周期。
路由集成:基座与子应用谁说了算
路由是微前端的冲突高发区,先选一种模式并写死约定:
| 模式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 基座主导 | 基座持有顶级路由,按 activeRule 加载子应用 | URL 权威唯一,切换体验好 | 子应用内部跳转要通知基座,约定必须遵守 |
| 子应用自管 | 子应用自带完整路由,基座只监听 URL 分发 | 子应用独立运行/调试友好 | 顶级导航语义分散,需要防"路由打架" |
// 子应用(qiankun 形态):从 props 拿基座 base,路由可独立运行
export async function mount(props) {
const { container, base = '/orders/' } = props;
router = createRouter({
history: createWebHistory(base),
routes,
});
app = createApp(App).use(router);
app.mount(container.querySelector('#app'));
}
export async function unmount() {
app?.unmount();
app = null;
router = null;
// 路由守卫、事件监听、定时器一并清理
}
三条实测有效的军规:子应用的 history base 必须由基座注入而不是写死,保证独立开发(localhost:7001 裸跑)与集成运行两种形态一致;顶级 URL 变更只允许经过基座(子应用跳别的子应用要调基座的导航方法,而不是自己 history.pushState);每个子应用必须有 404 兜底页,基座匹配不到 activeRule 时也降级到统一 404,而不是白屏。
样式与 JS 隔离:手段越多,说明越痛
| 隔离手段 | 挡住什么 | 挡不住什么 |
|---|---|---|
| CSS Modules / 哈希类名 | 类选择器冲突 | 全局选择器(*、body 样式)、覆盖第三方组件库全局样式 |
| 命名前缀(BEM 域名段) | 同上 | 依赖人的自觉,跨团队难约束 |
| Shadow DOM(qiankun strictStyleIsolation) | 页面级样式彻底隔离 | 挂到 body 的弹窗(Modal/Select 下拉)逃逸出 shadow 树,样式全丢 |
| qiankun experimentalStyleIsolation | 运行时给子应用样式加属性前缀 | 动态插入 body 的元素、:root/body 选择器 |
| JS Proxy 沙箱 | 子应用污染全局 window | 直接操作 document、location 这类共享单例的副作用 |
Shadow DOM 那一行是重点坑:组件库的弹层(弹窗、下拉)习惯 append 到 document.body,在 Shadow DOM 隔离下它们跑出了子应用的样式作用域,样式直接失效。所以"隔离最强"的方案反而最常被关掉,多数团队最终落在"命名约定 + experimentalStyleIsolation + 全局样式清单"的组合上。
// Proxy 沙箱的极简原型:qiankun 的思路,理解它才能排查"全局变量串了"
function createProxySandbox() {
const sandbox = Object.create(null);
return new Proxy(sandbox, {
get(target, key) {
if (key in target) return target[key];
const value = (window as any)[key];
// 未被改写过的全局函数保持 this 指向真实 window,避免宿主 API 行为异常
return typeof value === 'function' && !value.prototype
? value.bind(window)
: value;
},
set(target, key, value) {
target[key] = value; // 子应用的写入只落在沙箱,不污染真实 window
return true;
},
});
}
沙箱的边界要有清醒认识:它隔离的是"全局变量污染",隔离不了"共享单例上的副作用"——子应用往 document 上挂监听、改 location、动 localStorage,沙箱都拦不住,只能靠规范与评审。
公共依赖治理:共享是契约,不是优化
| 策略 | 做法 | 适合 | 风险 |
|---|---|---|---|
| 完全不共享 | 每个子应用自带依赖 | 追求隔离与独立演进 | 包体成倍膨胀,内存里多个框架实例 |
| externals + 全局单例 | 依赖打到 CDN,全局变量共享 | 少量重型依赖(React、组件库) | 版本升级变成全站事件 |
| Module Federation shared | 构建期协商版本,singleton 运行 | 同构建链路的子应用群 | 版本区间要求严格对齐 |
| 私有 npm 源统一组件库 | 组件层共享而不是运行时共享 | 设计系统 | 组件库迭代节奏被多团队拉扯 |
原则一句话:共享运行时是硬契约(shared 单例版本必须区间对齐,升级要跨团队排期),共享组件库是软契约(各自锁各自版本,代价是功能上线时间差)。能用软契约解决的别用硬契约。
灰度与回滚:把"拆"带来的发布优势兑现
| 维度 | 做法 | 典型场景 |
|---|---|---|
| 按路由灰度 | 新子应用只挂 /new-orders 路径,旧应用仍服务旧路径 | 整体重构迁移 |
| 按用户灰度 | 按用户 ID 哈希分桶,基座动态决定加载新旧 entry | 大改版对照 |
| 按比例灰度 | Nginx/网关按百分比把 entry 流量切到新版本 | 常规发布 |
// 基座按哈希桶选 entry:子应用级灰度,基座自己不参与
const bucket = hash(userId) % 100;
registerMicroApps([
{
name: 'orders',
entry: bucket < 5 ? 'https://orders-v2.example.com' : 'https://orders.example.com',
container: '#subapp-viewport',
activeRule: '/orders',
},
]);
回滚的关键设计:entry 地址本身版本化(/subapp/orders/2.3.1/),回滚就是把灰度规则切回旧 entry,而不是"重新构建旧版本再部署"——后者的回滚时间和发布一样长,等于没有回滚。基座还需要一个错误边界:子应用加载失败(网络、构建产物 404)时降级到统一错误页并上报,不要让一个子应用的故障拖死整个壳。
成本清单:动手前先算账
| 成本项 | 具体表现 |
|---|---|
| 调试成本 | 断点要跨"基座 → 子应用 → 沙箱"三层,堆栈里全是生命周期包装 |
| 构建与 CI | 每个子应用独立管线,制品管理、entry 版本化、回滚机制都要新建 |
| 协议治理 | 事件契约、路由规则、公共依赖版本表,都需要文档和评审流程维持 |
| 团队认知 | 新人上手要先理解微前端机制,故障排查会前先花半小时分锅 |
| 体验一致性 | 跨子应用跳转的白屏、样式微差、埋点断流,都需要专项治理 |
这些成本是结构性的,不随团队熟练度消失。反过来,如果组织确实有 3+ 团队独立演进的诉求,这些成本换来的"独立部署、故障隔离、技术栈解耦"是真实的。
注意事项与边界
- 微前端不会让代码变好:单体的烂代码拆进子应用还是烂代码,还多了通信层。
- 不要用微前端当"拒绝重构"的借口:老 jQuery 页面原样装进子应用只是隔离了它,不是现代化了它。
- qiankun 卸载泄漏是第一大线上问题源:定时器、事件监听、全局 observer 必须在
unmount清理,写进子应用验收清单。 - 跨应用的状态同步没有"免费"方案:事件总线只是传输,一致性还得靠服务端与协议。
- 站在 SEO 的角度,客户端渲染的子应用内容对爬虫不友好,内容型页面(落地页、商品详情)尽量留在服务端渲染或基座内。
- preload/prefetch 策略直接影响首屏:全量 prefetch 在弱网下会挤占关键请求带宽,按优先级分级加载。
- 技术方案锁定后再谈细节:qiankun 与 Module Federation 的世界观不同(运行时加载 vs 构建期共享),混用两套心智模型维护成本最高。
总结
微前端的决策链路要按顺序走:先确认组织信号(多团队、独立节奏、异构栈),再选拆分边界(业务域 + 单向依赖),然后才是技术选型(iframe 隔离最强、qiankun 平衡、Module Federation 同构共享、Web Components 组件级复用)。落地时的四个硬点——通信契约、路由权威、隔离手段、公共依赖——每项都要"文档 + 评审 + 监控"三件套,而不是口头约定。最后把灰度与回滚设计进 entry 的版本化里,让"拆"真正兑现成发布敏捷。如果这些代价和你的组织现实对不上,单体 + 模块化 + 严格 code review 依然是好架构。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。