2021 年作为一个入行不久的新人,我将公司平台拆分成微前端体系。
2021: 困境
技术栈混杂
公司多个业务线并行开发,技术栈各不相同。有的团队用 Angular,有的用 Vue2。我们需要将这些业务线集成为统一的 App 完成交付,每次集成都要消耗的大量的研发与测试人力。
交付困境
客户购买的是特定业务模块,我们不能将合同外的源码一并交付。对于一个大型应用,交付时需要手动删除无关代码,这个过程既耗时又容易出错。
集成与交付,都需要大量人力成本。
为什么不选 iframe
iframe 是最简单的方案,但问题也很明显:
- 全量加载:每个 iframe 都是完整的页面加载,性能开销大
- 定位问题:Modal、Drawer 等组件的遮罩层无法覆盖父窗口
- 路由同步:浏览器前进后退按钮难以处理
- 通信复杂:postMessage 的协议治理成本高
为什么选择 Qiankun
坦诚地说,2021 年可选方案不多。我们最终用了 Qiankun,原因很实际:
社区活跃度
当时文档、issue 和案例都比较全,遇到问题能较快找到答案。对小团队来说,这比“理论最优”更重要。
JS 沙箱 + CSS 隔离
技术栈不统一时,样式冲突和全局变量污染最容易出问题。Qiankun 的沙箱和样式隔离正好覆盖这两个点。
PS: 当然,Shadow Dom 会带来另一些问题。
生命周期管理
bootstrap mount unmount 钩子比较清楚,子应用的加载和卸载路径也固定下来,维护成本更可控。
可控的渲染 API
我最看重的是这一点。Qiankun 提供了 loadMicroApp 和 registerMicroApps 两种渲染方式,我可以按业务状态决定子应用何时加载、何时卸载。
例如:对于一个存在 Tab 的管理系统,我可以控制所有 Tab 关闭后再进行子应用卸载,实现跨应用 KeepAlive:
function unmountApp(appName: string) {
const openTabs = getOpenTabsByApp(appName)
if (openTabs.length === 0)
apps[appName].unmountMicroApp(appName)
}
从 0 到 1:搭建微前端底座
架构设计
我们的部署架构大致如下:
- 容器化:使用 Docker Compose 管理容器编排
- K8s 部署:通过 Helm Charts 进行微服务版本管理,支持快速回滚
- 请求流程:
- 浏览器访问外层 Nginx
location /转发到主应用- 对应 entry 路径转发到各个子应用
运行时配置:一次构建,多次部署
多环境部署的挑战
随着业务发展,我们需要在不同客户环境部署同一套代码。如果在构建时硬编码环境配置,就需要为每个环境单独构建,这会带来几个问题:
- 构建产物不一致,增加测试风险
- CI/CD 流程复杂,构建时间成倍增长
- 难以快速切换环境进行问题排查
运行时配置方案
我用了一个直接的方案:通过 config.js 在运行时注入配置。
配置文件结构:
window.AppConfig = {
app: {
foo: '/foo',
bar: '/bar'
}
}
主应用和子应用都从 window.AppConfig 读取配置:
loadMicroApp({
name: appName,
entry: window.AppConfig.app[appName],
container: '#container',
})
部署流程
- 构建一次,生成统一的产物
- 通过容器挂载在不同环境注入对应配置
落地结果
- 同一份产物可以部署到所有环境,不需要重复构建
- 环境差异留在配置层,不侵入业务代码
- 切换环境时替换配置文件即可,无需重新构建
基础设施建设
早期的教训
初期经验不足,我犯了一个错误:让每个子应用自己处理路由注册、权限校验、国际化等逻辑。
这导致每次调整集成策略,都要多个业务模块一起改。比如动态路由策略一变,就得通知所有子应用同步配置,人力消耗很大。
解决方案:统一入口 NPM 包
我决定将登录、权限、路由等平台基础模块封装为 NPM 包,暴露 renderWithQiankun API,将方案迭代的复杂度消化在框架内部。
子应用只需提供简单的配置:
const options: RenderAppOptions = {
pages: import.meta.glob('./views/**/index.vue'),
i18n: import.meta.glob('./locales/*.json'),
svg: import.meta.glob('./assets/svg/**', {
eager: true,
query: '?raw',
}),
}
export interface AppContext {
app: App
router: Router
}
export declare function renderWithQiankun(options: RenderAppOptions): Promise<AppContext>
框架内部会自动处理:
- 根据
pages配置生成动态路由 - 注册国际化资源
- 批量导入 SVG 图标,注册为 iconify
- 集成权限校验
- 统一错误处理和监控埋点
落地结果
- 调整集成策略时,升级 NPM 包版本即可
- 业务成员不需要理解完整的集成细节
依赖共享的演进之路
团队技术栈逐渐统一到 Vue 后,我开始从平台层面收敛公有依赖,目标是减少整体加载体积。
External + Script 标签(2021)
最开始,我采用了最简单的方案:
实现方式
- 将共享依赖(Vue、Vue Router、Ant Design Vue)通过 webpack external 排除
- 在主应用通过
<script>标签统一引入 - 子应用从 window 对象获取这些依赖,或者从统一地址进行依赖加载,依托 http 缓存策略
遇到的问题
-
加载顺序约定:moment.js 必须在 UI 框架之前加载,否则日期选择器会报错。这种隐式依赖引入顺序很难维护。
-
依赖升级复杂:依赖主应用进行版本修改,子应用完全丧失了版本治理能力。
-
版本冲突难排查:当某个子应用依赖的版本与主应用不一致时,问题很难定位。造成了运行时行为不一致隐患。
Module Federation(2022)
我将 vue-cli 升级到 webpack5,引入 Module Federation。这是当时影响最大的架构决策之一。
依赖分层设计
我对依赖进行了分层设计:
| 分层 | 依赖示例 | 策略 | 原因 |
|---|---|---|---|
| 核心运行时 | Vue、Vue Router | singleton + 严格版本 | 全局单例,不允许多版本 |
| 核心 UI 框架 | Ant Design Vue | singleton | 保证 UI 一致性 |
| 工具库 | - | 不共享 | treeshaking 价值更高 |
| 业务 SDK | 内部 SDK | 不共享 | 变化快,共享收益低 |
| 超大依赖 | Monaco Editor | CDN + HTTP 缓存 | 减少构建体积 |
共享取舍
对于工具库,我会考虑三个指标来决定是否共享:
- 变化速度:是否频繁升级
- 多版本共存:是否允许不同版本
- Treeshaking 收益:按需引入能节省多少体积
如果符合 “变化快 + 允许多版本 + treeshaking 收益高” 的情境,我不会选择共享这个工具库。
Vite 时代(2024)
2024 年,团队从 Vue2 迁移到 Vue3。我在 Vite 中重新实现了一遍 Module Federation 配置。为了减少重复配置,我把能力封装成 vite-config,并在构建阶段限制可共享依赖的集合。
import { defineConfig } from '@platform-config/vite'
export default defineConfig(async () => {
return {
app: {
host: true,
name: 'foo',
shared: ['vue', 'vue-router', 'pinia', 'ant-design-vue'],
exposes: {
'./Bar': './src/components/Bar.vue',
}
}
}
})
动态 Remotes:运行时加载远程组件
在多环境部署场景下,远程模块的地址不能在构建时写死。我需要在运行时根据 window.AppConfig 动态解析远程模块地址,因此在 vite-config 中对 external 进行了统一处理:
remotes[appName] = {
external: `Promise.resolve(window.AppConfig.app['${app}']).then(url => url + 'assets/remoteEntry.js')`,
externalType: 'promise',
from: 'vite',
}
为此,我设计了一个简单的接口来抽象远程组件:
interface RemoteComponentConfig {
appName: string
component: string
}
使用时只需要提供应用名和组件路径:
<template>
<RemoteComponent :app="appName" :component="component" />
</template>
在项目里,这个抽象带来三个直接结果:
- 同一份代码可以在任何环境运行
- 参数由 TypeScript 接口约束,调用方在编译期能发现错误
- 业务侧只提供应用名和组件路径,不用关心 Module Federation 的实现细节
治理实践
版本冲突处理
不兼容模块
对于不兼容的模块,可以采用非单例模式,允许多版本共存。前提是这个模块对全局无副作用。
全局副作用模块
对于有全局副作用的模块(如 polyfill),必须严格单例:
不重复消费原则
从架构层面避免重复依赖。例如代码编辑器:
- 如果选择
Monaco Editor,就不要再引入CodeMirror - 超大依赖统一走
CDN+ HTTP 缓存策略
这么做先减少体积,也减少维护面。编辑器相关链路只维护一套,长期成本更低。
运行时污染防治
CSS 污染
共享组件必须采用 scoped 样式:
<style scoped>
.foo {
color: red;
}
</style>
JS 污染
制定代码规范,做好 Code Review:
- 禁止修改 window 对象
- 禁止修改 Array.prototype 等原生对象
- 在 unmount 钩子中销毁所有监听器
通信治理:架构即约束
核心原则
我的原则是:尽可能避免子应用直接通信。
通信是弱约束能力。全局事件总线一旦放开,很容易被当成“万能通道”,后续很难收敛。
实践策略
核心能力 NPM 包化
将权限、主题、路由等核心能力通过 NPM 包提供,事件同步在架构内部处理,子应用通过简单的引入即可拿到通信数据。
import { preferences } from '@platform-core/shared'
子应用依赖稳定的 API 而非广播事件。
跨应用跳转 API 化
对于跨应用跳转,我提供了统一的 API:
import { microApp } from '@platform-core/shared'
// Incorrect
eventBus.emit('navigate', {
app: 'foo',
path: '/bar',
query: {
name: 'octohash'
}
})
// Correct
microApp.router.push({
path: '/foo/bar',
data: {
name: 'octohash',
from: 'somewhere'
},
})
这个做法的结果:
- 通信逻辑收敛在架构内部
- TypeScript 提供编译时约束
- 错误处理和日志记录走统一链路
全局通信的适用场景
只有在弱耦合场景下才使用全局通信:
- 埋点上报
- Sentry 日志
eventBus.emit('analytics:track', {
event: 'button_click',
properties: { button_id: 'submit' },
})
2026: 重新思考
微前端的业务价值
回顾这几年的实践,我对“微前端值不值得”有个更具体的判断:它最有价值的地方是业务解耦。
独立演进与团队自治
我们团队后期将平台拆分为多个微前端应用后,各业务线可以独立演进。产品团队按自己的节奏迭代功能,不需要等待其他团队的发布窗口。跨团队排期和联调成本明显下降。
灵活的产品组合
对于 ToB 业务,客户购买的是特定模块组合。微前端让我们可以低成本拆分、组合业务模块做产品包装。这解决了交付问题,也让商务团队能按模块组合销售。
靶向更新与缓存友好
微前端的更新是靶向的。某个业务模块发布新版本时,只有该模块用户会重新加载资源,其他模块继续使用缓存。这让缓存策略更稳定,用户受影响的范围也更小。
从我们的项目看,微前端延续了微服务“按边界拆分”的思路,在交付和协作上有实际收益。
利与弊
优势
- 独立部署:各模块可以独立发布,互不影响
- 技术灵活性:允许不同技术栈共存
- 故障隔离:单个模块的问题不会影响整个平台
劣势
- 运维负担:部署的应用数量成倍增加
- 过度拆分的代价:如果拆分粒度过细,部署会变得非常麻烦
- 环境限制:在缺乏 Helm Charts 等容器编排工具的环境下,部署微前端应用是一件非常折磨的事情
方案选型
从现实角度看:
- qiankun 从
2023.11.15后就没再发布过新版本。 - micro-app 仍在持续演进,但是相对社区热度较小。
- module-federation 有了构建工具无关的选择。
当前 3 种方案各有利弊:
| 方案 | 适用场景 | 核心优势 | 主要劣势 | 维护状态 |
|---|---|---|---|---|
| Qiankun | 技术栈混杂 需要强隔离 | JS 沙箱成熟 生态完善 | 已停止维护 Vite 支持不佳 | ⚠️ 2023.11 后无发布 |
| Micro-App | 技术栈混杂 需要强隔离 | 具备沙箱机制 持续演进 | 社区相对较小 | ✅ 持续维护 |
| Module Federation | 技术栈统一 构建工具统一 | 原生依赖共享 类型安全 | 需要规范约束 无沙箱隔离 | ✅ 生态多样 |
总结
我现在的结论很朴素:架构设计是权衡题,要按实际业务需求和团队能力来选。
如果团队规模小、技术栈统一、没有独立部署需求,Monorepo + Turborepo 通常更简单。反过来,如果面临多团队协作、技术栈混杂、灵活交付,微前端通常更划算。
技术服务于业务,架构服务于组织。不要为了微前端而微前端。