← 中文资源

简体中文版本

支持一次订阅与移除的事件发射器

一个无 Node 依赖的小型事件发射器,提供 once、按原始回调移除、派发快照与监听器计数。

阅读英文原文

功能说明

该示例实现一个不依赖 Node 的事件发射器,重点补齐 once 与 off 的语义:once 注册的监听器在首次触发前先把自己移除,因此即使回调内部再次 emit 同一事件也不会重复执行;off 既可以用包装函数移除,也可以用最初传入的原始回调移除。emit 在派发前复制监听器集合,允许回调中安全地增删监听器。适用于浏览器与 Node 共用的小型事件总线、插件生命周期钩子等场景。

关键代码

once 把原始监听器包进 wrapper,并在 wrapper 上记录 original 属性;触发时先调用 off 删除 wrapper,再执行原回调。off 遍历当前集合,同时匹配 entry 与 entry.original,因此两种引用都能删除。emit 使用展开运算符生成快照,避免遍历过程中修改 Set 导致漏发。on 返回的取消函数直接复用 off,调用方可保存以便组件卸载时清理。所有注册入口都会校验事件名与回调类型。

使用注意

派发是同步的,任一监听器抛错都会中断后续监听器,需要隔离时应自行 try/catch。once 只保证同一 wrapper 不再触发,若业务用同一原始函数注册多次,每次仍各触发一次。removeAllListeners 不传事件名会清空全部监听,适合测试重置。该实现没有通配符事件、异步派发或最大监听数限制,长期运行的服务应结合监听器计数做泄漏排查。

验证方式

用 node:test 编写测试:注册 once 后连续 emit 两次,断言只调用一次且 listenerCount 归零;用原始回调调用 off,确认已注册监听器被移除;在监听器内部再 emit 同一事件,验证快照派发不会无限递归;最后检查非法事件名与非法回调会抛出 TypeError。

代码示例

export class EventEmitter {
  #listeners = new Map();

  on(event, listener) {
    if (typeof event !== "string" || event.length === 0) {
      throw new TypeError("event must be a non-empty string");
    }
    if (typeof listener !== "function") {
      throw new TypeError("listener must be a function");
    }
    let bucket = this.#listeners.get(event);
    if (!bucket) {
      bucket = new Set();
      this.#listeners.set(event, bucket);
    }
    bucket.add(listener);
    return () => this.off(event, listener);
  }

  once(event, listener) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      listener(...args);
    };
    wrapper.original = listener;
    return this.on(event, wrapper);
  }

  off(event, listener) {
    const bucket = this.#listeners.get(event);
    if (!bucket) return false;
    let removed = false;
    for (const entry of bucket) {
      if (entry === listener || entry.original === listener) {
        bucket.delete(entry);
        removed = true;
      }
    }
    if (bucket.size === 0) this.#listeners.delete(event);
    return removed;
  }

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

  listenerCount(event) {
    return this.#listeners.get(event)?.size ?? 0;
  }

  removeAllListeners(event) {
    if (event === undefined) this.#listeners.clear();
    else this.#listeners.delete(event);
  }
}