Wujie 微前端架构下的跨应用状态管理实践:Props + Bus 双通道方案

在基于 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 只传了 tokenuserInfo,但子应用还需要 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   # 事件常量

看起来很"工程化",但实际落地后发现几个问题:

  1. 增加了复杂度却没带来多少收益。系统级数据本就由主应用维护,子应用只需要只读消费,多一个包多一层抽象,反而增加了理解成本和维护负担。
  2. Provider 的 watch 机制失效。我们设计了 createSharedStoreProvider(bus, source) 来监听 Store 变化,但 source.auth() 每次返回新的普通对象,Vue 的 watch 根本追踪不到响应式变化——整个广播机制是失效的。
  3. 子应用被迫依赖这个包。本来子应用只需要读 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 1wujie props冷启动初始快照同步、可靠、子应用启动即可用
Channel 2wujie 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:LOGOUTvoid用户登出

两者互补: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.tslogout() 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 层(useSharedAuthuseSharedDictuseSharedLocale),每个都有 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 的生产实践,如有问题欢迎交流。