前端知库

怎样手写一个支持 on、off、once 和 emit 的事件中心?

用 Map 按事件名保存监听器数组。emit 遍历前先复制一份快照,这样执行期间新增的监听器从下一次 emit 起生效,被移除的监听器本轮仍会执行,与 Node.js EventEmitter 一致。once 的监听器要在调用前移除,回调里再次 emit 同一事件时才不会重复触发。

参考答案

简答

  • 数据结构用 Map<事件名, 监听器数组>,数组保持注册顺序,emit 按顺序同步调用。
  • emit 先复制数组再遍历。直接遍历原数组时,回调里调用 off 会让后面的监听器下标前移而被跳过,调用 on 会让新监听器在本轮就执行。
  • 采用快照后,本轮执行期间新增的监听器下一次才会触发,被移除的监听器本轮仍会执行。Node.js 的 EventEmitter 也是这个行为。
  • once 给监听器打标记,emit 调用它之前先按包装对象移除。先调用再移除时,回调内部再次 emit 同一事件会再次触发它。
  • off 按函数引用查找,同一函数注册多次时只移除一次。返回 this 支持链式调用。

实现

JavaScript
class EventEmitter {
  #events = new Map();

  on(type, listener) {
    if (typeof listener !== 'function') throw new TypeError('listener must be a function');
    if (!this.#events.has(type)) this.#events.set(type, []);
    this.#events.get(type).push({ listener, once: false });
    return this;
  }

  once(type, listener) {
    this.on(type, listener);
    const list = this.#events.get(type);
    list[list.length - 1].once = true;
    return this;
  }

  off(type, listener) {
    const list = this.#events.get(type);
    if (!list) return this;
    // 从后往前找,同一函数注册多次时只移除最近一次,与 Node.js 一致
    for (let i = list.length - 1; i >= 0; i--) {
      if (list[i].listener === listener) {
        list.splice(i, 1);
        break;
      }
    }
    if (list.length === 0) this.#events.delete(type);
    return this;
  }

  // once 触发时按包装对象删除,避免误删同一函数用 on 注册的另一条
  #remove(type, entry) {
    const list = this.#events.get(type);
    const index = list ? list.indexOf(entry) : -1;
    if (index === -1) return;
    list.splice(index, 1);
    if (list.length === 0) this.#events.delete(type);
  }

  emit(type, ...args) {
    const list = this.#events.get(type);
    if (!list) return false;
    // 快照:本轮执行期间的增删从下一次 emit 起生效
    for (const entry of [...list]) {
      if (entry.once) this.#remove(type, entry); // 先移除,防止回调内重入时重复触发
      entry.listener.apply(this, args);
    }
    return true;
  }
}

监听器用 { listener, once } 包装,off 仍按原函数比较,所以 once 注册的监听器也能用原函数取消。

emit 删除 once 监听器时按包装对象删除,不调用 off。同一个函数既用 on、又用 once 注册时,如果本轮前面的监听器已经把 once 那条移除,按函数删除会误删 on 那条。按包装对象删除时,两次 emit 后该函数共执行 3 次,与 Node.js 的结果相同;按函数删除只有 2 次。

emit 期间增删监听器

JavaScript
const log = [];
const emitter = new EventEmitter();
const b = () => log.push('b');

emitter.on('x', () => {
  log.push('a');
  emitter.off('x', b);
  emitter.on('x', () => log.push('new'));
});
emitter.on('x', b);
emitter.once('x', (v) => log.push(`once${v}`));

emitter.emit('x', 1);
console.log(log.join()); // a,b,once1
log.length = 0;

emitter.emit('x', 2);
console.log(log.join()); // a,new

第一次 emit 时,b 在本轮被移除但仍执行,新增的监听器没有执行。第二次 emit 时 b 已不在,once 已移除,新增的监听器开始执行。把同样的代码换成 Node.js 的 require('events').EventEmitter,第一次输出同样是 a,b,once1。

常见追问

  • 某个监听器抛错会怎样?上面的实现中错误直接抛出,后面的监听器不再执行。需要隔离时在循环里 try...catch,把错误交给统一的错误处理。
  • 为什么要提供 off?组件卸载时不移除监听器,回调会继续引用组件里的数据,造成内存泄漏和对已卸载组件的更新。
  • 浏览器里有现成的吗?EventTarget 配合 addEventListener、dispatchEvent 可以实现相同用途,监听器选项 { once: true } 对应 once。