多窗口事件总线怎么处理

问题

我现在做的是多窗口应用(基于electron-vite,vue3,和dokeview),现在有一个场景:我们原项目里面多出地方使用的事件总线模式,在组件挂载的时候注册事件,在组件销毁的时候off事件,现在改成多窗口应用后,就出现了打开了多个注册事件的窗口,事件触发的时候会触发多次,事件关闭的时候就全都关闭了。这种情况 有什么好的解决方案吗?

解决方案

这是一个非常经典的架构演进问题。当你从“单页面应用 (SPA)”转变为“多窗口应用 (Multi-Window)”时,传统的事件总线(EventBus)模式如果不加改造,必然会遇到**“广播风暴”(触发多次)和“误杀”**(关一个全关了)的问题。

问题根源分析

  1. 触发多次 (Broadcast Storm)

    • 如果你的事件总线是基于 IPC(ipcRenderer)或 BroadcastChannel 的,消息是广播的。
    • 你有 3 个窗口,每个窗口里的组件都监听了 refresh-data
    • 当一个事件发出时,3 个窗口都收到了,导致代码执行了 3 次。
    • 但在旧的单页应用思维里,通常预期是谁发出的,或者谁在当前视野里,谁才响应。
  2. 全都不触发了 (Premature Unsubscription)

    • 这是最常见的问题。你的代码可能写的是 bus.off('event-name')
    • 这会移除该事件的所有监听器
    • 在多窗口(或同一窗口多 Tab)场景下,窗口 A 销毁组件时调用了 off,结果把窗口 B(甚至同窗口下的 Tab B)正在用的监听器也给删掉了。

解决方案

你需要将事件总线升级为 “多级分发策略”。我们需要明确区分:这是给谁的消息?

我为你提供一套 “带作用域的分布式事件总线” 解决方案。

核心策略

  1. 精确销毁:绝对禁止使用 bus.off('name')(清空所有),必须使用 bus.off('name', handler)(只移除当前函数的引用)。
  2. 作用域区分 (Scoping)
    • Local (本地):只在当前窗口/当前组件内生效(使用 mitt 或简单的回调)。
    • Global (全局):所有窗口都生效(例如:用户退出登录)。
    • Targeted (定向):只发给特定的 panelIdwindowId(例如:刷新表格,只刷新 ID 为 user-table 的那个)。

代码实现:DistributedEventBus

我们将封装一个智能的 EventBus,它底层结合了 mitt (本地) 和 BroadcastChannel (跨窗口)。

1. 工具类封装 (src/utils/event-bus.ts)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import mitt, { Handler } from 'mitt'

// 定义消息结构
type BusPayload = {
event: string
data: any
targetId?: string // 指定接收目标的 ID (PanelId 或 WindowId)
sourceId?: string // 发送者的 ID
}

export class DistributedEventBus {
private localBus = mitt() // 本地事件中心
private channel: BroadcastChannel
private myId: string // 当前实例/窗口的 ID

constructor(channelName: string = 'app-global-bus') {
// 生成或获取当前环境 ID (可以使用之前 Pinia 插件里的 generateId 逻辑)
this.myId = Math.random().toString(36).substr(2, 9)

// 1. 初始化跨窗口通道
this.channel = new BroadcastChannel(channelName)

// 2. 监听来自其他窗口的消息
this.channel.onmessage = (msg: MessageEvent<BusPayload>) => {
const { event, data, targetId, sourceId } = msg.data

// A. 如果指定了 targetId,且不是发给我的 -> 忽略
// (这里假设 myId 是窗口ID,或者是你传入的 panelId)
if (targetId && targetId !== this.myId) return

// B. 触发本地监听器
// 注意:我们在本地触发时,标记它来自"外部",避免死循环
this.localBus.emit(event, { ...data, _fromRemote: true })
}
}

/**
* 注册监听 (用法和以前一样)
*/
on(event: string, handler: Handler) {
this.localBus.on(event, handler)
}

/**
* 移除监听 (必须传入 handler 引用!)
*/
off(event: string, handler: Handler) {
this.localBus.off(event, handler)
}

/**
* 发送事件
* @param event 事件名
* @param data 数据
* @param scope 发送范围
* - 'local': 只在当前窗口触发 (默认)
* - 'global': 所有窗口都触发
* - string: 指定 ID (只发给特定 ID 的实例)
*/
emit(event: string, data: any = {}, scope: 'local' | 'global' | string = 'local') {
// 1. 总是触发本地 (除非有特殊需求)
this.localBus.emit(event, data)

// 2. 根据范围决定是否广播
if (scope === 'local') return

// 3. 构造广播包
const payload: BusPayload = {
event,
data,
sourceId: this.myId,
targetId: scope === 'global' ? undefined : scope // 如果是 global 则 targetId 为空
}

this.channel.postMessage(payload)
}

// 设置当前实例的 ID (用于 Dockview 的 Panel ID 绑定)
setId(id: string) {
this.myId = id
}

destroy() {
this.localBus.all.clear()
this.channel.close()
}
}

// 导出单例 (或者在组件里 new)
export const bus = new DistributedEventBus()

2. 在 Vue 组件中如何正确使用 (解决“全关闭”问题)

关键在于:onUnmounted 时,只移除自己的那个回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { bus } from './utils/event-bus'

// 假设这是 Dockview 里的一个 Panel
const props = defineProps<{ params: any }>()
const myPanelId = props.params.api.id

// 1. 设置 ID,以便能接收定向消息
bus.setId(myPanelId)

// 2. 定义处理函数 (必须提取出来,不能写匿名函数!)
// ❌ 错误:bus.on('refresh', () => { ... }) <- 匿名函数没法精确移除
// ✅ 正确:
const handleRefresh = (data: any) => {
console.log(`[${myPanelId}] 收到刷新请求:`, data)
// 业务逻辑...
}

onMounted(() => {
// 注册
bus.on('refresh-grid', handleRefresh)
})

onUnmounted(() => {
// 3. 销毁时,只移除当前的 handleRefresh
// 这样绝对不会影响其他窗口或 Tab 的 'refresh-grid' 监听
bus.off('refresh-grid', handleRefresh)
})

// --- 触发示例 ---

const triggerLocal = () => {
// 只刷新我自己这个窗口/Tab
bus.emit('refresh-grid', { page: 1 }, 'local')
}

const triggerGlobal = () => {
// 刷新所有打开这个页面的窗口 (比如用户修改了全局配置)
bus.emit('refresh-grid', { theme: 'dark' }, 'global')
}

const triggerTarget = () => {
// 假设你想刷新 ID 为 'user-list-panel' 的那个 Tab (无论它在哪个窗口)
bus.emit('refresh-grid', { reload: true }, 'user-list-panel')
}
</script>

方案优缺点总结

  1. 解决“触发多次”

    • 默认 scope: 'local':大多数业务场景(比如点击按钮刷新当前表格)只需要本地触发。旧代码如果不改,默认就是 Local,行为和单页应用一致,不会广播风暴。
    • 需要同步时显式用 'global'
  2. 解决“全部关闭”

    • 强制要求传入 handler 进行 off。这是编程习惯的矫正。
    • mitt 库本身就支持精确移除。
  3. 适配 Dockview

    • 通过 setId(panelId),实现了基于 Panel ID 的定向通信。
    • 你可以实现:“在窗口 A 点击保存,自动刷新窗口 B 里的列表”这种高级功能。

迁移建议

你现在的项目里可能充斥着 bus.emitbus.off

  1. 搜索替换:把引入的老 EventBus 换成这个新的 DistributedEventBus
  2. 默认行为:由于默认是 'local',你直接替换后,大部分功能会恢复正常(不再乱广播)。
  3. 修复销毁逻辑:你需要人工检查所有 onUnmountedbeforeDestroy,把 bus.off('event') 改成 bus.off('event', myHandler)
    • 如果老代码全是匿名函数怎么办?
    • 临时补救:在 DistributedEventBus 里维护一个 Map<PanelId, List<Handler>>,在组件销毁时调用 bus.clearCurrentScope()。但这比较复杂,推荐还是重构为命名函数。

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器