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 只传了 token 和 userInfo,但子应用还需要 permissions(权限列表)和 roles(角色树),只能自己再请求一次。

5. 数据源不唯一

同一份用户数据,在主应用的 Pinia Store 里有一份,在每个子应用的 Pinia Store 里又各有一份。数据不一致的风险始终存在。

归结为一句话:缺少一个统一的跨应用状态分发机制。

方案选型:我们踩过的坑

尝试一:封装 npm 包(过度设计)

最初我们的方案是创建一个 @cmclink/shared-stores 基础包:

scss 复制代码
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 1 wujie props 冷启动初始快照 同步、可靠、子应用启动即可用
Channel 2 wujie bus 运行时增量同步 异步、实时、事件驱动

props 结构:

typescript 复制代码
{
  $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

typescript 复制代码
// 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 监听。我们的做法是在写入点主动广播:

typescript 复制代码
// 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 中主动调用:

typescript 复制代码
async logout() {
  await logout()
  removeToken()
  emitSharedLogout()  // 主动广播,确保子应用收到
  this.resetState()
}

初始快照注入

主应用的 App.vue 通过 computed 构建 props,每次 Store 变化自动更新:

vue 复制代码
<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:

typescript 复制代码
// 冷启动:从 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,子应用始终保持独立可运行。

数据流全景

ruby 复制代码
┌──────────────────────────────────────────────────────────┐
│                        主应用                              │
│                                                          │
│  API 请求 → userStore / dictStore / localeStore          │
│                        │                                  │
│              shared-provider.ts                           │
│              (watch 响应式属性 → bus.$emit)                │
│                        │                                  │
│            ┌───────────┼───────────┐                      │
│            ▼           ▼           ▼                      │
│       wujie props   wujie bus   tabsStore 等              │
│       ($shared)     (SHARED:*)                            │
│            │           │                                  │
└────────────┼───────────┼──────────────────────────────────┘
             │           │
   ┌─────────┼───────────┼─────────┐
   ▼         ▼           ▼         ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ doc  │ │ ibs  │ │ mkt  │ │ ...  │
│      │ │      │ │      │ │      │
│ 只读 │ │ 只读 │ │ 只读 │ │ 只读 │
│ 消费 │ │ 消费 │ │ 消费 │ │ 消费 │
└──────┘ └──────┘ └──────┘ └──────┘

踩坑记录

坑 1:watch getter 返回新对象导致响应式失效

typescript 复制代码
// ❌ 错误:每次调用 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:

typescript 复制代码
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 的生产实践,如有问题欢迎交流。

相关推荐
福兮说12 分钟前
canvas 旋转图片的六个坑:四角被裁、Math.ceil 多出 1px 黑边、JPG 角发黑、翻转方向反了
前端·javascript·图像处理·canvas
传人once2 小时前
多图片上传预览功能如何写
前端·css·html·jquery·html5
Eric.462 小时前
Wan‑Animate+MiniMax-H3 本地部署 AI 漫剧流水线:8G 显存动作迁移与动态分镜工程实战
前端·人工智能·comfyui·ai漫剧
Dawson Zhu3 小时前
《Agentic Design Patterns》第 1 章导读:提示词链(Prompt Chaining)
人工智能·语言模型·架构·aigc·agi
feiyu_gao4 小时前
Mindcraft:从个人实践到可复用模式集
架构·aigc·ai编程
库拉镜像AI牛牛4 小时前
短剧内容自动化生产:知漫剧工作室落地教程
大数据·服务器·前端·人工智能·语音识别
abigalexy4 小时前
Claude Code从零搭建新项目全流程AI实现-宠物生命周期管理App
架构·aigc·ai编程
律宏阔4 小时前
Flutter 列表图片内存优化:CachedNetworkImage 与 ClipRRect 的取舍
前端·flutter
行者全栈架构师4 小时前
【鸿蒙心迹】相册文搜图工程化落地:HarmonyOS 7.0 端侧 AI 检索全链路实战与 5 个踩坑解法
前端
律宏阔4 小时前
Dart 3 Record:解决 Future.wait 异构返回值的类型问题
前端·flutter