发布订阅和观察者模式,差一层但差很多

0 阅读1分钟

发布订阅和观察者模式,差一层但差很多

观察者模式跟发布订阅,经常被混着说。不是一回事。中间差了一个调度中心,这一层的有无,直接决定系统能不能往大了做。

到底差在哪

观察者模式,直连。Subject 认识 Observer,Observer 也知道自己挂在谁身上。没中间商。

发布订阅呢,中间塞了个事件总线。发布者不知道谁在听,订阅者也不知道谁在说。大家只跟总线打交道。

观察者是面对面喊话,发布订阅是往公告栏贴通知。

项目小的时候感受不明显。一旦模块多起来,这个区别是质变的。

观察者模式:最朴素的通知机制

手写一个,20 行搞定:

class Subject {
  private observers: Function[] = []

  subscribe(fn: Function) {
    this.observers.push(fn)
  }

  notify(data: any) {
    // Subject 直接持有 Observer 引用,耦合在这里
    this.observers.forEach(fn => fn(data))
  }
}

const subject = new Subject()
subject.subscribe((val) => console.log('A 收到:', val))
subject.subscribe((val) => console.log('B 收到:', val))
subject.notify('hello') // A 和 B 都会被调用

够直接,够简单。问题也明显——Subject 和 Observer 强绑定。你想加个过滤?加个延迟?全得改 Subject 的代码。

像快递员直接送到手里。效率高,但快递员得记住所有人地址。地址一多,扛不住。

发布订阅:中间加一层

Node.js 的 EventEmitter 就是典型的发布订阅实现:

class EventBus {
  private events: Map<string, Function[]> = new Map()

  on(event: string, fn: Function) {
    // 订阅者只认"事件名",不认发布者
    const fns = this.events.get(event) || []
    fns.push(fn)
    this.events.set(event, fns)
  }

  emit(event: string, ...args: any[]) {
    // 发布者也不知道谁在听
    const fns = this.events.get(event) || []
    fns.forEach(fn => fn(...args))
  }

  off(event: string, fn: Function) {
    const fns = this.events.get(event) || []
    this.events.set(event, fns.filter(f => f !== fn))
  }
}

const bus = new EventBus()

// 模块 A 只管发
bus.emit('user:login', { id: 1, name: 'test' })

// 模块 B 只管收,不知道谁发的
bus.on('user:login', (user) => {
  console.log('更新头像:', user.name)
})

// 模块 C 也收,跟 B 互不相识
bus.on('user:login', (user) => {
  console.log('拉取消息列表:', user.id)
})

发布者和订阅者彻底不认识了。模块 A 不知道 B 存在,B 也不知道 C 存在。大家都跟中心打交道,互相透明。

快递驿站模式。寄件人扔驿站,取件人去驿站拿。寄件人永远不用管谁取了件。

代码层面的真实差异

// ===== 观察者模式 =====
subject.subscribe(observer)  // observer 被 subject 直接引用
subject.notify(data)         // subject 遍历自己的 observer 列表

// ===== 发布订阅 =====
bus.on('event', handler)     // handler 注册在 bus 上,不在发布者身上
bus.emit('event', data)      // 发布者调 bus,bus 去通知

// 观察者 → subject.observers 里存着所有 observer(强引用)
// 发布订阅 → bus.events 按事件名分组存 handler(间接引用)

Vue 为啥弃了 EventBus

Vue 2 那会儿,new Vue() 当 EventBus 用的写法到处都是。好使是好使。项目一大就炸。

调试地狱。 一个 bus.emit('update') 丢出去,谁在听?全项目 grep 一下能找到十几个 bus.on('update'),散在各种文件里。数据流向完全没法追。

内存泄漏。 组件销毁了,忘了 off,handler 还在 bus 上挂着。这 bug 不会马上炸,慢慢吃内存,等你发现已经晚了。

命名撞车。 两个模块都用了 data:update?恭喜,喜提一个极难排查的 bug。

Vue 3 直接把实例上的 $on$off$emit 砍了。不是这东西技术上不行,是工程上管不住。

RxJS Subject:观察者模式的加强版

RxJS 的 Subject 骨子里还是观察者模式,但套了一整套流处理能力。

import { Subject } from 'rxjs'
import { filter, debounceTime, map } from 'rxjs/operators'

const click$ = new Subject<MouseEvent>()

// Subject 既是 Observable(能被订阅),也是 Observer(能接收值)
// 跟普通 EventEmitter 的本质区别就在这

click$.pipe(
  debounceTime(300),                          // 防抖 300ms
  filter(e => e.target instanceof HTMLButton), // 只要按钮点击
  map(e => ({ x: e.clientX, y: e.clientY }))  // 只取坐标
).subscribe(pos => {
  console.log('处理点击:', pos)
})

document.addEventListener('click', e => click$.next(e))

看出来了吗?EventEmitter 只能"收到就执行"。Subject 可以在"收到"和"执行"之间塞任意多处理步骤。过滤、映射、合并、节流——全在管道里搞。

从"事件通知"到"事件流",是个质的变化。

Subject 的几个变体

别搞混了。

import { Subject, BehaviorSubject, ReplaySubject, AsyncSubject } from 'rxjs'

// Subject:最基础,订阅之后才能收到值
const s1 = new Subject<number>()
s1.next(1)              // 没人听,丢了
s1.subscribe(v => {})   // 从现在起才能收到

// BehaviorSubject:有"当前值",新订阅者马上拿到最新的
const s2 = new BehaviorSubject<number>(0) // 必须给初始值
s2.next(1)
s2.next(2)
s2.subscribe(v => console.log(v)) // → 立刻输出 2

// ReplaySubject:回放最近 N 个值
const s3 = new ReplaySubject<number>(3) // 缓存最近 3 个
s3.next(1)
s3.next(2)
s3.next(3)
s3.next(4)
s3.subscribe(v => console.log(v)) // → 输出 2, 3, 4

// AsyncSubject:只在 complete() 时吐出最后一个值
// 适合只关心最终结果的场景,比如 HTTP 请求
const s4 = new AsyncSubject<number>()
s4.next(1)
s4.next(2)
s4.complete()
s4.subscribe(v => console.log(v)) // → 只输出 2

BehaviorSubject 在状态管理里用得最多。天然就是个可观察的状态容器。

分层来看:三层事件架构

真实项目里不是选一个模式就完了。得分层。

┌─────────────────────────────────────────┐
│  组件层:观察者模式(直连,局部通信)       │
│  → props/emit、ref 回调、Vue watch        │
├─────────────────────────────────────────┤
│  模块层:发布订阅(解耦,跨模块通信)       │
│  → EventEmitter、mitt、tiny-emitter       │
├─────────────────────────────────────────┤
│  系统层:响应式流(可编排,全局状态流)      │
│  → RxJS Subject、Redux Observable         │
└─────────────────────────────────────────┘

组件层用观察者。 父子组件之间 props 往下传、emit 往上抛。直连高效,数据流清楚。父子关系本身就是强耦合的,不需要解耦。

模块层用发布订阅。 购物车和用户模块要通信?别互相 import。搞个 EventBus,各管各的。

系统层用响应式流。 WebSocket 消息进来要做鉴权、数据转换、错误重试、多播分发——这种复杂异步编排,只有流式处理扛得住。

import { webSocket } from 'rxjs/webSocket'
import { retry, share, filter, map } from 'rxjs/operators'

const ws$ = webSocket<any>('wss://api.example.com').pipe(
  retry({ count: 3, delay: 1000 }),  // 断线重连 3 次
  share()                             // 多播,多个订阅者共享一个连接
)

// 订单模块只管订单消息
const order$ = ws$.pipe(
  filter(msg => msg.type === 'order'),
  map(msg => msg.payload)
)

// 通知模块只管通知消息
const notification$ = ws$.pipe(
  filter(msg => msg.type === 'notification'),
  map(msg => msg.payload)
)

order$.subscribe(handleOrder)
notification$.subscribe(showNotification)

选哪个

维度观察者模式发布订阅RxJS Subject
耦合度高(直连)低(经过中心)中(管道连接)
调试难度高(事件追踪难)中(有 operator 栈)
学习成本几乎没有高(operator 上百个)
异步编排不行不行
内存管理手动手动且容易漏unsubscribe 机制完善
适合规模中到大

没有银弹。

小项目上 RxJS——杀鸡用牛刀。大项目只用 EventEmitter——给自己挖坑。

判断标准其实很简单:你的事件需不需要"处理"? 只是"A 发生了,通知 B",EventEmitter 够了。"A 发生了,等 300ms 看有没有 B,合并过滤一下再通知 C"——那得上 RxJS。

内存泄漏:公共敌人

不管用哪个模式,忘了取消订阅就是泄漏。这个没得商量。

// ❌ 经典泄漏
useEffect(() => {
  bus.on('update', handleUpdate)
  // 组件卸载了,handler 还挂着
  // bus 不会被 GC,handler 引用的闭包变量也不会被 GC
}, [])

// ✅ 必须清理
useEffect(() => {
  bus.on('update', handleUpdate)
  return () => bus.off('update', handleUpdate)
}, [])

// ✅ RxJS 更干净
useEffect(() => {
  const sub = source$.subscribe(handleUpdate)
  return () => sub.unsubscribe()
}, [])

RxJS 这点做得好一些。Subscription 可以 add 子订阅,一次 unsubscribe 全清掉。EventEmitter 的 off 需要传同一个函数引用,匿名函数根本没法 off。坑不少。

什么时候别用事件驱动

事件驱动不是万能药。

需要同步拿返回值。 emit 是单向的,const result = emit('calculate') 这种写不了。非要搞可以塞回调,但那跟直接调函数有啥区别。

强顺序依赖。 事件天生松散,监听器执行顺序不该被依赖。如果你发现自己在纠结"handler A 必须在 B 之前跑"——别用事件了,老实写调用链。

高频数据同步。 每秒几千次?事件系统的开销——创建事件对象、遍历监听器——会变成瓶颈。这种场景直接上 SharedArrayBuffer。

解耦是个光谱

直接调用 ←→ 观察者 ←→ 发布订阅 ←→ 消息队列
耦合度递减 ────────────────────────→
可追踪性递减 ──────────────────────→
灵活性递增 ────────────────────────→

往左走,代码好追踪但改起来牵一发动全身。往右走,模块独立但调试时想摔键盘。

5 人团队的中后台项目,mitt 糊个轻量 EventBus 就够了。50 人团队搞实时协作,不上 RxJS 级别的流处理基本是在跟自己过不去。

选型就看一件事:你团队的复杂度天花板在哪。别提前优化,也别等扛不住了才重构——那时候成本翻十倍。