在基于 Wujie 的微前端架构中,主应用与子应用之间的状态同步是一个绑不开的难题。本文分享我们在生产项目中的实践:如何用最简洁的方式,实现系统级数据的单一可信源和实时同步,同时避免过度设计的陷阱。
背景
我们的项目是一个典型的企业级 B 端系统,采用 Vue 3 + Pinia + Wujie 的微前端架构:
- 1 个主应用:负责登录、菜单、布局、系统级数据管理
- 6+ 个子应用:各自独立开发部署,通过 Wujie 嵌入主应用运行
技术栈:Vue 3.5 / Pinia 3 / Vite 7 / TypeScript 5 / Wujie 1.0
问题:6 个子应用,6 份重复数据
随着子应用数量增长,我们遇到了一系列状态管理问题:
1. 字典数据 N+1 次请求
主应用启动时请求一次全量字典,但每个子应用启动后又各自请求一次。6 个子应用 = 7 次相同的接口调用。
2. 语言切换不同步
用户在主应用切换语言后,已加载的子应用仍然显示旧语言,必须刷新页面才能生效。
3. Token 刷新断裂
主应用静默刷新 Token 后,子应用持有的旧 Token 继续发请求,触发 401 错误。
4. 用户信息传递不完整
主应用通过 Wujie props 只传了 token 和 userInfo,但子应用还需要 permissions(权限列表)和 roles(角色树),只能自己再请求一次。
5. 数据源不唯一
同一份用户数据,在主应用的 Pinia Store 里有一份,在每个子应用的 Pinia Store 里又各有一份。数据不一致的风险始终存在。
归结为一句话:缺少一个统一的跨应用状态分发机制。
方案选型:我们踩过的坑
尝试一:封装 npm 包(过度设计)
最初我们的方案是创建一个 @cmclink/shared-stores 基础包:
packages/shared-stores/
├── src/
│ ├── auth.ts # useSharedAuth() composable
│ ├── dict.ts # useSharedDict() composable
│ ├── locale.ts # useSharedLocale() composable
│ ├── provider.ts # createSharedStoreProvider()
│ ├── utils.ts # isInWujie() / getWujieBus()
│ ├── types.ts # 12 个类型定义
│ └── constants.ts # 事件常量
看起来很"工程化",但实际落地后发现几个问题:
- 增加了复杂度却没带来多少收益。系统级数据本就由主应用维护,子应用只需要只读消费,多一个包多一层抽象,反而增加了理解成本和维护负担。
- Provider 的 watch 机制失效。我们设计了
createSharedStoreProvider(bus, source)来监听 Store 变化,但source.auth()每次返回新的普通对象,Vue 的watch根本追踪不到响应式变化——整个广播机制是失效的。 - 子应用被迫依赖这个包。本来子应用只需要读 props 和监听 bus,现在还要安装一个额外的 npm 包。
最终方案:回归简洁
反思后我们确立了核心原则:
系统级数据由主应用独占维护,通过 Wujie 原生机制(props + bus)只读分发给子应用。不引入额外的包,不搞抽象层。
架构设计
Store 三层分类
┌─────────────────────────────────────────────────┐
│ Layer 0 — 系统级(主应用独占维护,子应用只读) │
│ userStore / dictStore / localeStore │
├─────────────────────────────────────────────────┤
│ Layer 1 — 应用级(主应用独有) │
│ tabsStore / messageStore / historyStore │
├─────────────────────────────────────────────────┤
│ Layer 2 — 业务级(各子应用独有) │
│ orderStore / routeStore / blStore ... │
└─────────────────────────────────────────────────┘
关键区分:Layer 0 的数据需要跨应用共享,Layer 1 和 Layer 2 不需要。只对 Layer 0 做状态分发,保持最小化。
双通道通信协议
利用 Wujie 自带的两个通信机制:
| 通道 | 机制 | 用途 | 特点 |
|---|---|---|---|
| Channel 1 | wujie props | 冷启动初始快照 | 同步、可靠、子应用启动即可用 |
| Channel 2 | wujie bus | 运行时增量同步 | 异步、实时、事件驱动 |
props 结构:
{
$shared: {
auth: { token, refreshToken, userId, permissions, roles, userInfo },
dict: { dictMap },
locale: { lang }
},
// 向后兼容旧字段
token: '...',
userInfo: { ... }
}
bus 事件:
| 事件 | 载荷 | 触发时机 |
|---|---|---|
SHARED:AUTH_UPDATED | { token, permissions, roles, userInfo } | 权限/角色/用户信息变更 |
SHARED:TOKEN_REFRESHED | { token, refreshToken } | Token 静默刷新后 |
SHARED:DICT_UPDATED | { dictMap, version } | 字典数据加载完成 |
SHARED:LOCALE_CHANGED | { lang } | 语言切换 |
SHARED:LOGOUT | void | 用户登出 |
两者互补:props 解决冷启动,bus 解决热更新。
核心实现
整个方案的核心就一个文件:主应用的 shared-provider.ts。
主应用侧:Provider
// apps/main/src/stores/shared-provider.ts
import { watch } from 'vue'
import { bus } from 'wujie'
// 事件常量就地定义,不引入额外包
const SHARED_EVENTS = {
AUTH_UPDATED: 'SHARED:AUTH_UPDATED',
TOKEN_REFRESHED: 'SHARED:TOKEN_REFRESHED',
DICT_UPDATED: 'SHARED:DICT_UPDATED',
LOCALE_CHANGED: 'SHARED:LOCALE_CHANGED',
LOGOUT: 'SHARED:LOGOUT',
} as const
export function setupSharedStoreProvider(): void {
const userStore = useUserStoreWithOut()
const dictStore = useDictStoreWithOut()
const localeStore = useLocaleStoreWithOut()
// 直接 watch Pinia store 的响应式属性
watch(
() => ({
permissions: userStore.permissions,
roles: userStore.roles,
roleId: userStore.roleId,
userInfo: userStore.user,
}),
(newVal) => {
bus.$emit(SHARED_EVENTS.AUTH_UPDATED, {
token: getAccessToken(),
...newVal,
})
},
{ deep: true },
)
// 字典加载完成后广播
watch(
() => dictStore.isSetDict,
(isSet) => {
if (isSet) {
bus.$emit(SHARED_EVENTS.DICT_UPDATED, {
dictMap: dictMapToRecord(dictStore.dictMap),
version: Date.now(),
})
}
},
)
// 语言切换
watch(
() => localeStore.currentLocale.lang,
(lang) => bus.$emit(SHARED_EVENTS.LOCALE_CHANGED, { lang }),
)
// $subscribe 兜底检测登出
userStore.$subscribe(() => {
if (!userStore.isSetUser && userStore.permissions.length === 0) {
bus.$emit(SHARED_EVENTS.LOGOUT)
}
})
}
关键细节:直接 watch Pinia store 的响应式属性,而不是通过 getter 函数间接访问。这是我们踩过的坑——如果 watch(() => source.auth().token, ...) 中的 source.auth() 每次返回新对象,Vue 的响应式追踪会完全失效。
非响应式数据的处理
Token 存储在 sessionStorage(通过 wsCache),不是 Pinia 的响应式状态,无法用 watch 监听。我们的做法是在写入点主动广播:
// apps/main/src/utils/auth.ts
export const setToken = (token: TokenType) => {
wsCache.set(CACHE_KEY.REFRESH_TOKEN, token.refreshToken)
wsCache.set(CACHE_KEY.ACCESS_TOKEN, token.accessToken)
// Token 写入后主动广播(动态 import 避免循环依赖)
import('@/stores/shared-provider').then(({ emitTokenRefreshed }) => {
emitTokenRefreshed()
})
}
Logout 同理,在 user.ts 的 logout() action 中主动调用:
async logout() {
await logout()
removeToken()
emitSharedLogout() // 主动广播,确保子应用收到
this.resetState()
}
初始快照注入
主应用的 App.vue 通过 computed 构建 props,每次 Store 变化自动更新:
<WujieVue
v-for="app in loadedApps"
:key="app.name"
:props="sharedProps"
:alive="true"
/>
<script setup>
const sharedProps = computed(() => ({
$shared: {
auth: { token, permissions, roles, userInfo, ... },
dict: { dictMap },
locale: { lang },
},
// 向后兼容旧子应用
token: getAccessToken(),
userInfo: userStore.user,
}))
</script>
子应用侧:只读消费
子应用不需要安装任何额外依赖,直接用 Wujie 原生 API:
// 冷启动:从 props 获取初始数据
const wujie = (window as any).__WUJIE
const shared = wujie?.props?.$shared
if (shared) {
// 微前端环境:使用主应用的数据
authStore.setToken(shared.auth.token)
authStore.setPermissions(shared.auth.permissions)
dictStore.setDictMap(shared.dict.dictMap)
i18n.global.locale.value = shared.locale.lang
} else {
// 独立运行:走本地 API
await authStore.fetchUserInfo()
await dictStore.fetchDictData()
}
// 热更新:监听 bus 事件
wujie?.bus?.$on('SHARED:TOKEN_REFRESHED', (data) => {
authStore.setToken(data.token)
})
wujie?.bus?.$on('SHARED:LOCALE_CHANGED', (data) => {
i18n.global.locale.value = data.lang
})
wujie?.bus?.$on('SHARED:LOGOUT', () => {
authStore.clearLocal()
router.push('/login')
})
通过 __WUJIE 是否存在来判断运行环境,微前端环境走 props/bus,独立运行走本地 API,子应用始终保持独立可运行。
数据流全景
┌──────────────────────────────────────────────────────────┐
│ 主应用 │
│ │
│ API 请求 → userStore / dictStore / localeStore │
│ │ │
│ shared-provider.ts │
│ (watch 响应式属性 → bus.$emit) │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ wujie props wujie bus tabsStore 等 │
│ ($shared) (SHARED:*) │
│ │ │ │
└────────────┼───────────┼──────────────────────────────────┘
│ │
┌─────────┼───────────┼─────────┐
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ doc │ │ ibs │ │ mkt │ │ ... │
│ │ │ │ │ │ │ │
│ 只读 │ │ 只读 │ │ 只读 │ │ 只读 │
│ 消费 │ │ 消费 │ │ 消费 │ │ 消费 │
└──────┘ └──────┘ └──────┘ └──────┘
踩坑记录
坑 1:watch getter 返回新对象导致响应式失效
// ❌ 错误:每次调用 source.auth() 返回新对象,watch 追踪不到变化
watch(() => source.auth().token, (token) => { ... })
// ✅ 正确:直接 watch Pinia store 的响应式属性
watch(() => userStore.permissions, (permissions) => { ... })
这是 Vue 响应式系统的基本原理,但在抽象层过多时很容易忽略。getter 函数如果每次返回新的普通对象,Vue 无法建立依赖追踪。
坑 2:dictStore.dictMap 是 Map 不是 Object
Pinia store 中字典数据用 Map<string, any> 存储,但跨 iframe context 传输时 Map 无法正确序列化。需要转换为普通 Record:
function dictMapToRecord(dictMap: any): Record<string, any[]> {
const result: Record<string, any[]> = {}
if (dictMap instanceof Map) {
dictMap.forEach((value, key) => { result[key] = value })
} else {
Object.assign(result, dictMap)
}
return result
}
坑 3:Token 存在 sessionStorage 中,不是响应式的
Token 通过 wsCache(封装的 web-storage-cache)存储在 sessionStorage 中,不在 Pinia state 里,watch 监听不到。
解决方案:在写入点主动广播,而不是试图监听存储变化。用动态 import() 避免循环依赖。
坑 4:过度抽象的代价
最初我们设计了完整的 composable 层(useSharedAuth、useSharedDict、useSharedLocale),每个都有 fallback 机制、readonly 包装、onScopeDispose 清理。
看起来很优雅,但实际上:
- 子应用只需要读 props + 监听 bus,10 行代码的事
- 多了一个 npm 包依赖,子应用的 package.json 要加,CI 要装
- composable 内部的 wujie 环境检测逻辑和子应用自己写没区别
- 维护成本远大于收益
最终我们删掉了整个包,回归最简方案。
设计原则总结
| 原则 | 说明 |
|---|---|
| 主应用独占维护 | 系统级数据只在主应用写入,子应用只读 |
| 不过度设计 | 不搞 composable 抽象层、不搞额外 npm 包 |
| 用平台能力 | Wujie 自带 props + bus,够用就不造轮子 |
| 在写入点广播 | 非响应式数据(Token)在写入时主动 emit |
| 向后兼容 | 新增 $shared 字段,保留旧的 token/userInfo |
| 独立可运行 | 子应用通过 __WUJIE__ 环境检测,非微前端环境走本地 API |
效果
- 接口调用:字典请求从 N+1 次降为 1 次
- 语言切换:实时同步,无需刷新
- Token 刷新:主应用刷新后 50ms 内所有子应用同步
- 代码量:主应用新增 1 个文件(~180 行),子应用各减少 3 个冗余 Store
- 依赖:零新增 npm 包
适用场景
这个方案适用于:
- 基于 Wujie(或类似 iframe 沙箱方案)的微前端架构
- 主应用是唯一的系统级数据管理者
- 子应用数量 > 2,且共享用户/权限/字典/语言等全局数据
- 团队希望保持架构简洁,避免过度工程化
不适用于:
- 子应用之间需要双向通信的场景(本方案是单向只读分发)
- 子应用需要修改系统级数据的场景(应该通过 bus 事件请求主应用修改)
本文基于 Wujie 1.0 + Vue 3.5 + Pinia 3 的生产实践,如有问题欢迎交流。