中介者模式:把面板之间的蜘蛛网拆干净
先看一段代码:
// 三个面板组件,互相监听、互相调用
class PanelA {
constructor(private panelB: PanelB, private panelC: PanelC) {}
onFilterChange(filter: string) {
this.panelB.updateList(filter) // A 直接戳 B
this.panelC.refreshChart(filter) // A 直接戳 C
}
}
class PanelB {
constructor(private panelA: PanelA, private panelC: PanelC) {}
onItemSelect(id: string) {
this.panelA.highlightFilter(id) // B 直接戳 A
this.panelC.zoomToDataPoint(id) // B 直接戳 C
}
}
// PanelC 也引用了 A 和 B……循环依赖已经在路上了
三个面板,六条依赖线。再加一个 PanelD?12 条。蜘蛛网。
问题出在哪
面板联动这事不难。难的是谁该知道谁。
后台管理系统里常见的布局:左侧筛选、中间列表、右侧详情、底部图表。筛选面板点一下,其余三个面板全得动。列表选中一行,详情和图表跟着变。
每个面板都直接引用其他面板的实例——这就是全连接拓扑。N 个面板,依赖数量 N×(N-1),O(N²) 增长。
跟框架没关系。Vue、React、原生 Web Components,只要组件直接互调,拓扑就是网状的。加面板,已有面板得改。删面板,引用全爆红。
说白了:通信拓扑和业务逻辑长一块去了。
中介者:网状变星状
机场调度塔台。飞机之间不直接喊话,所有通信走塔台中转。每架飞机只认识塔台。
中介者就这个意思——N×(N-1) 条线收成 N 条,组件只跟中介者打交道:
class PanelMediator {
private panels = new Map<string, PanelComponent>()
register(name: string, panel: PanelComponent) {
this.panels.set(name, panel)
}
// 面板不直接调彼此,走中介者
notify(sender: string, event: string, data: unknown) {
switch (event) {
case 'filter:change':
this.panels.get('list')?.updateList(data)
this.panels.get('chart')?.refresh(data)
break
case 'item:select':
this.panels.get('detail')?.showDetail(data)
this.panels.get('chart')?.highlight(data)
break
// 新面板加个 case 就完事
}
}
}
面板那边就简单了:
class FilterPanel {
constructor(private mediator: PanelMediator) {}
onFilterChange(filter: string) {
// 不关心谁在听,喊一嗓子就行
this.mediator.notify('filter', 'filter:change', filter)
}
highlightFilter(id: string) { /* ... */ }
}
每个面板只认一个东西——中介者。加面板不改旧代码,删面板也不改。变更隔离。
跟 EventBus 啥区别
"这不就是 EventBus?"
不是。差在控制权。
// EventBus:广播,发布者不管谁在听
eventBus.emit('filter:change', data)
// Mediator:路由,中介者决定消息去哪
mediator.notify(sender, event, data)
EventBus 是盲发。谁订了 filter:change 谁收到。有人忘取消订阅?鬼知道哪个犄角旮旯会被触发。出 bug 全局搜事件名,祈祷没拼错。
中介者是显式路由。打开中介者代码,谁和谁联动,一眼就看到。追踪起来完全不是一个量级。
| 维度 | EventBus | Mediator |
|---|---|---|
| 通信模式 | 广播 | 定向路由 |
| 联动逻辑在哪 | 散落在各订阅者里 | 中介者里集中管 |
| 调试 | 全局搜事件名 | 看一个文件 |
| 加面板 | 自己订阅 | 中介者注册 |
| 适合干嘛 | 松散通知 | 强业务联动 |
落地长啥样
拿 Vue 3 + TypeScript 做例子,思路跟框架无关:
// types.ts
interface PanelComponent {
readonly name: string
receive(event: string, data: unknown): void
}
// mediator.ts
class DashboardMediator {
private panels = new Map<string, PanelComponent>()
private rules: MediationRule[] = []
register(panel: PanelComponent) {
this.panels.set(panel.name, panel)
}
addRule(rule: MediationRule) {
this.rules.push(rule)
}
notify(sender: string, event: string, data: unknown) {
for (const rule of this.rules) {
if (rule.event === event) {
for (const target of rule.targets) {
if (target === sender) continue // 别通知自己
this.panels.get(target)?.receive(rule.targetEvent ?? event, data)
}
}
}
}
}
interface MediationRule {
event: string
targets: string[]
targetEvent?: string // 可以转换事件名
}
联动规则这么配:
const mediator = new DashboardMediator()
mediator.addRule({
event: 'filter:change',
targets: ['list', 'chart', 'detail']
})
mediator.addRule({
event: 'list:select',
targets: ['detail', 'chart'],
targetEvent: 'item:focus' // 面板不需要知道谁触发的
})
mediator.addRule({
event: 'chart:click',
targets: ['list', 'detail'],
targetEvent: 'item:focus'
})
面板只干两件事:发消息、收消息。
// FilterPanel.vue
const mediator = inject<DashboardMediator>('mediator')!
function onFilterChange(value: string) {
mediator.notify('filter', 'filter:change', value)
}
function receive(event: string, data: unknown) {
if (event === 'reset:all') {
resetFilters()
}
}
FilterPanel 不知道 ListPanel 存在。ListPanel 也不知道 ChartPanel。它们只认中介者。干净。
状态管理不能替代吗
能。但得看情况。
状态管理搞定的是数据共享——多个组件读写同一份数据。中介者搞定的是行为协调——A 做了个动作,B 和 C 得跟着执行操作。
// 状态管理:共享数据
const store = useFilterStore()
// 所有面板读 store.currentFilter
// 问题来了:chart 的过渡动画谁触发?list 的滚动谁管?
// 数据同步了,行为塞不进 store
// 中介者:协调行为
mediator.notify('filter', 'filter:change', value)
// chart 播动画
// list 滚到顶部、拉数据
// detail 清空、显示占位符
数据归 store 管,行为归 mediator 管。实际项目里两个经常搭着用。
中介者会膨胀
这个问题躲不掉。逻辑全往中介者塞,它迟早变上帝对象。
所以按业务域拆:
class FilterMediator extends BaseMediator {
// 只管筛选联动
}
class SelectionMediator extends BaseMediator {
// 只管选中/聚焦
}
class LayoutMediator extends BaseMediator {
// 只管面板展开/折叠/拖拽
}
// 顶层组合
class DashboardMediatorGroup {
private mediators: BaseMediator[]
notify(sender: string, event: string, data: unknown) {
const prefix = event.split(':')[0]
const target = this.mediators.find(m => m.handles(prefix))
target?.notify(sender, event, data)
}
}
单个中介者管一类联动,规则控制在 10 条以内。可读性没问题。
调试体验
中介者有个 EventBus 给不了的东西:集中式日志。
class DashboardMediator {
notify(sender: string, event: string, data: unknown) {
console.debug(`[Mediator] ${sender} → ${event}`, data)
for (const rule of this.rules) {
if (rule.event === event) {
for (const target of rule.targets) {
if (target === sender) continue
console.debug(` → notify ${target}`)
this.panels.get(target)?.receive(rule.targetEvent ?? event, data)
}
}
}
}
}
// 控制台输出:
// [Mediator] filter → filter:change { category: 'electronics' }
// → notify list
// → notify chart
// → notify detail
出 bug 了打开控制台——事件从哪来、去了哪,全有。不用在六个文件里跳来跳去。
别啥都往上套
几个不该用的场景:
两三个面板别用。 两个组件直接调,清清楚楚。硬加中介者反而多绕一层。过度设计比直接耦合更烦人。
纯数据同步用不着。 面板之间只共享一个筛选值?Pinia store 或 React Context 够了。中介者是给行为联动准备的。
异步联动有坑。 A 通知 B,B 异步处理完通知 C,C 再通知 A——循环了。得加防重入:
class SafeMediator extends DashboardMediator {
private processing = new Set<string>()
notify(sender: string, event: string, data: unknown) {
const key = `${sender}:${event}`
if (this.processing.has(key)) return // 挡住循环通知
this.processing.add(key)
try {
super.notify(sender, event, data)
} finally {
this.processing.delete(key)
}
}
}
换个角度看
组件通信这事,说到底是图论问题。直接依赖是全连接图,O(N²)。中介者打成星型图,O(N)。EventBus 也是星型——但它是隐式的,不翻每个订阅者的代码你都不知道这颗星长啥样。
中介者的价值不在"模式"这俩字。在于它把隐式全连接图变成了显式的、可配置的、能追踪的星型路由。
碰到组件之间互相 import、互相调用的代码,数一下依赖线。超过 N 条了,中间该加个调度中心了。