读完这篇你能得到:分得清观察者模式和发布订阅模式、能手写两套实现、知道各自该用在哪、以及避开几个高频坑。
两个模式经常被混着叫,代码写出来长得也像,但它们的耦合结构完全不同。下面按步骤拆。
第一步:先写观察者模式(Subject 直接持有 Observer)
核心特征:被观察者自己维护观察者列表,状态一变就挨个通知,观察者必须实现统一的 update 接口。
// 观察者:必须实现 update
class Observer {
constructor(name) { this.name = name }
update(data) { console.log(this.name, '收到:', data) }
}
// 被观察者:自己存观察者
class Subject {
constructor() { this.observers = [] }
add(ob) { this.observers.push(ob) }
remove(ob) { this.observers = this.observers.filter(o => o !== ob) }
notify(data) { this.observers.forEach(o => o.update(data)) }
}
const sub = new Subject()
const a = new Observer('A')
sub.add(a)
sub.notify('新消息') // A 收到:新消息
sub.remove(a)
关键点:Subject 里有一个 observers 数组,它认识自己的观察者;观察者也知道自己被谁通知。双方是直接引用关系,是紧耦合。Vue 2 的 Dep / Watcher、MutationObserver 都是这个结构。
第二步:再写发布订阅模式(中间加一个事件中心)
核心特征:发布者和订阅者互相不认识,都只跟中间的 EventBus 打交道。
class EventBus {
constructor() { this.events = new Map() }
on(type, fn) {
if (!this.events.has(type)) this.events.set(type, [])
this.events.get(type).push(fn)
return () => this.off(type, fn) // 返回取消订阅函数,省得记引用
}
once(type, fn) {
const wrapper = (...args) => { this.off(type, wrapper); fn(...args) }
wrapper.fn = fn // 便于用原函数取消
this.on(type, wrapper)
}
off(type, fn) {
const list = this.events.get(type)
if (!list) return
if (!fn) return this.events.delete(type) // 不传 fn 清空该事件
this.events.set(type, list.filter(f => f !== fn && f.fn !== fn))
}
emit(type, ...args) {
const list = this.events.get(type)
if (!list) return
;[...list].forEach(fn => { // 必须拷贝,见第四步
try { fn(...args) } catch (e) { console.error(`[${type}] 订阅者报错`, e) }
})
}
}
注意两处细节:on 返回取消函数、once 给 wrapper 挂 fn,都是为了解决「取消订阅传不回去原函数」的问题。emit 里的 try/catch 保证一个订阅者抛错不连累其他人。
第三步:看清区别,别只看代码像不像
| 维度 | 观察者模式 | 发布订阅模式 |
|---|
| 结构 | 目标 ↔ 观察者 直接相连 | 发布者 → 事件中心 → 订阅者 |
| 耦合 | 紧耦合,双方互相知道 | 松耦合,互相不认识 |
| 事件类型 | 通常只有一种统一通知 | 按事件名区分,可任意扩展 |
| 关系 | 一对一 / 一对多 | 多对多 |
| 典型场景 | Vue 2 响应式、MutationObserver | EventBus、Node 的 EventEmitter |
判断标准很简单:有没有第三方调度中心。有,就是发布订阅;没有、目标自己存监听器,就是观察者。DOM 的 addEventListener 因为元素自己持有监听器列表、没有独立中心,更接近观察者的一种变体。
第四步:这几个坑基本上人人都踩
注意:off 传匿名函数一定失效。bus.on('x', () => {}) 之后再 bus.off('x', () => {}) 是两个不同的函数引用。要么用具名函数,要么用 on 返回的取消函数。
注意:emit 遍历时不能直接遍历原数组。如果某个订阅者在回调里 on 了新订阅者或 off 了自己,原数组长度会变,导致漏发或重复发。上面代码用 [...list] 拷贝一份就是为这个。
注意:组件卸载时忘了 off,就是内存泄漏。EventBus 是全局单例,订阅者被长期持有,引用的 DOM、定时器全都释放不掉。在 beforeUnmount / useEffect 的 cleanup 里统一取消订阅。
注意:默认是同步执行。发布者 emit 会阻塞到所有订阅者跑完,一个死循环就卡住。要解耦执行时机,可以把 emit 里的调用包一层 queueMicrotask,或者业务上拆到下一帧。
第五步:选哪个
- 对象之间是明确的从属关系、通知只有寥寥几种——用观察者,实现小、链路短,比如表单字段变化通知校验器。
- 跨模块、跨组件、事件种类会不断新增——用发布订阅,谁都能发、谁都能听,加事件不用改任何已有代码。
一个折中的实际做法:内部小范围用观察者,对外暴露一层 on/emit 包装成事件中心,既保留内部性能,又拿到解耦。
小结
- 观察者是「目标自己存观察者列表」,紧耦合,通知靠统一的
update 接口。
- 发布订阅多一个事件中心,发布者与订阅者互不认识,按事件名分发,是多对多。
- 实现 EventBus 的三个必备能力:
on 返回取消函数、once 挂原始引用、emit 拷贝数组 + 异常隔离。
- 高频坑集中在
off 的引用匹配、遍历中增删订阅者、卸载时忘取消订阅。
- 选择标准就一条:有没有必要让双方互相认识。不需要,就加事件中心。