微前端架构做到一定阶段,一定会撞上子应用之间怎么通信这个问题。路由、沙箱、部署模式都可以商量着来,唯独通信方案选不好,业务代码会越来越别扭。这篇文章不打算讲一堆概念,就聊三个最常见的选择:Props 传递、CustomEvent 和全局状态管理,以及它们在真实项目里各自好使的场景和容易翻车的地方。

很多人以为微前端只是把几个应用拼到一个页面里,但实际运行时会发现,每个子应用都有自己的 JavaScript 运行时实例。在 qiankun、micro-app 这类框架中,脚本执行被沙箱包了一层,window 上的很多属性也未必是同一个。这种隔离让应用之间的通信没有天然的桥梁,只能靠主应用或者公共容器提供一条通路。
先弄清谁在和谁通信
在选择具体方案之前,先要区分两类通信:一类是主应用与子应用之间的通信,一类是子应用与子应用之间的通信。前者往往带有管理属性,比如把登录后的用户信息、权限标识下发给子应用,或者要求子应用执行一次全局登出。后者偏业务属性,比如订单创建成功后通知用户中心跳转到详情页。
这两类需求的难点不一样。主应用与子应用之间还可以借助框架提供的生命周期和配置项,而子应用之间不能直接引用对方代码,也无法共享同一个 DOM 上下文,所以需要一种更解耦的机制。
方案一:Props 传递,简单直接但只适合单向控制
Props 传递是微前端框架天然支持的通信方式。以 qiankun 为例,主应用在注册子应用时可以通过 props 传入任意数据和方法,子应用在 mount 生命周期里接收。
// 主应用
registerMicroApps([{
name: 'order-app',
entry: '//localhost:3001',
container: '#container',
props: {
userInfo: { name: '张三', id: 'u001' },
onLogout: () => logout()
}
}])
// 子应用
export async function mount(props) {
render(props.container, {
userInfo: props.userInfo,
onLogout: props.onLogout
})
}
这种方式的优点是不会引入额外依赖,数据流方向很清晰:主应用是上游,子应用是下游。适合管理类信息同步,比如登录成功后把用户信息下发给所有子应用,或者主应用切换菜单时需要子应用更新路由。
但它的局限也很明显。props 在子应用初始化时传入一次之后,如果主应用的数据后续发生了变化,子应用不会自动感知。你可以在主应用通过 ref 调用子应用暴露的方法去推送新数据,但这意味着每次状态变化都要写一遍主动更新逻辑。另一个问题是子应用无法主动向主应用请求数据,只能被动接收。所以如果业务场景是“子应用随时需要读一份最新状态”,props 就不太够用。
这里有个常见的工程场景:一个后台管理系统,主应用负责登录,订单子应用和用户子应用是不同团队开发的。主应用把用户信息传给了订单子应用,但用户在订单子应用里修改了头像,用户子应用那边看到的还是旧头像。这种跨子应用的状态同步,只靠 props 很难处理。
方案二:CustomEvent 事件总线,灵活解耦但要注意生命周期
当通信不再是单一的上下行关系,而是子应用之间互相通知时,事件机制会更合适。浏览器原生提供的CustomEvent是一个轻量选择,通过 window 派发事件,其他应用监听事件,从而实现解耦。
window.dispatchEvent(new CustomEvent('order:submitted', {
detail: { orderId: 'A10086' }
}))
window.addEventListener('order:submitted', (event) => {
const { orderId } = event.detail
// 跳转到订单详情
})
直接使用原生 CustomEvent 有一个风险:沙箱隔离后,子应用拿到的 window 可能和主应用不是同一个对象。比如在 qiankun 的 proxySandbox 下,直接挂在 window 上的监听器有时不稳定,事件发不到预期的地方。更稳妥的做法是,在主应用创建一个真正共享的事件总线对象,然后通过 props 传给子应用,这样大家拿到的是同一个实例。
实现一个轻量事件总线并不复杂,使用发布订阅模式即可:
class EventBus {
constructor() {
this.events = {}
}
on(name, fn) {
this.events[name] = this.events[name] || []
this.events[name].push(fn)
}
emit(name, data) {
;(this.events[name] || []).forEach((fn) => fn(data))
}
off(name, fn) {
this.events[name] = (this.events[name] || []).filter((f) => f !== fn)
}
}
const eventBus = new EventBus()
// 把这个 eventBus 通过 props 传给所有子应用
事件总线很适合低频、解耦的通知类需求。比如订单子应用提交成功后,emit 一个事件,用户中心子应用监听这个事件并跳转到对应详情页。两个应用不需要知道对方存在,全部交给主应用转发。直接从 npm 引入 mitt 或自定义一个几十行的模块都可以,没必要为了事件总线引入重型状态库。
这种方案的缺点是事件总线只负责“通知”,并不管理数据状态。如果两个子应用要实时读写同一份数据,光靠事件,你要么每次把数据带在 detail 里,要么让每个子应用自己维护副本,业务一复杂就很容易多处不一致。
事件监听器的生命周期也是重灾区。子应用挂载时注册监听,卸载时必须移除,否则下一次挂载会重复触发。而且事件名如果不做规范,多团队容易写重,产生难以排查的联动 bug。
方案三:全局状态管理,适合多子应用共享同一份状态
当多个子应用需要频繁读写同一份状态时,事件总线就不够用了。更合适的做法是创建一个全局的 store 实例,让所有子应用共享它。你可以用 Redux、Zustand,也可以基于 observable 对象自己封装一套。
核心思路是在主应用里初始化一个 store,然后通过 props 塞给子应用。子应用不仅能读 store 里的数据,还能订阅变化,调用 action 修改状态。因为所有子应用持有的是同一个 store 引用,只要有一方通过 action 更新了数据,所有订阅方都会收到通知。
// 主应用:创建共享 store
import { createStore } from 'zustand/vanilla'
export const globalStore = createStore((set) => ({
user: null,
permissions: [],
setUser: (user) => set({ user }),
setPermissions: (permissions) => set({ permissions })
}))
// 注册子应用时传入 props
props: {
globalStore
}
子应用里可以这样使用:
const { user, permissions, setUser } = props.globalStore.getState()
props.globalStore.subscribe((state) => {
console.log('state changed', state)
})
这种方案的优点是有完整的响应式链路,状态可追踪,跨应用的数据同步是自动的。主应用和子应用都可以通过 action 修改用户信息,然后所有依赖这份信息的子应用同步刷新。相比事件总线,它的语义更清晰:事件表达“发生了某件事”,store 表达“当前状态是什么”。
代价也很明显。子应用代码里会出现主应用传入的 store,这让子应用无法完全独立运行。比如子应用想在本地开发模式单独启动,就需要 mock 这个 globalStore,否则一进来就报错。另外,全局状态放多了,子应用之间的隐性耦合会升高,一旦 state 结构变更,所有依赖方都要跟着改。所以必须约定好哪些状态可以进全局,哪些只能留在本地。
一个典型场景是登录态和权限点。多个子应用都依赖用户角色和菜单权限,而且这些信息会在主应用里动态刷新。如果用事件总线,每次权限变化都要手动通知所有应用,很容易漏。用全局 store 之后,子应用只需要订阅 store 里的 user 和 permissions,主应用更新一次,所有子应用都能感知。
三种方案怎么选?一张表说清楚
这套方案并不互斥,很多项目会混用。下表对比了关键差异,便于在具体场景里快速判断。
| 方案 | 通信方向 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|---|
| Props 传递 | 主应用 → 子应用 | 初始化配置、登录信息下发、主应用控制子应用 | 简单直接,无额外依赖,数据流清晰 | 不支持子应用反向获取,状态更新需要手动推送 |
| CustomEvent | 任意应用之间 | 低频业务通知、跨子应用跳转、解耦事件触发 | 天然解耦,实现简单,不需要状态机制 | 不适合状态同步,受沙箱影响,需要管理监听生命周期 |
| 全局状态管理 | 任意应用之间 | 多子应用共享同一份数据、频繁读写同一状态 | 状态可追踪,响应式更新,数据一致性好 | 引入跨应用依赖,独立开发需要 mock,需要约定状态范围 |
从维护成本来看,Props 传递最低,但表达能力也最弱。CustomEvent 和全局状态管理都能实现双向通信,区别在于你要传递的是“通知”还是“状态”。通知用事件,状态用 store。
工程落地时最常踩的坑
方案本身不复杂,真正麻烦的是细节。这里整理几个容易出问题的地方。
事件命名冲突
多个团队各自开发子应用时,事件名很容易重名。比如一个叫 logout,另一个也叫 logout,但两个应用监听后都会执行。建议约定统一的命名前缀,比如 app:order:submitted,同时维护一份事件名清单,发布前互相确认。
监听器没有清理
子应用卸载时,事件监听和 store 订阅都必须清理。有些团队把监听逻辑写在组件里,组件卸载时顺手移除了;有些写在 mount 周期里,unmount 时却忘记 off。结果就是同一个子应用反复进入页面后,旧监听器还在运行,处理逻辑执行多次。
// 子应用 mount 时注册
const handler = (event) => { ... }
props.eventBus.on('app:order:submitted', handler)
// unmount 时一定要移除
export async function unmount() {
props.eventBus.off('app:order:submitted', handler)
}
沙箱隔离带来的 window 不一致
有些人习惯直接定义 window.xxx 作为全局变量,但在严格沙箱模式下,子应用里访问的 window 可能被代理过,事件监听器挂不到真正的共享 window 上。所以最好通过框架提供的 props 或全局变量注入机制,而不是自己往 window 上塞东西。
全局 store 导致隐式耦合
全局 store 用顺手之后,很容易把业务数据也放进去,最后所有子应用都依赖这个 store 里的几十个字段。一旦某天主应用重构,这些字段一改,所有子应用全部报错。建议只把真正需要跨应用共享的核心状态放到全局,业务私有状态留在子应用内部。
一些落地建议
如果项目还处于起步阶段,通信需求不多,优先用 Props 传递加一个简单的 EventBus。这样可以避免过早引入重型状态管理,也方便后续切换。
- 凡是主应用单向下发的配置类信息,用 Props 传。
- 凡是跨子应用的低频通知,用 EventBus,不要直接操作 window 原生事件。
- 凡是需要多端共享且频繁更新的状态,再考虑全局 store,并约定好 state 结构。
另外,无论选择哪种方案,都要把通信逻辑封装成一个独立模块。子应用只依赖这个模块,不要直接在业务代码里到处引用全局对象。同时,在子应用的 unmount 阶段,统一做一次清理,把监听器和订阅全部关掉,这是保证微前端长期稳定运行最基础的一步。
还有一个容易被忽略的问题:通信数据的序列化。如果事件或 store 里塞入了函数、DOM 对象或循环引用,在部分跨应用场景(比如结合 iframe 的微前端方案)中会丢失或报错。尽量只传递可序列化的数据,函数调用通过事件名约定来完成。
总结
微前端的子应用通信没有银弹。Props 传递适合简单的下行控制,CustomEvent 适合灵活的事件通知,全局状态管理适合复杂的共享状态场景。实际项目里,这三种方案往往混在一起,关键在于你能不能看清每一次通信的本质:它是配置、通知还是状态?把这个想清楚,技术选型就只是顺理成章的事。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/650/