微前端模块联邦实践系列(8) - 消息通信

195 阅读8分钟

本系列大纲

这个是这个系列的最后一篇文章了,主要关于各个app之间如何实现消息通信。在我给出的这个例子中,需要通信的地方主要在于:当在container切换用户之后,需要通知posts以及albums进行相应的数据刷新,因为posts和albums的数据是通过用户id来进行筛选的。

我们之前在上一篇文章 微前端模块联邦实践系列(7) - 路由管理 中同步路由的信息是采用了callback的方式,这种情况更多适用于做组件外的消息同步,我称之为”紧耦合“同步,在组件内部,其实更加需要一种”松耦合“的同步,这也是大家熟知的”消息订阅与发布“。

采用事件的订阅和发布机制,能够轻松解决模块之间、app之间的耦合关系,可以在各个app之间的任意组件中进行使用,因为在使用“模块联邦”这种方式构建出来的微服务架构,其实最终和Build Integration达到的结果是一样的,最终都是合成一个app,只是加载时间的区别而已。

所以由此,我们主要要考虑几点:

  • EventBus库的选择应该尽量偏向于底层,使用原生js实现,不能特定于某种技术栈
  • 事件对象在全局应该是单例的,这方便在各个模块中使用,否则会因为多实例的问题导致消息不能收到,但是事件可以有多个subject,不同的app只需要关注自身关注的subject即可
  • 模块联邦中使用的第三方EventBus库的版本不能存在major版本的区别,否则实例化的Event对象就是多实例的,这点可以看看 微前端模块联邦实践系列(4) - subApp之间共享libraries 文章中的讲解。

数据传递与共享

一般会有两种方式来进行数据传递与共享

  • 本地数据共享:状态维护在每个app本身,各个app之间采用EventBus来订阅和发布自己感兴趣的事件,做到消息同步
  • API Center:维护一个全局的api center,各个应用中的操作实时同步到数据中心,在各个app中通过api的方式或者推送 (Websocket/SSE)的方式来订阅更新

显然第一种方式的状态依旧维持在整个app的内部,因此各个app之间如何高效的进行事件发布、订阅,还要保证事件不会重复发送、订阅消息错乱,都需要经过精心的设计,好处是这种反馈环比较浅,消息传递的即时性是有保证的。

而维护一个api center的方式,本质上会更加解耦,各个app之间并不需要刻意去关心其他app的状态变化,只需要在有需要的时候进行主动pull、或者被动订阅就好,缺点就是维护成本会大,因为毕竟要加一个服务来持久化,而且可能存在消息延迟的问题。

如何进行本地状态共享

各个app中可以维护自己的状态库,正如第2篇文章 微前端模块联邦实践系列(2) - 微前端与模块联邦 中所讲,各个app之间应该不应该限定使用某种技术栈,因此在选择状态库的方面,也可以跟当前的整体技术栈匹配,比如React可以选择 React Contextzustand,Vue 可以选择 Vuexpinia 等等。

状态存储介质

在container (hosted) app中,更多的是承载着基础的信息,比如用户的登录操作,一般在操作之后都会储存用户的登录信息,比如accessToken,这些信息可以存储在以下几个地方

  • localStorage
  • cookie
  • 状态库(内存)

localStorage与cookie本身有着存储空间的限制,而且本身两者的存储只能是字符创形式,因此操作起来并不是特别友好,优点是每个app都能够访问到,适合存储一些比较小、不是经常改变、多tab访问的状态,比如用户信息、登录token、国际化语言配置等等。

状态库中的数据,主要是维护在各个内存中,同时每个开源的状态框架在数据维护、组件内状态刷新上也下了不少的功夫,所以操作起来更加得心应手,但是有着刷新即丢失的特点,因此如果需要持久化的话,需要花一点维护成本。

针对上面各个储存介质的主要特点,选用哪种其实并没有严格界限,比如我可以结合 localStorage + 状态库 做部分关键数据的持久化,同时将一些即时状态使用操作效率更高的状态库来维护,也是一种很好的方案。

EventBus + 存储介质

通过结合EventBus + 储存介质,们可以有效地来进行本地的状态的共享。各个app通过存储自身的独立的数据,通过EventBus,将一些公有的数据暴露出去,使得其他app通过订阅来生成自己的初始状态。

下面结合例子来看看如何实现这种方式。

案例分析

container app采用了React框架,因此我选用了 zustand 来做状态管理,当切换用户之后,会维护一份在状态库中用于作为页面的显示,同时会将新的改动发布出去。

全局的EventBus,采用的是浏览器的默认API:BroadcastChannel

BroadcastChannel 是浏览器中一个用于在不同的浏览器上下文之间(如不同的标签页、iframe、Worker 等)传递消息的 API。它允许多个脚本之间进行实时的通信,而不需要使用复杂的存储或服务器。

选它的主要原因是,我找了一圈EventBus的库都是多实例的,而我又比较懒,不想改造改造,所以用了这个简单粗暴的API,综合使用下来,感觉还不错,支持object类型的数据直接传递。

// apps/container/src/store/eventBus.js

const channel = new BroadcastChannel('shared_state_channel')

export const syncUserToSubApps = (user) => {
  channel.postMessage({ type: 'user:sync', payload: user })
}

以上代码主要用来同步user信息的改动。

// apps/container/src/store/userStore.js

const useUserStore = create((set) => {
  return {
    currentUser: { name: '' },
    users: [],
    fetchUser: async () => {
      const usersResponse = await fetch('https://jsonplaceholder.typicode.com/users')
      const users = await usersResponse.json()
      const currentUser = users[0]
      set({ users, currentUser })
    },
    chooseUser: (currentUser) => {
      syncUserToSubApps(currentUser)
      set({ currentUser })
    },
  }
})

选择用户之后 syncUserToSubApps

为什么不在fetchUser之后同步?

这是因为在获取用户之后进行一次user同步可能是无用功,因为此时我们的subApp可能并没有加载,那么同步的消息是无法被子app进行订阅的,因此就会造成信息同步的缺失。

挂载后同步初始信息

挂载后同步初始信息,这种方式是更加可靠的,同时由于挂载只能有一次,因此然而获取用户信息是异步的,因此在挂载完成之后立马进行同步其实也不靠谱,所以除此之外,我们还需要监听user信息变化之后才同步。

// apps/container/src/entries/AlbumsApp.js

const currentUser = useUserStore((state) => state.currentUser)

useEffect(() => {
  syncUserToSubApps(currentUser)
}, [currentUser])

albums初始化订阅消息

// apps/albums/src/store/useUserStore.js

const useUserStore = defineStore('user', {
  state: () => ({ currentUser: null }),
  actions: {
    syncUser(user) {
      this.currentUser = user
    },
  },
})

创建 userStore,用来同步用户信息。

// apps/albums/src/store/eventBus.js

export const channel = new BroadcastChannel('shared_state_channel')

export const listenForUserSync = (userStore) => {
  channel.onmessage = (event) => {
    if (event.data.type === 'user:sync') {
      userStore.syncUser(event.data.payload)
    }
  }
}

这里就比较干脆了,在收到user改变的消息之后,就直接设置了,这里不区分初次用户信息初始化还是之后的切换用户操作。

页面中使用:

// apps/albums/src/views/Home.vue

const userStore = useUserStore()
const currentUser = computed(() => userStore.currentUser)

watch(
  currentUser,
  async (user) => {
    if (!user) {
      return
    }

    try {
      const response = await fetch(`https://jsonplaceholder.typicode.com/albums?userId=${user.id}`)
      if (response.ok) {
        const albumsData = await response.json()
        albums.value = map(albumsData, (album) => ({
          ...album,
          description: faker.lorem.lines(1),
          image: `https://picsum.photos/250/330?random=${album.id}`,
        }))
      }
    } catch (error) {
      console.error(error)
    } finally {
      loading.value = false
    }
  },
  { immediate: true },
)

监听id改变,改变之后,重新请求数据。

一些其他的改动

本地开发调试

本地如果想要正常运行,也需要给app发送初始数据。

// apps/albums/src/bootstrap.js

if (process.env.NODE_ENV === 'development') {
  const root = document.getElementById('albumsRoot')
  if (root) {
    mount(root, { defaultHistory: createWebHistory() })
    
    const channel = new BroadcastChannel('shared_state_channel')
    channel.postMessage({ type: 'user:sync', payload: { name: 'Local User', id: 1 } })
  }
}

懒加载subApps

// apps/container/src/router.js

const PostsApp = React.lazy(() => import('@/entries/PostsApp'))
const AlbumsApp = React.lazy(() => import('@/entries/AlbumsApp'))

const AppRouter = () => (
  <BrowserRouter>
    <Suspense fallback={<InfiniteProgressBar />}>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route path="/" element={<Navigate to="/posts" />} />
          <Route path="/posts" element={<PostsApp />} />
          <Route path="/albums/*" element={<AlbumsApp />} />
        </Route>
      </Routes>
    </Suspense>
  </BrowserRouter>
)

添加模块进度加载条<InfiniteProgressBar />,在模块还未加载时,会显示进度加载条,加载完成之后,再多次点击,进度条不会出现。

最后的总结

这是我第一次尝试写系列文章,也可能是最后一次了😄😄😄。

总结起来就是挺累的,创作不易,多多支持。