观察者模式与发布订阅
这是前端使用频率最高的模式:DOM 事件、Vue 响应式、Redux 订阅、EventBus、Node 的 EventEmitter、RxJS,本质上都是它。
一、观察者 vs 发布订阅
两者常被混为一谈,核心差别在于有没有中间人:
观察者模式: Subject ─────直接持有────→ Observer
发布订阅模式: Publisher ──→ Event Channel ──→ Subscriber
(两端互不知道对方)
| 观察者模式 | 发布订阅模式 | |
|---|---|---|
| 双方是否知道对方 | Subject 持有 Observer 列表,知道通知谁 | 发布者和订阅者互相不知道,只认识事件中心 |
| 耦合度 | 松耦合,但仍有直接引用 | 完全解耦 |
| 调度方式 | 通常同步:触发即调用 | 常可异步(消息队列、微任务) |
| 典型实现 | Vue 的 Dep / Watcher、MutationObserver | EventEmitter、EventBus、Redis Pub/Sub |
严格来说发布订阅是观察者模式的变体,多引入了一层事件中心。日常交流中不必过分纠结,但面试问到时要能说清"有没有中间调度层"这个差别。
二、观察者模式实现
class Subject {
constructor() {
this.observers = new Set(); // 用 Set 天然去重,避免重复订阅
}
addObserver(observer) {
this.observers.add(observer);
return () => this.removeObserver(observer); // 返回取消函数,比记名字更可靠
}
removeObserver(observer) {
this.observers.delete(observer);
}
notify(message) {
// 遍历副本,防止回调里增删观察者导致遍历行为异常
[...this.observers].forEach(observer => {
try {
observer.update(message);
} catch (err) {
console.error('observer 执行出错:', err); // 一个观察者出错不应中断其他观察者
}
});
}
}
class Observer {
constructor(name) { this.name = name; }
update(message) { console.log(`${this.name} 收到:`, message); }
}
const subject = new Subject();
const a = new Observer('A');
const unsubscribe = subject.addObserver(a);
subject.addObserver(new Observer('B'));
subject.notify('hello'); // A 收到: hello / B 收到: hello
unsubscribe();
subject.notify('again'); // 只有 B 收到
用数组 + findIndex 移除时的经典 bug
removeObserver(observer) {
const index = this.observerList.findIndex(o => o.name === observer.name);
this.observerList.splice(index, 1); // ❌
}
findIndex 找不到时返回 -1,而 splice(-1, 1) 会删掉数组的最后一个元素——移除一个不存在的观察者,反而误删了别人。必须先判断:
if (index !== -1) this.observerList.splice(index, 1);
另外用 o.name === observer.name 做比较,会让同名的不同观察者互相误删,应该比较对象引用本身。
三、发布订阅(EventEmitter)实现
class EventEmitter {
constructor() {
this.events = new Map(); // eventName -> Set<handler>
}
on(event, handler) {
if (!this.events.has(event)) this.events.set(event, new Set());
this.events.get(event).add(handler);
return this;
}
once(event, handler) {
const wrapper = (...args) => {
this.off(event, wrapper); // 先解绑,避免 handler 内部再次 emit 造成重入
handler.apply(this, args);
};
wrapper.raw = handler; // 记住原函数,才能被 off(event, handler) 正确移除
return this.on(event, wrapper);
}
off(event, handler) {
const handlers = this.events.get(event);
if (!handlers) return this;
if (!handler) { // 不传 handler 时移除该事件的全部监听
this.events.delete(event);
return this;
}
for (const h of handlers) {
if (h === handler || h.raw === handler) handlers.delete(h);
}
if (handlers.size === 0) this.events.delete(event);
return this;
}
emit(event, ...args) {
const handlers = this.events.get(event);
if (!handlers || handlers.size === 0) return false;
[...handlers].forEach(h => h.apply(this, args)); // 遍历副本,兼容 once 的自我移除
return true;
}
}
用法:
const bus = new EventEmitter();
bus.on('login', user => console.log('欢迎', user.name));
bus.once('init', () => console.log('只执行一次'));
bus.emit('login', { name: 'Tom' });
bus.emit('init');
bus.emit('init'); // 不再触发
三个必须处理的工程问题
- 内存泄漏:
on了就必须在组件卸载时off。EventBus 是前端内存泄漏的头号来源——被订阅的闭包持有组件引用,导致整个组件树无法回收。 - 错误隔离:一个 handler 抛错会中断后续 handler,生产实现要
try/catch。 - 事件名硬编码:字符串事件名无法被 IDE 追踪、拼错了也不报错。用常量或 TS 的事件映射类型约束:
type Events = { login: [user: User]; logout: [] }; class TypedEmitter<E extends Record<string, unknown[]>> { on<K extends keyof E>(event: K, handler: (...args: E[K]) => void) { /* ... */ } }
Vue 3 移除了 $on / $off / $emit(实例事件)
理由正是上面这些问题:EventBus 让数据流向变得不可追踪——出了问题不知道是谁发的、谁在听。现在推荐用 props/emit、provide/inject 或状态库(Pinia)来传递数据,只在真正需要跨越层级的全局通知时才用事件总线(如 mitt)。
四、在框架中的样子
Vue 2 响应式是标准的观察者模式:
数据 getter → Dep.depend() 收集当前的 Watcher(依赖收集)
数据 setter → Dep.notify() 通知所有 Watcher 更新(派发更新)
Dep 是 Subject,Watcher 是 Observer。Vue 3 换成 Proxy + effect,思路完全一致,只是收集依赖的方式从 defineProperty 变成了 Proxy 的 trap(见代理模式)。
Redux 的 store.subscribe、MobX 的 autorun、RxJS 的 Observable.subscribe 也都是同一个模式。RxJS 在此之上加了操作符(map / filter / debounceTime),把事件当成"随时间变化的流"来处理。
五、与中介者模式的关系
发布订阅的"事件中心"和中介者很像,区别在于:
- 事件中心是"哑"的:它只负责转发,不知道业务含义;
- 中介者是"聪明"的:它封装了对象之间的协作逻辑(A 变了以后 B 该怎么办,由中介者决定)。
下一步 👉 结构型模式
