跳到主要内容

观察者模式

观察者模式让对象登记自己关心的变化,由被观察的对象在变化发生时通知它们。这样,产生变化的代码不必写死每一个后续处理者。例如购物车数量变化后,角标、价格摘要和埋点可以分别响应,而不用都塞进修改数量的方法。

真正需要设计的是通知契约:通知是同步还是异步,重复订阅怎样处理,监听器能否在执行中取消其他监听器,以及某个回调失败后剩余回调怎么办。只写出一个回调数组,还不足以说明这个机制在项目中怎样可靠使用。

1. 面试核心​

先记住这几条,再读正文
  • 观察者模式建立一处变化、多方响应的关系,变化来源通过约定的回调或接口通知观察者,不必了解每个观察者的业务实现。卡 · 变化来源与订阅者
  • 发布订阅通常在发布者与订阅者之间加入事件总线或其他中介,减少双方直接认识彼此的需要,但仍共享事件名称、载荷与时序契约。它不天然等于异步或跨进程。卡 · 变化来源与订阅者
  • EventBus 的实现要先约定重复、顺序和取消语义。订阅记录的身份不一定等于回调函数的身份,同一个函数可以代表两次独立订阅。卡 · EventBus 的通知契约
  • 通知时复制监听列表,只解决遍历集合被修改的一部分问题。新增、删除和重入是否影响当前通知,仍需明确规则;一次性监听应在调用回调前失效。卡 · 通知期间修改订阅
  • 同步通知不等于异步任务已经完成。同步异常、Promise 拒绝和业务回滚是不同的问题,错误处理不能含糊。卡 · 错误、异步与生命周期
  • 订阅的生命周期应跟随使用者。页面移除不代表长生命周期的总线会自动释放回调;全局总线还会增加追踪事件来源的成本。卡 · 错误、异步与生命周期
  • 同步多个界面时,应区分修改命令与状态通知。收到状态后再次无条件广播,可能形成循环;改为异步并不能自动消除它。卡 · 综合题

2. 变化来源与订阅者​

被观察对象(Subject)保存状态或产生事件,并管理通知。观察者(Observer)登记一个处理入口,在约定变化发生后接收通知。这里的“观察”通常不是不断轮询对象,而是由变化来源主动调用观察者。

假设购物车对象提供订阅入口。角标组件订阅数量变化,修改购物车时先更新业务状态,再通知订阅者。之后增加价格摘要组件,只需让它订阅,不必在购物车中增加“调用价格摘要组件”的专属分支。

这个分离有一个前提:事件含义要明确。“数量已改变”是事实通知,“请检查库存并决定能否修改”则包含请求与结果。如果后者必须得到一个明确答案,直接调用业务接口通常更清楚。不能把需要顺序、返回值和失败处理的一整段业务流程,全都藏在几次事件广播后面。

通知可以直接携带所需数据,也可以只通知“发生变化”,让观察者再读取当前状态。前者便于表达这次变化的快照,后者减少重复携带数据,但读取时可能已出现更新。选择哪一种,要看订阅者需要“这次事件的数据”还是“此刻的最新状态”。

3. 观察者与发布订阅​

发布订阅(Publish–Subscribe)通过中介连接事件产生者和接收者。事件总线(EventBus)是其中一种实现:发布者按事件名称发布,订阅者按名称登记,总线负责查找并调用相应处理者。

关系直接观察对象通过事件总线发布订阅
订阅入口某个具体被观察对象总线上的事件名称或主题
谁保存订阅关系被观察对象或其通知组件事件总线
发布者需要知道什么通知接口及已登记的观察者总线接口和事件契约
接收方需要知道什么订阅哪个对象、接收什么通知订阅哪个总线和事件、怎样理解载荷

两种说法在资料和框架中可能有重叠。解释时把参与者和依赖关系画清楚,比争论某个库究竟只能叫什么更有价值。加入中介后,事件名称和数据格式仍是双方的共同约定,不能说“完全没有耦合”。

进程内的 EventBus 可以同步调用函数,不需要消息服务器。分布式发布订阅还要解决传输、持久化、重试和顺序等问题;不能因为本地总线有 emit,就宣称它具备离线投递或消息可靠性。

是否只有一个总线,是另一个问题。事件总线可以按页面、功能或请求创建多个实例;单例管理的是实例获取范围,见单例模式。把所有事件放入全局单例,会扩大共享范围,并不自动使通信关系更清楚。

4. EventBus 的通知契约​

实现之前先确定可观察行为,再选择集合结构。 本文实现一个同步、进程内的 EventBus,约定如下:

问题本文的选择
事件名称字符串或 Symbol
重复注册同一个函数每次注册都是独立订阅,分别调用、分别取消
调用顺序同一轮按订阅顺序同步调用
取消订阅返回的取消函数只移除这次订阅,重复调用无副作用
通知期间新增订阅不进入当前轮,下一次 emit 才能看到
通知期间取消尚未调用的订阅当前轮也跳过该订阅
一次性订阅调用回调前移除,防止重入再次调用
回调同步抛错继续调用其他有效订阅,最后抛出 AggregateError
回调返回值忽略,包括 Promise;emit 不等待异步任务

这些不是所有事件库的统一规则。Node.js EventEmitter 允许重复添加同一函数,但通知开始后移除的监听器仍可能在当前轮执行;DOM 的事件监听有自己的去重和移除规则。使用现成库时,应以它的契约为准。

4.1 实现​

下面使用现代 JavaScript 的私有字段与 AggregateError。每次订阅保存一个独立记录,因此即使回调函数相同,也能区分两次注册:

export class EventBus {
#events = new Map();

#subscribe(type, listener, once) {
if (typeof type !== 'string' && typeof type !== 'symbol') {
throw new TypeError('事件名称必须是字符串或 Symbol');
}
if (typeof listener !== 'function') {
throw new TypeError('监听器必须是函数');
}
let records = this.#events.get(type);
if (!records) {
records = new Set();
this.#events.set(type, records);
}
const record = { listener, once, active: true, off: null };
const off = () => {
if (!record.active) return;
record.active = false;
record.listener = null;
records.delete(record);
if (records.size === 0) this.#events.delete(type);
};
record.off = off;
records.add(record);
return off;
}

on(type, listener) {
return this.#subscribe(type, listener, false);
}

once(type, listener) {
return this.#subscribe(type, listener, true);
}

emit(type, ...args) {
if (typeof type !== 'string' && typeof type !== 'symbol') {
throw new TypeError('事件名称必须是字符串或 Symbol');
}
const snapshot = [...(this.#events.get(type) ?? [])];
const errors = [];
let called = 0;
for (const record of snapshot) {
if (!record.active) continue;
const listener = record.listener;
if (record.once) record.off();
called++;
try {
listener(...args);
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, '事件监听器执行失败');
}
return called;
}
}

没有对应订阅时,emit 返回 0;正常完成时,返回这一轮实际调用的订阅数量。回调通过普通函数调用接收参数,不会自动绑定到总线或某个业务对象。需要对象方法中的 this 时,订阅者应自行绑定,或用闭包调用对应方法。

下面沿用购物车事件,展示重复注册与精确取消:

const bus = new EventBus();
const log = [];
const showCount = count => log.push(count);
const stopFirst = bus.on('cart:changed', showCount);
const stopSecond = bus.on('cart:changed', showCount);

bus.emit('cart:changed', 2);
stopFirst();
stopFirst();
bus.emit('cart:changed', 3);
stopSecond();
console.log(log); // [2, 2, 3]
console.log(bus.emit('cart:changed', 4)); // 0

第一次通知有两条订阅,所以记录两个 2;取消第一条后只剩第二条,记录一个 3。返回取消函数,可以避免调用者为了取消某次注册而重新查找回调,也避免一个组件取消另一个组件使用同一函数建立的订阅。

4.2 通知期间修改订阅​

快照固定当前轮的候选集合,active 决定候选是否仍有效。 只复制列表而不检查有效状态,会使已取消的记录仍被调用;直接遍历可修改的集合,则可能让新加入的记录意外参与本轮。

例如 A 先于 B 订阅。A 的回调取消 B,再订阅 C:本轮只执行 A,B 被跳过,C 不在本轮快照中。下一轮才有机会执行 C。这是本文选择的语义,不能直接套到 Node.js EventEmitter 上。

如果 B 的回调已经开始,之后再取消 B,不会把正在运行的 JavaScript 或已发出的请求倒回去。取消只改变后续是否继续通知;取消已启动任务需要该任务自己的协议。

4.3 重入与一次性订阅​

重入是在处理当前通知的过程中,又触发一次通知。本文的 emit 是同步的,回调中调用 emit 会立即进入内层通知,处理完成后才回到外层。

如果一次性监听在执行之后才移除,它在回调里再次触发同一事件时,就可能被第二次调用。因此实现先保存 listener,再执行 record.off(),最后调用保存的函数。即使内层再次通知,或外层快照还保存这条记录,也会因为它已失效而跳过。

“当前轮不加入新订阅”也有范围:如果 A 新增 C 后立即重入 emit,内层是一次新的通知,会取得新的快照,因此可以看到 C。外层依然使用原来的候选集合。

持续互相触发的回调可能造成无限递归、栈溢出或重复业务操作。把通知改成微任务或队列,只是改变排队方式,不会自动消除循环。应先检查状态是否真正改变、事件来自哪里,以及是否把接收通知错误地当成了再次发布命令。

5. 错误、异步与生命周期​

错误策略决定失败怎样影响其他订阅者。 本文选择收集同步错误、继续通知,最后用 AggregateError.errors 交给 emit 调用方处理。另一种实现可以在第一个错误时立即停止,但两者会得到不同的业务结果,不能默默混用。

继续通知不意味着事务成功。A 已经修改某个对象,B 随后抛错,EventBus 不会撤销 A 的修改。调用方看到错误后直接重新 emit,还可能重复执行已经成功的副作用。需要原子业务规则时,应在业务操作中完成校验与提交,再把已发生的事实通知出去。

5.1 异步回调​

同步 emit 只负责调用回调,不会等待回调内部的异步任务。 async 函数会返回 Promise;即使在第一次 await 前抛错,它也体现为 Promise 拒绝,不能指望外层同步 try/catch 按普通异常收集。

在本文的契约下,订阅者需要自行处理异步失败,例如在回调里启动任务,并为该 Promise 添加错误处理。否则即使 emit 正常返回,也可能随后出现未处理的拒绝。若业务要等待全部订阅者,应另外设计异步通知接口,并说明并行还是串行、一个失败是否阻止其他任务、超时和取消怎样处理。

同步调用还意味着慢回调会阻塞发布者。把重任务移到异步阶段可以缩短同步占用,但会引入队列积压、并发和顺序问题。事件总线本身不会提供无限处理能力。

5.2 取消与资源释放​

谁建立订阅,谁应把取消函数交给明确的生命周期管理。 组件卸载时调用它,可以解除总线中的登记;若总线比组件活得久,而回调闭包引用组件状态或 DOM,忘记取消就可能使这些对象继续可达。

页面上的 DOM 被移除,不表示总线保存的闭包已经消失;同样,形成循环引用也不必然泄漏,关键是这些对象是否仍从存活的对象可达。垃圾回收的判断见 JavaScript 内存管理。

事件载荷也有所有权问题。本文不会复制对象载荷,各回调接收到的引用仍指向同一个对象;一个订阅者修改对象,后面的订阅者会看到修改。需要稳定事件快照时,应约定载荷不可变,或在发布前构造适合共享的数据。总线不会免费深拷贝所有参数,也不能用浅冻结保证嵌套对象完全不可变。

6. 开关同步与循环通知​

同步两个显示位置时,最好先确定谁拥有状态,再区分修改请求与状态通知。 假设两个房间的开关界面都显示同一盏灯的状态。若 A 收到 B 的变化后再次广播,B 又把收到的广播当成新操作,就可能持续来回通知。

一种进程内设计是由一个状态对象保存灯的状态。用户操作发出“设置为开”或“设置为关”的请求,状态对象检查新旧值,只在值变化时修改并通知。两个界面收到状态通知后只更新显示,不再次发布用户命令。

这同时明确了幂等边界:连续两次“设为开”与连续两次“切换”不同,后者可能把状态又切回去。事件名称和载荷应表达真实意图,而不是让接收方猜测。

如果两个开关是跨设备的真实硬件,还要考虑消息丢失、重复、乱序、设备离线和哪个状态源更可信。单播可以减少不必要的广播范围,但不能单独解决这些一致性问题。本地 EventBus 只提供通知关系,不能作为已验证的硬件同步协议。

7 · 面试题卡片​

本页题目与参考回答

7.1 发布订阅模式的核心逻辑是什么?​

考察重点:通知关系、中介和仍然存在的事件契约。

相关内容:变化来源与订阅者、观察者与发布订阅。

参考回答

发布订阅通过事件总线等中介保存订阅关系,按事件契约把变化通知给感兴趣的接收者,发布者不必直接管理每个接收者。 直接观察一个对象时,订阅者向该对象登记;使用总线时,双方按事件名称和载荷契约通信。

中介减少了直接依赖,但事件名、数据格式和通知时序仍需要共同约定。进程内发布订阅可以同步执行,不天然具备跨进程传输、离线保存或重试能力。术语在不同资料中存在重叠,应以具体参与者和数据流解释。

7.2 如何实现一个支持订阅、取消和一次性监听的 EventBus?​

考察重点:事件索引、订阅身份、遍历规则与一次性监听的失效时机。

相关内容:EventBus 的通知契约、实现。

参考回答

用事件名称索引订阅集合,每次注册保存独立记录,并返回只取消该记录的函数。 本文用 Map 和 Set 保存记录,同一回调可以独立注册多次;取消后标记失效并从集合移除,空集合从 Map 删除。

通知时取得快照,再逐条检查有效状态;这样新增订阅不进入本轮,尚未调用但已取消的记录会被跳过。once 在调用回调前移除,避免重入重复执行。同步异常和异步返回值的处理要另行说明,不能仅有 on、emit 两个方法就认为所有边界已经确定。

7.3 通知过程中增删监听器,或者再次触发通知,会有什么影响?​

考察重点:快照、有效标记、内外两轮通知和重入顺序。

相关内容:通知期间修改订阅、重入与一次性订阅。

参考回答

结果由具体契约决定,不能只凭“用了数组或 Set”判断。 本文快照固定当前轮候选,新增订阅下一轮可见;有效标记让已取消但尚未调用的记录在本轮也被跳过。取消不终止已经运行的回调。

重入会同步开启新一轮通知,取得新的快照,所以能看到外层刚新增的订阅;内层结束后才回到外层。一次性监听要先失效再调用,才能防止重入时被再次通知。

7.4 EventBus 的实现有哪些错误处理和资源风险?​

考察重点:同步与异步失败、部分副作用、订阅生命周期。

相关内容:错误、异步与生命周期。

参考回答

先说明一个回调失败后是否继续通知,再说明异步任务与清理规则。 本文收集同步错误后抛出 AggregateError,但不会撤销已完成的副作用;盲目重试整次通知可能重复执行。

Promise 拒绝不等于同步抛错,emit 也不等待异步任务,订阅者必须自行处理失败,或使用另行约定的异步通知接口。长生命周期总线会保留订阅,组件退出时应取消。还要检查循环通知、共享可变载荷和慢回调造成的阻塞。

7.5 怎样让两个房间的开关同步状态,并避免循环广播?​

题型:综合题

考察重点:状态所有者、命令与通知、循环消除和跨设备边界。

相关内容:变化来源与订阅者、开关同步与循环通知。

参考回答

先区分用户修改命令和状态变化通知,并明确状态由谁维护。 进程内可以由一个状态对象接收设置请求,仅在值发生变化时更新并通知;两个显示位置收到通知只更新界面,不再把它转成新的用户操作。

“设置为开”重复执行仍然是开,“切换”重复执行会改变结果,因此还要明确操作语义。减少广播范围只能减少传播,不能替代去重或状态规则。真实跨设备场景另需处理丢失、重复、乱序和离线恢复,本地 EventBus 不提供这些保证。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明