本系列大纲
- 微前端模块联邦实践系列(1) - 微前端入门
- 微前端模块联邦实践系列(2) - 微前端与模块联邦
- 微前端模块联邦实践系列(3) - 第一个案例
- 微前端模块联邦实践系列(4) - subApp之间共享libraries
- 微前端模块联邦实践系列(5) - Path to Production
- 微前端模块联邦实践系列(6) - 集成React & Vue
- 微前端模块联邦实践系列(7) - 路由管理
- 微前端模块联邦实践系列(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 Context,zustand,Vue 可以选择 Vuex,pinia 等等。
状态存储介质
在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 />
,在模块还未加载时,会显示进度加载条,加载完成之后,再多次点击,进度条不会出现。
最后的总结
这是我第一次尝试写系列文章,也可能是最后一次了😄😄😄。
总结起来就是挺累的,创作不易,多多支持。