拆了三年,终于在双十一前把微前端跑通了——一家电商的架构升级血泪史
我们为什么要折腾这件事
先交代一下背景。我们公司是一家做垂直电商的,主营服饰和美妆,日活大概五六十万的规模。在2022年之前,我们所有业务都塞在一个巨大的Vue2单页应用里。
这个单体应用是怎么长到这么庞大的?大概是这样的:
/src
/views
/home // 首页
/product // 商品详情页
/cart // 购物车
/order // 订单中心
/user // 用户中心
/promotion // 营销活动
/live // 直播电商
/coupon // 优惠券
/search // 搜索
/merchant // 商家后台(这个最离谱,后来证明是最该拆的)
/admin // 内部运营后台
...
加起来大概三十几个路由,三个核心团队成员维护,每个人都在改同一个router/index.js文件。冲突到了什么程度呢?我们团队的Git提交记录里,每周至少有3到5次因为合并冲突导致线上bug——两个组同时改了路由配置,合并的时候一个人胜了,另一个人的改动被覆盖,然后第二天早上用户就投诉某个页面打不开了。
真正的压死骆驼的最后一根稻草发生在2023年春天。我们要做一个直播电商的新业务,技术负责人选了React,而我们主站是Vue2。当时老板的意思是:新业务用新技术栈没问题,但要跟主站融为一个产品。于是我们做了一个”拼凑”的决定——用一个iframe把直播模块嵌进去,其他模块继续用Vue2。
效果惨不忍睹。
直播页面的导航栏跟主站完全割裂,用户从主站点进直播间,感觉像是跳到了另一个网站。更致命的是,用户状态不互通——在主站已经登录了,进直播间又要重新登录一次。客服每天接到几十个关于”为什么登录状态丢了”的投诉。
就是那个阶段,我们开始认真考虑微前端方案。
决定拆了:技术选型的过程
那段时间我们调研了不下七八种方案,简单过一下我们的思考过程:
方案一:iframe嵌套
这就是我们之前踩过的坑,直接Pass。用户体验太差,状态隔离严重,SEO也做不了。
方案二:Nginx按路径分发(纯后端方案)
就是让用户访问/home打到主站服务,访问/live打到直播服务。这个方案技术上最简单,但问题是——我们不想放弃SPA带来的用户体验优势。用户切换页面时不需要整页刷新,这个体验一旦失去,用户会明显感知到产品”变卡了”。
方案三:Web Components封装
每个子应用用Web Components包一层,主应用负责路由和调度。这个方案理论上很优雅,但当时的浏览器兼容性、样式隔离的彻底性、以及我们团队对Web Components的熟悉程度,都不太适合。
方案四:qiankun(蚂蚁金服的微前端框架)
最终选了qiankun。理由很实际:1)社区活跃,文档相对完善;2)基于single-spa,生态好;3)对Vue2/React都支持;4)团队之前有项目用过,有现成经验可以借鉴。
决定之后,我们花了两周时间做了技术预研,把几个关键问题验证了一遍,然后才正式立项。
第一个大坑:路由冲突
问题是怎么暴露的
微前端架构下,每个子应用都有自己的路由。我们的拆分方案是:
| 子应用 | 路径前缀 | 技术栈 |
|---|---|---|
| 主站应用 | / | Vue2 + Vue Router |
| 直播应用 | /live | React + React Router |
| 商家应用 | /merchant | Vue3 + Vue Router |
| 运营后台 | /admin | Vue3 + Vue Router |
问题出现在我们接入直播应用之后。
直播应用内部的路由是这样的:
// 直播应用的路由配置
const routes = [
{ path: '/', component: LiveHome },
{ path: '/room', component: LiveRoom },
{ path: '/record', component: LiveRecord },
]
主应用的路由配置里,有一个首页路由:
// 主应用的路由配置
const routes = [
{ path: '/', component: Home },
{ path: '/product/:id', component: ProductDetail },
{ path: '/cart', component: Cart },
{ path: '/live', component: LazyLive }, // 懒加载的直播入口
{ path: '/live/room/:roomId', component: LiveRoomProxy },
]
表面上看没问题,主应用在/live路径下放了一个占位组件LiveRoomProxy,用来承载微前端的子应用。但我们部署后发现,当用户直接访问/live/room/123时,直播应用的路由器试图匹配/room,但主应用的路由器先截获了这个请求,导致直播房间页面直接白屏。
这就是典型的路由冲突——子应用的路由层级被父应用的通配符路由覆盖掉了。
怎么解决的
我们在主应用的路由配置里加了一个”黑洞”路由来吞掉所有不属于主应用的路径:
// 主应用路由配置
const routes = [
{
path: '/',
component: Home,
children: [
{ path: '', component: Home },
{ path: 'product/:id', component: ProductDetail },
{ path: 'cart', component: Cart },
{ path: 'user', component: UserCenter },
]
},
{
// 黑洞路由:匹配所有未定义的子路径
// 微前端框架会捕获这些路径并分发给对应的子应用
path: '/:pathMatch(.*)*',
component: () => import('@/views/MicroAppContainer.vue')
}
]
但更重要的是,子应用必须把路径前缀配置对。直播应用注册到qiankun的时候,要明确告诉框架自己的base path:
// 直播应用的注册配置
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'live-app',
entry: '//localhost:9001',
container: '#live-container',
activeRule: '/live', // ← 关键:只有以/live开头的路径才激活这个子应用
props: {
basePath: '/live'
}
},
{
name: 'merchant-app',
entry: '//localhost:9002',
container: '#merchant-container',
activeRule: '/merchant',
props: {
basePath: '/merchant'
}
},
{
name: 'admin-app',
entry: '//localhost:9003',
container: '#admin-container',
activeRule: '/admin',
props: {
basePath: '/admin'
}
}
])
start()
而直播应用内部的React Router也需要配置basename,确保路由路径是相对于/live的:
// 直播应用的App.js
import { BrowserRouter, Routes, Route } from 'react-router-dom'
function App() {
return (
// basename设为子应用的激活路径
<BrowserRouter basename="/live">
<Routes>
<Route path="/" element={<LiveHome />} />
<Route path="/room/:roomId" element={<LiveRoom />} />
<Route path="/record" element={<LiveRecord />} />
</Routes>
</BrowserRouter>
)
}
这里有一个细节很多人会踩:basename和activeRule必须保持一致。如果activeRule是/live,那子应用内部的basename也必须设为/live。否则子应用自己路由跳转的时候,路径会不对。
比如用户在直播房间内点击一个链接跳转到/live/record,如果basename没设对,浏览器实际请求的是/record,就被主应用截获了,直播应用直接404。
第二个大坑:样式隔离
样式污染有多严重
样式隔离这个问题,我们是上线一周后才发现的。
那天一个运营同学反馈,直播间的样式有点奇怪——背景色变白了,按钮的间距不对劲。我们排查后发现,是主应用的CSS reset样式穿透到了直播应用。
具体来说,主应用有一个全局样式文件:
/* 主应用的 reset.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'PingFang SC', sans-serif;
background-color: #f5f5f5;
color: #333;
}
button {
border: none;
cursor: pointer;
/* 这里有个全局的按钮样式,直播应用的按钮也被影响了 */
}
而直播应用自己的样式:
/* 直播应用的样式 */
.live-room {
background-color: #1a1a1a; /* 深色背景,直播场景需要 */
}
.live-button {
background-color: #ff4d4f;
padding: 8px 16px;
}
问题在于,qiankun虽然提供了样式隔离机制,但我们当时的配置漏掉了一个关键点。
qiankun的样式隔离机制
qiankun默认使用CSS Scoped + Shadow DOM来隔离样式。具体来说:
- Snapshot机制:在子应用加载前,qiankun会记录当前页面的CSSOM(CSS对象模型)状态,子应用卸载后恢复到那个状态。
- CSS Scoped:通过JS注入样式时,自动给选择器加上属性选择器,如
.live-button变成.live-button[qiankun-hash]。 - Shadow DOM(可选):把子应用的DOM包裹在Shadow DOM里,实现彻底的样式隔离。
我们当时的配置:
// 主应用的main.js
import { registerMicroApps, start, initGlobalState } from 'qiankun'
// 默认开启了样式隔离
start({
singular: false,
sandbox: {
// 开启CSS隔离
css: {
singular: false,
// 这里可以选strictStyleIsolation或experimentalStyleIsolation
strictStyleIsolation: false,
}
}
})
看起来没问题,但实际效果不理想。原因是我们主应用的样式是写在.css文件里然后通过webpack打包注入到<head>的,这种全局样式文件不受qiankun的sandbox机制保护。qiankun的样式隔离主要作用于通过JS动态注入的样式,对预打包的全局CSS文件效果有限。
我们的解决方案
第一层:子应用自身做样式隔离
每个子应用的组件样式都改成scoped:
<!-- 直播应用的LiveRoom.vue -->
<template>
<div class="live-room">
<!-- 内容 -->
</div>
</template>
<style scoped>
.live-room {
background-color: #1a1a1a;
}
.live-button {
background-color: #ff4d4f;
padding: 8px 16px;
}
</style>
// 直播应用的LiveRoom.jsx - 用CSS Modules
import styles from './LiveRoom.module.css'
function LiveRoom() {
return (
<div className={styles.liveRoom}>
<button className={styles.liveButton}>开始直播</button>
</div>
)
}
第二层:主应用克制使用全局样式
主应用的全局样式尽量只放真正需要全局的:
/* reset.css - 保持最小化 */
*, *::before, *::after {
box-sizing: border-box;
}
html {
font-size: 16px;
line-height: 1.5;
}
body {
margin: 0;
}
把组件级别的颜色、间距、字体等样式都收归到组件内部。
第三层:用CSS变量统一管理主题
这个是我们后来加的一个优化。因为主应用和子应用偶尔会有需要共享的样式变量(比如主题色),我们用CSS自定义属性(CSS Variables)来统一管理:
/* 主应用的主题变量 */
:root {
--primary-color: #ff6b35;
--secondary-color: #004e89;
--text-color: #333333;
--bg-color: #ffffff;
--spacing-unit: 8px;
}
/* 子应用也可以引用 */
.live-button {
background-color: var(--primary-color);
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
}
这样既保持了样式的统一管理,又避免了硬编码的颜色值到处飞。
第四层:极端情况用Shadow DOM
直播应用有一次实在搞不定样式冲突——因为他们用了一个第三方的直播播放器组件,这个组件自带了一大堆内联样式和全局样式,怎么scoped都挡不住。
最后我们给直播应用开了Shadow DOM隔离:
// 主应用配置
registerMicroApps([
{
name: 'live-app',
entry: '//localhost:9001',
container: '#live-container',
activeRule: '/live',
// 开启严格的样式隔离(使用Shadow DOM)
sandbox: {
strictStyleIsolation: true // 注意:这是子应用级别的配置
}
}
])
开启strictStyleIsolation后,qiankun会给子应用的DOM包裹一层Shadow DOM,样式彻底隔离。代价是子应用的一些全局UI效果(比如tooltip、dropdown的popper)需要特殊处理,因为它们会渲染到body下而不是Shadow DOM内。不过对于直播应用来说,这些问题都不大。
第三个大坑:状态共享
状态共享有多必要
微前端拆完之后,我们发现有几个状态是跨应用共享的:
- 用户登录状态:用户在主站登录了,进直播间、进商家后台都应该保持登录,不能每个子应用都弹一次登录框。
- 购物车数据:用户在主站加了购物车,在直播间的”立即购买”应该能读到购物车里的商品。
- 搜索历史:用户在主站搜过什么,在子应用里也应该有记录。
- 全局配置:比如平台的活动配置、功能开关等。
如果每个子应用都自己维护一份用户状态,不仅体验差,数据还容易不一致。
用globalState管理共享状态
qiankun提供了一个initGlobalStateAPI来做跨应用的状态管理:
// 主应用的state.js - 创建全局状态
import { initGlobalState } from 'qiankun'
const initialState = {
userInfo: null, // 当前登录用户
token: null, // 登录token
cart: [], // 购物车
searchHistory: [], // 搜索历史
globalConfig: {} // 全局配置
}
const actions = initGlobalState(initialState)
export default actions
然后各个子应用注册的时候接入:
// 直播应用的main.js
import { setupStore } from '@/store'
import actions from 'path/to/main-app/state' // 引入主应用的全局状态
// 启动前先同步状态
actions.setGlobalState({
userInfo: localStorage.getItem('userInfo'),
token: localStorage.getItem('token'),
cart: JSON.parse(localStorage.getItem('cart') || '[]')
})
// 监听状态变化
actions.on('change', (state) => {
console.log('全局状态变更:', state)
// 更新本地状态
setupStore(state)
})
export default actions
实战中的坑
坑一:状态同步的时序问题
我们第一次上线的时候,遇到了一个很诡异的问题:用户在主站登录了,点进直播间,直播间的用户信息显示还是未登录。但刷新一下直播间页面就好了。
排查后发现是时序问题。子应用启动时,主应用的全局状态还没初始化完,子应用读到的userInfo是null。
解决方案是在主应用的mount钩子里确保状态先同步:
// 主应用 - 在bootstrap阶段就初始化好状态
export async function bootstrap() {
// 从存储中恢复状态
const savedState = {
userInfo: await getUserInfo(),
token: await getToken(),
cart: await getCart()
}
actions.setGlobalState(savedState)
}
// 子应用 - 在mount阶段同步状态
export async function mount(props) {
const { initialState } = props
if (initialState) {
actions.setGlobalState(initialState)
}
actions.on('change', (newState) => {
updateLocalState(newState)
})
}
坑二:状态过大影响性能
有一次我们往globalState里塞了一个很大的配置对象,包含了几百个功能开关。结果发现每次状态变化,所有子应用都会收到通知并触发重新渲染,页面卡得不得了。
解决方案是对状态做合理的拆分:
// 把大状态拆成多个小的globalState
const userState = initGlobalState({ userInfo: null, token: null })
const cartState = initGlobalState({ cart: [] })
const configState = initGlobalState({ featureFlags: {} })
// 每个子应用只订阅自己关心的状态
// 直播应用只关心用户状态和购物车
userState.on('change', (state) => { ... })
cartState.on('change', (state) => { ... })
// 主应用关心所有状态
另外,对于不需要实时同步的状态(比如全局配置),可以用localStorage或sessionStorage来持久化,子应用直接读存储而不是走globalState。
坑三:跨子应用调用API
直播应用需要获取购物车数据,但它不应该直接调用主站的API——因为每个子应用有自己的请求拦截器和token管理。我们最终的做法是在主应用暴露一个API接口给子应用调用:
// 主应用暴露API
window.__MALL_API__ = {
getUserInfo: () => fetch('/api/user/info'),
getCart: () => fetch('/api/cart'),
search: (keyword) => fetch(`/api/search?q=${keyword}`),
}
// 直播应用调用
async function getCart() {
const data = await window.__MALL_API__.getCart()
return data
}
这样主应用可以统一管理API请求的鉴权、拦截、错误处理,子应用只需要关心业务逻辑。
部署和构建的问题
拆微前端之后,构建和部署也变成了一场噩梦。
构建依赖问题
之前所有代码在一个仓库里,一个npm run build全部搞定。拆完之后,每个子应用有自己独立的仓库,构建流程完全不同。
我们的解决方案是维护一个monorepo的构建配置:
// package.json - 根目录
{
"name": "mall-micro-frontend",
"private": true,
"workspaces": [
"apps/main-app",
"apps/live-app",
"apps/merchant-app",
"apps/admin-app"
],
"scripts": {
"dev": "concurrently \"npm run dev:main\" \"npm run dev:live\" \"npm run dev:merchant\"",
"dev:main": "npm run dev --workspace=apps/main-app",
"dev:live": "npm run dev --workspace=apps/live-app",
"build": "npm run build --workspaces",
"build:main": "npm run build --workspace=apps/main-app",
"build:live": "npm run build --workspace=apps/live-app"
}
}
这样团队成员在一个仓库里就能开发所有应用,但每个应用又保持独立的部署能力。
CI/CD的问题
我们之前是一个提交触发一次全量构建。拆微前端之后,应该按应用粒度构建——只构建有代码变化的应用。
# .gitlab-ci.yml
stages:
- build
- test
- deploy
# 根据commit路径判断构建哪个应用
build:main-app:
stage: build
only:
changes:
- apps/main-app/**/*
script:
- npm run build:main
artifacts:
paths:
- apps/main-app/dist/
build:live-app:
stage: build
only:
changes:
- apps/live-app/**/*
script:
- npm run build:live
artifacts:
paths:
- apps/live-app/dist/
这样改直播应用的代码,只构建直播应用,不影响主应用的构建流程。
性能问题
微前端上线后,我们监控发现首屏加载时间从1.2秒变成了2.8秒。主要原因是子应用需要额外加载JavaScript bundle。
解决方案一:预加载
// 主应用 - 在用户可能访问的子应用路径时提前加载
const prefetchApps = () => {
// 用户hover在"直播间"菜单项时预加载
const liveLink = document.querySelector('[data-nav="live"]')
if (liveLink) {
liveLink.addEventListener('mouseenter', () => {
preloadApp('live-app')
})
}
}
async function preloadApp(appName) {
const app = microApps.find(a => a.name === appName)
if (app && !app.loading) {
app.loading = true
await import(/* webpackPrefetch: true */ `//${app.entry}/remoteEntry.js`)
app.loading = false
}
}
解决方案二:懒加载
直播应用和商家应用不是每个用户都会访问,我们做了懒加载:
// 主应用的路由
const routes = [
{
path: '/live',
component: () => import('@/components/MicroAppShell.vue'),
meta: {
appName: 'live-app',
lazy: true // 标记为懒加载
}
}
]
// MicroAppShell.vue - 懒加载子应用
<script setup>
import { onMounted } from 'vue'
import { loadApp } from 'qiankun'
const props = defineProps({
appName: String
})
onMounted(async () => {
// 只有组件挂载时才加载子应用
await loadApp(props.appName)
})
</script>
解决方案三:代码分割优化
我们重新梳理了各应用的代码依赖,把公共代码抽出来:
// webpack配置 - 公共代码分离
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
// 提取React公共包
react: {
name: 'chunk-react',
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
priority: 10,
chunks: 'initial'
},
// 提取Vue公共包
vue: {
name: 'chunk-vue',
test: /[\\/]node_modules[\\/](vue|vuex|vue-router)[\\/]/,
priority: 10,
chunks: 'initial'
},
// 提取qiankun运行时
qiankun: {
name: 'chunk-qiankun',
test: /[\\/]node_modules[\\/](qiankun|single-spa)[\\/]/,
priority: 5,
chunks: 'initial'
}
}
}
}
}
优化后首屏加载时间从2.8秒降到了1.9秒,基本回到了拆之前的水平。
团队协作的变化
除了技术问题,微前端改造对我们的团队协作方式也产生了很大影响。
以前:一个仓库,所有人改同一个代码库,提交前需要code review整个PR。
现在:四个应用四个仓库,每个团队有自己的迭代节奏。主站团队和直播团队可以同时开发,互不影响。
但这带来了一个新问题:版本兼容性。
主应用升级了qiankun的版本,子应用还是旧版本,可能会导致运行时错误。我们的解决方案是制定一个兼容性矩阵:
| 主应用版本 | 直播应用版本 | 商家应用版本 | 兼容性 |
|-----------|------------|------------|--------|
| 2.0.0 | >=1.2.0 | >=1.0.0 | ✓ |
| 2.1.0 | >=1.3.0 | >=1.1.0 | ✓ |
| 2.2.0 | >=1.3.0 | >=1.2.0 | ✓ |
每个子应用在package.json里声明对主应用版本的依赖范围,CI流程里检查版本兼容性。
如果重来一次,我们会怎么做
回头看这大半年的改造,有几个地方如果当初想清楚一点,能少走很多弯路:
1. 路由设计应该从一开始就统一规划
我们当初是先拆了子应用,再回来补路由配置,导致后面改了很多次。正确的做法应该是先定义好所有应用的activeRule和内部路由结构,再开始开发。
2. 样式隔离应该用BEM命名规范配合CSS Scoped,而不是依赖qiankun的沙箱
qiankun的样式隔离是最后一道防线,不应该作为主要的隔离手段。每个子应用内部的组件样式应该做到完全独立,这样即使沙箱失效也不会互相污染。
3. 状态共享要克制
不是所有状态都需要跨应用共享。我们最初把太多东西放进了globalState,后来发现维护成本很高。应该只共享真正需要跨应用的数据,其他的各自维护。
4. 监控和日志要提前规划
微前端架构下,错误可能发生在任何一个子应用里。我们花了很长时间才把错误监控串联起来——主应用和子应用都要上报错误,然后在统一的平台(我们用的是Sentry)里按appName做分类。
// 每个子应用启动时上报自己的信息
Sentry.init({
dsn: 'your-dsn',
initialScope: {
tags: {
appName: 'live-app',
appVersion: '1.3.0'
}
}
})
最后说几句
这次微前端改造我们花了大概六个月时间(包括预研和试错),目前已经在生产环境稳定运行了四个月。双十一期间峰值QPS大概是平时的三倍,没有出过问题。
最大的感受是:微前端不是银弹。它解决了单体应用膨胀的问题,但也引入了新的复杂度。如果你们的团队规模不大(三五个人),业务也没有快速迭代多个独立团队的需求,单体SPA可能反而是更简单的选择。
但如果你们和我们一样——多个业务线需要独立迭代、技术栈不统一、团队规模在增长——那微前端是值得投入的。关键是前期把架构设计想清楚,尤其是路由和样式隔离这两个最容易踩坑的地方。
祝大家好运。
