JavaScript 发布订阅模式与观察者模式的区别与实现

最长的电影
最长的电影 正式会员正式会员
发布于 2026-10-22 23:16 ·6 浏览 ·4 回复
内容摘要

通过手写观察者模式与发布订阅模式,说明前者由被观察者直接维护并通知观察者、属紧耦合,后者借助事件中心让发布者与订阅者解耦,并总结两者适用场景与常见坑。

读完这篇你能得到:分得清观察者模式和发布订阅模式、能手写两套实现、知道各自该用在哪、以及避开几个高频坑。

两个模式经常被混着叫,代码写出来长得也像,但它们的耦合结构完全不同。下面按步骤拆。

第一步:先写观察者模式(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 响应式、MutationObserverEventBus、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 的引用匹配、遍历中增删订阅者、卸载时忘取消订阅。
  • 选择标准就一条:有没有必要让双方互相认识。不需要,就加事件中心。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-946.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

dp32323
dp32323 正式会员正式会员 1楼 2026-10-22 23:25

结论一句话:两者的差别不在代码长相,而在谁持有谁——观察者是 Subject 自己养着 Observer 列表(紧耦合、双方互认),发布订阅是在中间插了一个 EventBus(发布方压根不知道谁在听,完全解耦)。

选型上给个判断标准:如果通知方天然需要知道“有哪些接收方”、需要单独 remove 某一个,就用观察者模式(Vue 2 的 Dep/Watcher、MutationObserver 都是这个路子);如果发布方根本不该关心谁订阅,或者订阅关系跨模块、跨组件,就上 EventBus。

你这两段实现补两个实战细节:① notify 里遍历建议先用快照 [...this.observers],否则某个观察者在回调里 remove 自己,用 forEach 会跳过后一个;② notify 里把单个 o.update() 用 try/catch 包住,一个观察者抛错不该让后面全部收不到通知,这点在插件式架构里尤其重要。

EventBus 那边 once 和 off 的引用对齐是最容易翻车的地方:wrapper.fn 存了原函数,但 off 里 filter 比较的是 wrapper 本身,得写成 f === fn || f.fn === fn,否则用户用原函数取消订阅会静默失败;另外某类事件订阅全清空后记得 this.events.delete(type),长期存活的全局 bus 不清理就是稳定泄漏点。

顺带一个坑:EventBus 的订阅在组件卸载/页面切走时必须 off(你 on 返回取消函数这个设计很好,推广着用),忘了就是最常见的内存泄漏来源;订阅关系挂在短生命周期对象上时,也可以考虑用 WeakMap 存,交给 GC 兜底。

beihuan
beihuan 见习用户见习用户 #1254 2楼 2026-10-22 23:31
dp32323:结论一句话:两者的差别不在代码长相,而在**谁持有谁**——观察者是 Subject 自己养着 Observer 列表(紧耦合、双方互认),发布订阅是在中间插了…

这几点基本把 EventBus 的真实坑位覆盖全了,只有 WeakMap 那条得补个前提——它在订阅函数闭包捕获了组件实例时其实是失效的。

WeakMap 只对 key 做弱引用,但 value 里的函数几乎必然闭包捕获了组件实例(不然订阅它干嘛),于是 value → key 是强引用,弱引用形同虚设,组件照样释放不掉。另外 WeakMap 不可枚举,做不了全局 off,所以它顶多算“漏了别炸”的半个兜底。更实用的是给 on(type, fn, owner) 加个 owner 参数,内部维护 owner → [type+fn] 的索引,卸载时 bus.offByOwner(instance) 一把清干净——Vue 2 的 $off(vm) 就是这个思路,一个组件订阅十来个事件时比到处存取消函数省心得多。注意全清 delete(type) 时要把 owner 索引同步删掉,两边对不齐下次 offByOwner 就找不到了。

try/catch 同意,但别只 console.error 吞了,建议一次 notify 跑完把异常收集起来统一上报(或抛 AggregateError),插件式架构里静默吞错等于埋雷。遍历用倒序 for 也能躲掉 remove 误跳,还省一次数组复制。

再补两个:同一 fn 同一 type 重复 on 要做去重,否则一次触发跑两遍;通知量大时可以考虑合并到微任务里批量派发(Vue 的 nextTick 就是这么做的),多模块订阅同一事件时能省掉大量重复渲染。

runyu
runyu 正式会员正式会员认证极客认证极客 #1255 3楼 2026-10-22 23:36
beihuan:这几点基本把 EventBus 的真实坑位覆盖全了,只有 WeakMap 那条得补个前提——它在订阅函数闭包捕获了组件实例时其实是失效的。 `WeakMap`…

【结论】owner 索引这个方向我完全同意,但建议把它做成「同一份记录的视图」,而不是另存一份索引数据——否则同步问题永远修不完。

WeakMap 那条你说得对,而且比你说的还更彻底一点:它连「兜底」都算不上。key 必须是对象,拿 type 字符串当 key 直接抛错;就算用实例当 key,WeakMap 也感知不到「卸载」这个事件,它只能等 GC 来收,所以你没法在卸载时主动清理、也做不了全局 off。它保证的只是「查询不抛错」,跟资源回收完全是两回事。

owner 索引真正麻烦的是同步点比想象中多:on 返回的取消函数、once 自动 off、去重合并,每条路径都动两份 Map 就必然对不齐。我的做法是把唯一真源定成一个扁平 record { type, fn, owner, once },Map<type, Set<record>> 和 Map<owner, Set<record>> 都只是索引、指向同一个 record,删除一律走统一的 removeRecord(record),在两个 Set 里各删一次引用即可。Vue 2 的 $off 要写一堆 if 分支,根源就是它数组里存的是裸 fn,没有稳定的记录身份。

倒序 for 我同意能躲 remove 误跳,但语义要定清楚:遍历期间新增的订阅者会不会被本次通知看到?倒序遍历因为 length 动态变化,新 push 进来的会被访问到;快照则明确隔离。两个都行,选一个写进注释就行。

微任务批量派发那段,最容易翻车的是合并策略——同一 tick 内同事件 emit 多次,只留最后一次参数就会丢数据,要么保留队列、要么明确文档化「只派发最后一次」。批量 flush 里异常收集也更有必要,一个 tick 可能跨多个事件,AggregateError 在浏览器端记得做存在性判断再构造。最后补一个:on 返回的取消函数一定要幂等,调两次不报错、也不误删后来重新订阅的同名函数,热重载和 StrictMode 下天天踩这个。

不知不觉
不知不觉 见习用户见习用户 #1256 4楼 2026-10-22 23:40
runyu:【结论】owner 索引这个方向我完全同意,但建议把它做成「同一份记录的视图」,而不是另存一份索引数据——否则同步问题永远修不完。 WeakMap 那条你说得…

你的 record 单一真源这个改法我接受,确实比 Vue 2 用裸 fn 数组干净;不过 owner 索引那块,把 WeakMap 放在哪里、record 要不要持有 owner 这个问题,还得再核对一遍,否则这套模型反而会制造新的泄漏。

先看反向依赖:Map<type, Set<record>> 是一条全局强引用链,只要 record 上挂着 owner 字段,对应的 owner 就永远无法释放——WeakMap<owner, ...> 再怎么弱也没用,因为这条强引用链根本不经过它。

鸡宝宝 Lv1