手写一个简易的EventEmitter,理清订阅发布机制。

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-11 18:43 ·2 浏览 ·0 回复

订阅发布是前端最熟悉的设计模式之一:DOM 事件、Vue 的 `$on/$emit`、Node 的 `EventEmitter`,背后都是同一套思路。它解决的核心问题很简单——让事件的产生方和消费方解耦,产生方只管“喊一嗓子”,谁关心谁自己来听。不过,真正自己动手写一个 `EventEmitter`,才会发现 `once`、`off`、`this` 指向这些细节并不像想象中那么理所当然。

这篇文章不依赖任何库,从零实现一个可用的 `EventEmitter`,顺便把订阅发布机制里几个容易模糊的点彻底理清。

核心只有两件事:存和取

无论 API 设计成什么样,`EventEmitter` 内部始终在做两件事:

1. :把「事件名 → 回调列表」的映射关系保存起来,对应 `on`、`once`;
2. :根据事件名找到回调列表,依次执行,对应 `emit`。

所以最核心的数据结构就是一个 `Map`,键是事件名,值是一个函数数组。这里我更推荐用 `Map` 而不是普通对象,原因很实际:普通对象会受原型链影响,如果有人 `emit('constructor')`,在没做好判断的情况下可能取到不该取的东西。`Map` 的键可以是任意类型,也没有原型污染问题,语义更干净。

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

  on(event, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError('listener must be a function');
    }
    const listeners = this.events.get(event) || [];
    listeners.push(listener);
    this.events.set(event, listeners);
    return this;
  }

  emit(event, ...args) {
    const listeners = this.events.get(event);
    if (!listeners || listeners.length === 0) return false;

    // 复制一份,避免回调内部 on/off 影响本次遍历
    [...listeners].forEach((fn) => fn.apply(this, args));
    return true;
  }
}

短短几行,订阅发布的基本模型就出来了。`on` 负责收集,`emit` 负责触发,`apply(this, args)` 让监听器里的 `this` 指向 emitter 实例本身,使用体验和 Node 保持一致。

off 和 once 才是容易出错的地方

如果只实现 `on` 和 `emit`,这个类其实能用,但一旦加上 `off` 和 `once`,细节就开始冒出来了。

先说 `off`。最直接的想法是用 `indexOf` 找到回调,再 `splice` 删掉。但要注意两个问题:一是同一个函数可能被多次注册,删一次还是全删;二是删除时数组正在变化,如果外部还在遍历旧引用,行为可能不符合预期。更稳妥的方式是过滤出一个新数组再重新 `set`:

off(event, listener) {
  const listeners = this.events.get(event);
  if (!listeners) return this;

  if (!listener) {
    this.events.delete(event);
    return this;
  }

  this.events.set(
    event,
    listeners.filter((fn) => fn !== listener && fn._original !== listener)
  );
  return this;
}

这里的 `_original` 是为 `once` 准备的。因为 `once` 的实现通常是包一层 `wrapper`,真正注册进数组的是 `wrapper`,而不是用户传进来的原始函数。如果不做标记,用户调用 `off(event, originalFn)` 就会删不掉,这算是一个很常见的“隐藏 bug”。

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

`once` 的逻辑是“先移除自己,再执行回调”。这样即使回调里再次 `emit` 同一个事件,也不会无限递归。

emit 时为什么要复制数组

上面 `emit` 里有一句 `[...listeners]`,这不是多此一举。如果直接在原数组上 `forEach`,而某个监听器在执行过程中调用了 `off` 或 `once`,数组长度和内容会变化,`forEach` 的行为就变得不可控:可能漏掉后面的监听器,也可能重复执行。

复制一份之后,本次 `emit` 触发的是“快照”,新增的监听器不会在本次生效,移除的监听器也仍然会执行完当前这一轮。这和 Node.js `EventEmitter` 的行为是一致的,也更符合直觉:事件已经开始广播了,就让它按开始时的名单走完。

几个值得注意的小边界

- 没有监听器时:`emit` 应该静默返回 `false`,而不是报错。事件模型本就允许“无人订阅”。
- 回调抛异常:如果某个监听器抛错,是否继续执行后面的?Node 默认会直接抛出并中断。如果希望更健壮,可以在内部 `try/catch` 并收集错误,但这会改变同步异常的语义,要不要做取决于使用场景。
- 返回 this:`on`、`off`、`once` 返回 `this` 可以支持链式调用,算是低成本提升手感。
- 移除所有监听:`removeAllListeners(event)` 删除单个事件,不传参则清空整个 `Map`,在组件销毁时非常实用。

完整实现

把上面的片段拼起来,一个简易但可用的 `EventEmitter` 就完成了。它支持 `on`、`once`、`emit`、`off`、`removeAllListeners`,也处理了 `this` 指向和遍历安全的问题。代码不长,但订阅发布机制里几个关键决策点——数据结构选型、回调包装、遍历快照、移除策略——都已经覆盖到了。

真正理解这套机制之后,再去看 Vue 的事件系统、Node 的 `EventEmitter`,甚至浏览器的事件流,都会觉得它们只是在这套骨架上增加了优先级、捕获冒泡、错误处理等能力。自己手写一遍,比背十遍 API 印象都深。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-230.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~