← 返回题目列表

如何手写 EventEmitter?

高频 中等 第 6 / 27 题 更新于 2026/07/28
手写代码发布订阅EventEmitter事件

简化版

EventEmitter 用事件名管理监听器集合,核心接口是 onoffemitonce。实现时不能只会遍历数组,还要定义重复订阅、发布过程中增删监听器、once 重入、异常传播和资源清理规则。

详细版

常见实现用 Map<事件名, Set<监听器>>

class EventEmitter {
  events = new Map()

  on(event, listener) {
    if (!this.events.has(event)) this.events.set(event, new Set())
    this.events.get(event).add(listener)
    return () => this.off(event, listener)
  }

  off(event, listener) {
    const listeners = this.events.get(event)
    if (!listeners) return false
    const removed = listeners.delete(listener)
    if (listeners.size === 0) this.events.delete(event)
    return removed
  }

  emit(event, ...args) {
    const listeners = this.events.get(event)
    if (!listeners) return false
    for (const listener of [...listeners]) listener(...args)
    return true
  }
}

发布时遍历快照,避免某个监听器的增删行为破坏本轮顺序。once 应在调用用户回调之前注销包装器,防止回调内部再次 emit 时被执行第二次。

完整版教学

一、先明确发布订阅的合同

一个可面试的 EventEmitter 至少应回答:

问题本文选择
事件名类型字符串或 Symbol,均可作为 Map key
同一函数重复订阅Set 去重,只保留一次
本轮 emit 中新增监听不参与本轮,只参加下轮
本轮 emit 中移除监听因快照语义,已进快照的仍执行
监听器异常同步向调用者抛出,后续监听停止

这些不是纯实现细节。数组可以保留重复订阅,Set 会自动去重;两者都合理,但接口语义不同。

发布时复制监听器快照,是为了让一次 emit 的参与者集合稳定。否则监听器一边遍历一边增删,可能跳项、重复执行,甚至形成不可预测循环。

二、完整的基础实现

class EventEmitter {
  constructor() {
    this.events = new Map()
  }

  on(event, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError('listener must be a function')
    }
    let listeners = this.events.get(event)
    if (!listeners) {
      listeners = new Set()
      this.events.set(event, listeners)
    }
    listeners.add(listener)
    return () => this.off(event, listener)
  }

  off(event, listener) {
    const listeners = this.events.get(event)
    if (!listeners) return false
    const removed = listeners.delete(listener)
    if (listeners.size === 0) this.events.delete(event)
    return removed
  }

  emit(event, ...args) {
    const listeners = this.events.get(event)
    if (!listeners || listeners.size === 0) return false
    for (const listener of Array.from(listeners)) {
      Reflect.apply(listener, this, args)
    }
    return true
  }

  once(event, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError('listener must be a function')
    }
    const wrapper = (...args) => {
      this.off(event, wrapper)
      return Reflect.apply(listener, this, args)
    }
    return this.on(event, wrapper)
  }

  removeAllListeners(event) {
    if (arguments.length === 0) this.events.clear()
    else this.events.delete(event)
  }

  listenerCount(event) {
    return this.events.get(event)?.size ?? 0
  }
}

on 返回取消订阅函数,组件可以在卸载时直接调用;这比要求调用者再次保存事件名和函数引用更不容易泄漏。

三、once 为什么要先移除再调用

错误写法是先执行回调,回来后再删除。若监听器内部同步发布同一事件:

const bus = new EventEmitter()
let count = 0

bus.once('ready', () => {
  count++
  bus.emit('ready')
})

bus.emit('ready')

先移除时 count 最终是 1;后移除时,内层 emit 仍能看到包装器,会不断重入。一次性语义要求在把控制权交给用户代码之前完成注销。

本文 once 返回取消函数。若 API 还要求 off(event, originalListener) 能移除 once 监听,需要保存“包装器到原函数”的关系,或给包装器挂一个 original 标记后在 off 中同时比较。

四、发布过程中的增删与快照

假设 A 监听器执行时删除 B 并新增 C:

bus.on('tick', listenerA)
bus.on('tick', listenerB)
bus.emit('tick')

遍历 [...listeners] 快照时,本轮集合已经确定:B 仍执行,C 下一轮才执行。这种语义简单且可测试。若产品合同要求“删除后本轮立即失效”,则快照元素执行前还要用当前 Set 再做一次 has 检查。

快照会产生 O(k) 的临时数组,k 是该事件的监听器数量。大多数事件 k 很小;若每秒数万次发布且监听器很多,应基准测试并考虑链表或版本化数组,而不是盲目优化。

五、异常和异步监听器怎么处理

本文是同步 emitter:监听器抛错时,emit 立即抛出,后续监听器不再执行。这与“每个监听器都 try/catch 后继续”是两种不同合同。

若选择隔离异常,应明确错误去向,例如收集为数组返回、调用统一错误处理器,或异步上报;静默吞错会让故障难以定位。

async 监听器返回 Promise,但同步 emit 不会等待它,也捕获不到稍后的拒绝。需要等待所有监听器时应单独提供 emitAsync

async function emitAsync(emitter, event, ...args) {
  const listeners = [...(emitter.events.get(event) ?? [])]
  return Promise.all(listeners.map(fn => Reflect.apply(fn, emitter, args)))
}

Promise.all 会在首个拒绝时拒绝;若希望收集全部结果可使用 Promise.allSettled

六、复杂度、内存与 Node 差异

onoff 在 Map + Set 下平均为 O(1),emit 必须调用 k 个监听器,所以至少为 O(k),快照空间也是 O(k)。事件总数和监听器总数决定常驻内存。

长期对象若不断订阅却不退订,会保留监听器闭包及其捕获的大对象。返回取消函数、组件卸载清理、提供 removeAllListeners,都是生命周期管理的一部分。

Node.js 的 EventEmitter 有自己的详细合同,例如特殊的 'error' 事件、重复监听和返回值规则。本文是通用教学实现,不应冒充 Node 的完整 polyfill;若题目明确要求 Node 兼容,应以对应版本官方文档为准逐项实现。

七、常见误区与追问

  • 误区:遍历原 Set 时随便增删不会影响结果。 活集合的迭代会受修改影响,新增项甚至可能在本轮被访问,应先确定合同并使用快照。
  • 误区:once 在回调执行后删除也一样。 回调可能同步重入同一事件,必须先删除再交出控制权。
  • 误区:监听器抛错后静默吞掉最稳。 这会隐藏故障;要么同步传播,要么定义可观测的统一错误通道。
  • 追问:为什么 emit 返回布尔值? 本文用它表示是否存在监听器,便于调用者判断事件有没有消费者。
  • 追问:重复订阅同一个函数怎么办? Set 语义会去重;若业务要求执行两次,应改用数组并让每次订阅拥有独立令牌。
  • 追问:如何移除 once 的原始函数? 记录包装器与原函数关系,off 同时匹配二者,或直接使用 once 返回的取消函数。
  • 追问:怎样防止内存泄漏? 明确所有权,在卸载时调用取消函数,并对异常增长的监听器数量做监控或上限警告。

八、加强记忆

  1. 存储:Map 按事件分组,Set 决定去重语义。
  2. 发布:快照固定本轮参与者,顺序和修改行为才可预测。
  3. 一次once 必须先注销,再调用用户函数,防止重入。
  4. 错误:同步传播、隔离继续、异步等待必须三选一并写进合同。
  5. 清理:订阅返回取消函数,生命周期结束及时释放闭包。
  6. 边界:通用 emitter 不等于 Node.js EventEmitter 的完整实现。