文章

手写发布订阅与观察者模式深度解析:EventEmitter 的 5 个暗坑

发布订阅经中心化事件通道让双方解耦,观察者模式主体直接持有观察者列表——区别在于解耦程度。 面试常考 EventEmitter 的 5 个暗坑:off 遍历安全、once 自毁、错误冒泡等。

手写发布订阅与观察者模式深度解析:EventEmitter 的 5 个暗坑

一句话概括

发布订阅模式通过一个中心化的事件通道让发布者和订阅者完全解耦,而观察者模式是主体直接持有观察者列表——二者最核心的区别不是代码实现,而是「解耦程度」:前者发布者不知道谁在听,后者主体明确知道谁在观察自己。

核心知识点

1. 最小 EventEmitter:on / emit / off / once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class EventEmitter {
  #events = new Map()

  on(event, fn) {
    if (!this.#events.has(event)) this.#events.set(event, new Set())
    this.#events.get(event).add(fn)
    return this
  }

  once(event, fn) {
    const wrapper = (...args) => { fn(...args); this.off(event, wrapper) }
    wrapper._original = fn  // 保留原始引用,方便 off 取消
    return this.on(event, wrapper)
  }

  off(event, fn) {
    if (!this.#events.has(event)) return this
    const set = this.#events.get(event)
    for (const l of set) {
      if (l === fn || l._original === fn) { set.delete(l); break }
    }
    if (set.size === 0) this.#events.delete(event)
    return this
  }

  emit(event, ...args) {
    const set = this.#events.get(event)
    if (!set) return false
    // ⚠️ 快照复制:防止回调中 on/off 修改正在遍历的 Set
    for (const fn of [...set]) {
      try { fn(...args) } catch (e) { console.error('[EventEmitter]', event, e) }
    }
    return true
  }
}

// 使用
const bus = new EventEmitter()
bus.on('login', user => console.log('欢迎', user.name))
bus.emit('login', { name: 'Alice' })  // 欢迎 Alice

2. 观察者模式:Subject 直接通知 Observer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Subject {
  #observers = new Set()
  #state = null

  attach(o) { this.#observers.add(o) }
  detach(o) { this.#observers.delete(o) }

  setState(s) {
    this.#state = s
    // 迭代前快照,道理同 EventEmitter
    for (const o of [...this.#observers]) o.update(s)
  }
}

class Observer {
  constructor(name) { this.name = name }
  update(state) { console.log(`[${this.name}] 收到:`, state) }
}

const store = new Subject()
store.attach(new Observer('日志'))
store.attach(new Observer('渲染'))
store.setState({ count: 1 })

3. 两种模式的核心差异

维度观察者模式发布订阅模式
耦合度Subject 直接持有 Observer 列表通过事件通道解耦
通信方式Subject.updateObservers()事件名 + 数据载荷
谁持有谁Subject → Observer[]EventChannel → Map<event, Set>
典型用例Vue reactive(dep→watcher)Node EventEmitter、微前端跨应用通信
消息格式无约定,主体自由决定统一事件名约定

4. emit 中的列表快照:Node.js 也在用

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 错误:直接遍历,回调中 off 导致 Set 在迭代中修改
emit(event, ...args) {
  for (const fn of this.#events.get(event)) {  // 遍历中删除会出 bug
    fn(...args)
  }
}

// ✅ 正确:快照 + try-catch 隔离
emit(event, ...args) {
  const listeners = [...(this.#events.get(event) || [])]
  for (const fn of listeners) fn(...args)
}

Node.js 的 EventEmitter.emit 也使用 ArrayClone 快照,这是生产级设计的标准做法。

5. 内存泄漏的典型陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 泄漏:组件销毁时忘了取消订阅
class Component {
  constructor() {
    globalBus.on('data', (data) => this.render(data))
    // 箭头函数捕获 this → Component 实例永不回收
  }
}

// ✅ 安全:保存取消函数,销毁时调用
class SafeComponent {
  constructor() {
    this._unsub = globalBus.on('data', this.render)
  }
  destroy() { this._unsub() }
}

其实你每天都在用

  • Vue 组件通信 — $emit('click') / $on('event') 就是发布订阅。Vue 3 用 mitt 库替代了 $on/$off
  • Node.js 的 EventEmitter — http.createServer() 返回的 server 继承 EventEmitter,server.on('request', handler) 就是在订阅
  • 浏览器原生事件 — addEventListener / removeEventListener / dispatchEvent 本质也是发布订阅模式
  • WebSocket 消息分发 — 收到一条 ws 消息,按 type 字段分发给不同处理器,内部就是一个 EventEmitter
  • 微前端主子应用通信 — qiankun 用 initGlobalState + onGlobalStateChange,其实就是全局事件总线

常见误解(FAQ)

❌ 误区:「观察者模式和发布订阅模式是一回事」

观察者模式中 Subject 明确持有 Observer 列表并逐个调用 .update();发布订阅模式中多了一个「事件通道」中间层,发布者和订阅者互不相知。前者紧耦合适合同一模块内数据变化通知,后者松耦合适合跨模块通信。

❌ 误区:「emit 时遍历 Set 安全,因为 Set 支持迭代中删除」

Set 本身支持迭代中删除当前元素不报错,但如果你在回调中向 Set 添加新元素,新元素是否在当前遍历中出现是未定义的(取决于引擎实现)。所以快照是最稳妥的做法。

❌ 误区:「once 实现就是 on + 回调里 off」

思路对,但面试中容易被追问:如果你用 bus.once('a', fn) 然后想通过 bus.off('a', fn) 提前取消,却取消失败——因为 once 注册的是 wrapper 而非原始 fn。解决方法就是给 wrapper 挂 _original 引用。

❌ 误区:「EventEmitter 的 once 只执行一次,不需要手动取消订阅」

once 的回调确实只执行一次,但如果在回调执行前组件被销毁了,wrapper 仍然留在 listeners 中,导致闭包中的原始 fn 和其捕获的变量无法被 GC。组件的 destroy 中仍然要调 off。

一句话总结

EventEmitter 不只是「一个存回调的 Map」——它涉及迭代快照、try-catch 隔离、once 的引用追踪、通配符匹配、以及内存泄漏防御。Node.js 源码里为了一个 emit 写了 80 行,每一行都是在解决真实的生产事故。

本文由作者按照 CC BY 4.0 进行授权