1. 从一个“牵一发动全身”的尴尬场景说起
如果你写过稍微复杂一点的交互逻辑,大概率遇到过这种局面:一个页面上有搜索框、下拉筛选、分页器、列表区域、空状态提示,还有底部的批量操作按钮。用户改了搜索词,下拉筛选要重置;用户点了分页,列表要刷新、空状态要重新判断;用户勾选了列表项,批量操作按钮要亮起来;用户清空了搜索词,分页要回到第一页……这些逻辑如果全部写在一个大组件里,用一堆watch或者事件回调互相调用,代码很快就会变成一张蜘蛛网。
我见过最夸张的一个版本,一个文件里塞了十几个ref,每个ref的watch里又去改别的ref,改完之后又触发另一个watch,最后连开发者自己都说不清“到底是谁触发了谁”。这种代码的典型症状是:改一个功能,崩三个地方;加一个筛选条件,要翻遍整个文件找所有相关的事件绑定。
中介者模式(Mediator Pattern)就是冲着这个问题来的。它的核心思路非常朴素:不要让对象之间互相直接通信,而是让它们都只跟一个“中间人”打交道,由这个中间人统一协调。就像机场塔台,飞机之间不直接喊话,所有起降请求都报给塔台,塔台再统一调度。飞机不需要知道其他飞机的存在,只需要知道塔台。
这篇文章我会从中介者模式到底解决什么问题讲起,拆解它的结构、和观察者模式的区别、在前端状态管理里的真实落地方式,以及我自己在项目里踩过的几个坑。不管你是刚接触设计模式的新手,还是写过一堆“事件满天飞”代码的老手,应该都能从中找到能直接用的东西。
2. 中介者模式到底在解决什么:从“多对多”到“一对多”
2.1 没有中介者时,对象之间是怎么互相折磨的
先看一个最直观的例子。假设有三个模块:搜索模块 A、列表模块 B、分页模块 C。它们之间需要联动:
- A 变化时,要通知 B 重新请求,同时通知 C 重置页码
- C 变化时,要通知 B 重新请求
- B 请求完成后,要通知 C 更新总页数
如果让它们直接互相持有引用,代码大概长这样:
class SearchModule { constructor(listModule, paginationModule) { this.list = listModule; this.pagination = paginationModule; } onSearchChange(keyword) { this.pagination.reset(); this.list.fetch({ keyword }); } } class PaginationModule { constructor(listModule) { this.list = listModule; } onPageChange(page) { this.list.fetch({ page }); } }看起来还行?但问题是,当模块从 3 个变成 6 个、8 个的时候,每个模块都要持有其他好几个模块的引用,构造函数参数会爆炸,依赖关系会变成一张密不透风的网。更麻烦的是,你想单独测试 SearchModule,就必须先造出 ListModule 和 PaginationModule 的实例,测试成本直线上升。
这就是典型的多对多耦合。N 个模块之间如果两两都可能通信,最坏情况下会有 N×(N-1)/2 条通信链路。模块一多,维护成本是指数级增长的。
2.2 中介者把网状结构压成星型结构
中介者模式做的事情,就是在这张网中间插入一个中心节点。所有模块不再互相持有引用,而是统一持有中介者的引用;模块之间的通信,全部通过中介者转发。
还是上面那个例子,改造之后:
class Mediator { constructor() { this.modules = {}; } register(name, module) { this.modules[name] = module; module.setMediator(this); } notify(sender, event, payload) { // 由中介者决定:谁发的消息,该通知谁 if (sender === 'search' && event === 'change') { this.modules.pagination.reset(); this.modules.list.fetch({ keyword: payload }); } if (sender === 'pagination' && event === 'change') { this.modules.list.fetch({ page: payload }); } if (sender === 'list' && event === 'loaded') { this.modules.pagination.updateTotal(payload.total); } } }每个模块只需要在状态变化时调用this.mediator.notify('自己', '事件', 数据),完全不需要知道其他模块的存在。通信链路从 N×(N-1)/2 条压缩成了 N 条(每个模块只连中介者)。
这个变化带来的好处不只是“代码好看”,更重要的是协调逻辑被集中到了一处。以前你想搞清楚“搜索变化后到底发生了什么”,得在三个文件里跳来跳去;现在只需要看 Mediator 的 notify 方法,所有联动规则一目了然。
2.3 中介者模式的角色构成
用标准的设计模式语言来说,中介者模式包含这么几个角色:
| 角色 | 职责 | 对应上面的例子 |
|---|---|---|
| Mediator(抽象中介者) | 定义同事对象通信的接口 | 定义 notify 方法 |
| ConcreteMediator(具体中介者) | 实现协调逻辑,持有所有同事对象 | 上面那个 Mediator 类 |
| Colleague(抽象同事类) | 持有中介者引用,定义自身行为 | 各模块的基类 |
| ConcreteColleague(具体同事类) | 具体业务模块 | SearchModule、ListModule 等 |
实际写业务代码时,抽象层往往可以省略,直接写一个具体的中介者就够了。设计模式的价值在于思路,不在于你是否严格照着 UML 图写四个类。
3. 中介者 vs 观察者:长得像,但解决的不是一回事
很多人第一次学中介者模式时,会有一个疑问:这不就是发布订阅吗?我用 EventBus 也能做到模块之间不直接引用啊,为什么还要单独搞一个中介者?
这个问题问得非常好,也是我自己当初卡了很久的地方。两者确实有重叠,但侧重点完全不同。
3.1 观察者模式关注的是“广播”,中介者关注的是“协调”
观察者模式(以及基于它的发布订阅)解决的是一对多的通知问题:一个对象状态变了,通知所有关心它的对象。发布者不需要知道订阅者是谁,订阅者也不需要知道发布者是谁,双方通过事件名解耦。
但观察者模式有一个隐含假设:发布者不关心订阅者收到消息后做什么。它只是喊一嗓子“我变了”,至于谁响应、怎么响应,跟它没关系。
中介者模式则不同。中介者是要主动参与协调决策的。它知道每个同事对象的存在,知道谁该在什么时候被通知,甚至知道通知的顺序。上面例子里,搜索变化时中介者先重置分页、再请求列表,这个顺序是中介者决定的,不是各个模块自己决定的。
打个比方:观察者模式像小区广播,物业喊一声“停水了”,谁听到谁自己处理;中介者模式像婚礼司仪,他清楚每个环节该谁上场、该说什么话,是整场流程的调度者。
3.2 什么时候该用中介者,什么时候用事件总线就够了
我的经验判断标准是这样的:
- 如果模块之间只是“通知一下”,不需要保证顺序、不需要根据情况决定通知谁,用事件总线就够了,简单直接。
- 如果模块之间的联动有明确的业务规则,比如“A 变了必须先把 B 重置再让 C 刷新”,这种带顺序、带条件的协调逻辑,就适合用中介者把它集中管理。
- 如果联动规则经常变,今天加一个筛选条件、明天改一个联动顺序,那更应该用中介者,因为改一处就够了,不用去翻每个模块。
注意:中介者不是银弹。如果模块之间本来就没多少联动,硬塞一个中介者进去,只会多一层没必要的间接。设计模式是用来消除痛苦的,不是用来制造痛苦的。
3.3 一个容易混淆的点:中介者会不会变成“上帝对象”
这是中介者模式最常被诟病的地方。因为所有协调逻辑都集中在中介者里,如果业务足够复杂,中介者本身可能膨胀成一个几千行的巨型类,反而变成了新的维护噩梦。
我在一个后台管理系统里就遇到过这个问题:一开始中介者只有几十行,后来越加越多,最后变成了一个 800 多行的“万能调度器”,谁都不敢动。后来我们的处理方式是按业务域拆分中介者:筛选区一个中介者,表格区一个中介者,弹窗区一个中介者,中介者之间通过少量事件通信。这样每个中介者都保持在可控规模内。
所以用中介者模式时,一定要有“拆分”的意识。中介者应该是协调者,不是什么都往里塞的垃圾桶。
4. 在前端状态管理里落地中介者:三种真实可用的写法
理论讲完了,接下来是能直接抄的部分。中介者模式在前端最常见的落地场景就是复杂表单联动和多模块状态协调。我按复杂度从低到高给三种写法。
4.1 写法一:一个朴素的 Mediator 类
适合模块数量不多、联动规则相对固定的场景。核心就是注册 + 通知两步。
class FormMediator { constructor() { this.colleagues = new Map(); } register(name, colleague) { colleague.mediator = this; colleague.name = name; this.colleagues.set(name, colleague); } notify(sender, event, payload) { const rules = this.rules || []; rules.forEach(rule => { if (rule.from === sender && rule.event === event) { const target = this.colleagues.get(rule.to); target && target.receive(rule.action, payload); } }); } }用的时候,每个同事对象实现一个receive(action, payload)方法,中介者根据规则表决定调用谁。规则表可以写成配置,这样加联动规则不用改代码逻辑,只加配置就行。
这种写法的好处是规则和逻辑分离,规则表一眼就能看懂整个联动关系。缺点是规则表本身可能变得很长,需要定期整理。
4.2 写法二:用状态机思路组织中介者
如果联动规则里有明显的“状态流转”,比如“未搜索 → 搜索中 → 有结果 / 无结果”,那用状态机的思路来写中介者会更清晰。
const states = { idle: { SEARCH: (ctx, payload) => { ctx.pagination.reset(); ctx.list.fetch(payload); return 'loading'; } }, loading: { SUCCESS: (ctx, payload) => { ctx.pagination.updateTotal(payload.total); return payload.total > 0 ? 'hasResult' : 'empty'; }, FAIL: (ctx) => { ctx.list.showError(); return 'idle'; } }, hasResult: { PAGE_CHANGE: (ctx, payload) => { ctx.list.fetch(payload); return 'loading'; }, SEARCH: (ctx, payload) => { ctx.pagination.reset(); ctx.list.fetch(payload); return 'loading'; } } };中介者只负责根据当前状态和收到的事件,查表找到对应的处理函数,执行完拿到新状态。这样每个状态下的合法行为被明确约束了,不会出现“加载中还能点分页”这种奇怪的状态。
这种写法我在一个数据看板项目里用过,效果很好。因为看板的联动逻辑特别多,用状态机把状态和事件理清楚之后,测试用例都好写了很多——每个状态、每个事件单独测就行。
4.3 写法三:中介者 + 响应式数据的组合
如果你用的是 Vue 或 React 这类有响应式系统的框架,其实可以借力打力,让中介者只负责“决策”,数据更新交给框架。
// 用一个响应式对象作为共享状态 const store = reactive({ keyword: '', page: 1, pageSize: 10, list: [], total: 0, loading: false }); // 中介者只负责监听变化、决定动作 const mediator = { init() { watch(() => store.keyword, () => { store.page = 1; this.fetchList(); }); watch(() => store.page, () => { this.fetchList(); }); }, async fetchList() { store.loading = true; const res = await api.fetch({ keyword: store.keyword, page: store.page, pageSize: store.pageSize }); store.list = res.list; store.total = res.total; store.loading = false; } };这种写法里,中介者的职责被压缩到了最小:它只定义“谁变了该触发什么”,具体的数据流转交给响应式系统。代码量最少,但前提是你要对框架的响应式机制足够熟悉,否则容易出现循环触发的问题。
提示:用响应式方案时,一定要小心“A 改 B、B 又改 A”的循环。我的习惯是给每个 watch 加一个明确的触发条件判断,避免无意义的重复执行。
5. 我在实际项目里踩过的四个坑
设计模式这种东西,看别人写觉得挺简单,自己上手才知道坑在哪。下面这几个是我印象最深的。
5.1 坑一:中介者里做了太多“业务判断”,导致职责不清
最开始我写中介者时,喜欢在里面写各种 if-else 判断业务条件,比如“如果用户是 VIP 且订单金额大于 100 才触发某个联动”。结果中介者里混入了大量业务规则,变得又臭又长。
后来我调整了思路:中介者只负责“谁通知谁”,不负责“该不该通知”。业务判断应该放在同事对象自己的 receive 方法里,或者放在一个独立的规则层。中介者保持纯粹,才能保持可维护。
5.2 坑二:同事对象直接调用中介者的具体方法,破坏了抽象
有一段时间,我的同事对象里直接写this.mediator.handleSearchChange()这种调用,等于绕过了统一的 notify 接口。这样做的后果是,中介者一旦想改通知机制(比如加日志、加防抖),就得去改所有同事对象的调用点。
正确的做法是同事对象只调用统一的notify(sender, event, payload),中介者内部怎么处理是它自己的事。这样中介者的实现可以随时替换,同事对象完全无感。
5.3 坑三:忘记处理“中介者不存在”的情况
同事对象在初始化时如果还没被注册到中介者,直接调用this.mediator.notify()就会报错。我在一个项目里就因为这个,在组件销毁重建的瞬间偶发报错,排查了半天。
解决办法很简单:同事对象里对 mediator 做空值判断,或者在 notify 里加一个“未注册则忽略”的保护。这种防御性代码看起来多余,但在组件生命周期复杂的场景里能救命。
5.4 坑四:中介者持有同事对象的强引用,导致内存泄漏
在单页应用里,如果中介者是全局单例,而同事对象是页面级组件,组件销毁时如果没从中介者里注销,中介者就会一直持有组件的引用,导致内存泄漏。
我的处理方式是给中介者加一个unregister(name)方法,在组件卸载时调用。或者用 WeakMap 来存同事对象,让垃圾回收机制自动处理。这个细节很容易被忽略,但在长期运行的应用里影响很大。
6. 中介者模式的适用边界与替代方案
6.1 什么场景下中介者模式收益最高
根据我的经验,下面这几类场景用中介者模式收益最明显:
- 复杂表单联动:一个表单里十几个字段互相影响,用中介者把联动规则集中管理,比散落各处的 watch 清晰得多。
- 多模块协同的看板/工作台:筛选区、图表区、列表区、详情区之间需要联动,中介者能有效降低耦合。
- 游戏或仿真里的对象协调:多个实体之间需要根据规则互相影响,中介者可以充当“规则引擎”的角色。
- 工作流引擎:多个步骤节点之间的流转条件,用中介者统一调度比每个节点自己判断下一步要清晰。
6.2 什么场景下不该用中介者
反过来,下面这些情况用中介者就是过度设计:
- 模块之间几乎没有联动,各自独立工作。
- 联动逻辑非常简单,一两个事件就能搞定,引入中介者反而增加理解成本。
- 团队对设计模式不熟悉,中介者会变成“只有写的人看得懂”的黑盒。
我个人的判断标准是:当你发现自己在多个文件里反复修改同一套联动逻辑时,就是引入中介者的时机。如果还没到那个程度,先用简单方案,别为了模式而模式。
6.3 中介者和其它模式的组合使用
中介者很少单独出现,它经常和其它模式搭配:
- 中介者 + 观察者:中介者内部用观察者机制来管理同事对象的订阅关系,既解耦又灵活。
- 中介者 + 状态机:用状态机管理中介者的协调逻辑,适合状态流转复杂的场景。
- 中介者 + 工厂:用工厂来创建和注册同事对象,中介者只负责协调,不负责创建。
这些组合没有固定公式,关键是理解每个模式解决的核心问题,然后按需组合。
7. 一个完整的可运行示例:筛选面板的中介者实现
光说理论不够,我给一个能直接跑的完整例子。场景是一个带搜索、分类筛选、排序、分页的列表页,所有联动通过中介者协调。
// 中介者 class ListPageMediator { constructor() { this.colleagues = {}; this.state = { keyword: '', category: 'all', sort: 'default', page: 1 }; } register(name, colleague) { this.colleagues[name] = colleague; colleague.mediator = this; } notify(sender, event, payload) { // 更新状态 if (event === 'keywordChange') { this.state.keyword = payload; this.state.page = 1; // 搜索变化重置页码 } if (event === 'categoryChange') { this.state.category = payload; this.state.page = 1; } if (event === 'sortChange') { this.state.sort = payload; this.state.page = 1; } if (event === 'pageChange') { this.state.page = payload; } // 统一触发列表刷新 this.colleagues.list.fetch({ ...this.state }); // 同步分页器状态 if (event !== 'pageChange') { this.colleagues.pagination.reset(); } } } // 同事对象:搜索框 class SearchBox { constructor(el) { this.el = el; el.addEventListener('input', (e) => { this.mediator.notify('search', 'keywordChange', e.target.value); }); } } // 同事对象:分类筛选 class CategoryFilter { constructor(el) { this.el = el; el.addEventListener('change', (e) => { this.mediator.notify('category', 'categoryChange', e.target.value); }); } } // 同事对象:列表 class ListView { async fetch(params) { this.showLoading(); const res = await api.fetchList(params); this.render(res.list); this.mediator.notify('list', 'loaded', { total: res.total }); } showLoading() { /* ... */ } render(list) { /* ... */ } } // 同事对象:分页器 class Pagination { reset() { this.currentPage = 1; this.render(); } updateTotal(total) { this.total = total; this.render(); } render() { /* ... */ } } // 组装 const mediator = new ListPageMediator(); mediator.register('search', new SearchBox(document.querySelector('#search'))); mediator.register('category', new CategoryFilter(document.querySelector('#category'))); mediator.register('list', new ListView()); mediator.register('pagination', new Pagination());这个例子里,每个同事对象都只跟中介者打交道,彼此之间零引用。你想加一个“排序”模块,只需要写一个 SortSelect 类,注册到中介者,然后在 notify 里加一条规则就行,完全不用动搜索、列表、分页的代码。
这就是中介者模式最实在的价值:让变化被隔离在一个地方。
8. 关于中介者模式,我最后想说的几点体会
写了几年代码,我对设计模式的态度从“背下来”变成了“用得上才用”。中介者模式是我用得比较多的一个,因为它解决的是真实存在的痛点——模块之间的联动逻辑散落各处,改起来提心吊胆。
但我也想说,中介者模式不是越早引入越好。项目初期模块少、联动简单的时候,直接写事件回调完全没问题,别为了“架构优雅”提前上模式。等到你发现自己第三次在同一个地方改联动逻辑时,再考虑引入中介者,这时候你对业务的理解也足够深了,设计出来的中介者才靠谱。
另外,中介者的拆分意识比中介者本身更重要。一个健康的中介者应该是几十行到一两百行的规模,超过这个量级就该考虑按业务域拆分了。我见过太多“万能中介者”最后变成没人敢动的祖传代码,那还不如一开始就别用。
最后一个实用建议:给中介者写单元测试。因为所有协调逻辑都集中在这里,测好中介者,基本就测好了整个联动逻辑。这比测一堆散落的事件回调要省事得多。