文章

手写 EventEmitter深度解析

EventEmitter 是发布订阅的教科书实现:on 存回调、emit 逐个调、off 精准删、once 一次自毁。 面试考 once 的 wrapper 技巧、emit 中 off 自己的遍历安全,以及与观察者模式的区别。

手写 EventEmitter深度解析

一句话概括

EventEmitter 是发布订阅模式的教科书级实现:on 存回调、emit 逐个调、off 精准删、once 一次自毁。面试不考 API 怎么调——考的是 once 的 wrapper 代理技巧、emit 中 off 自己的遍历安全、以及它和观察者模式的本质区别。

核心知识点

1. 骨架——事件名到回调数组的映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class EventEmitter {
  constructor() {
    this._events = Object.create(null); // {'click': [fn1, fn2], 'load': [fn3]}
  }

  on(event, fn) {
    (this._events[event] ??= []).push(fn);
    return this; // 链式调用
  }

  emit(event, ...args) {
    this._events[event]?.forEach(fn => fn(...args));
    return this;
  }

  off(event, fn) {
    const fns = this._events[event];
    if (fns) this._events[event] = fns.filter(cb => cb !== fn);
    return this;
  }
}

20 行能跑——但藏着两个面试必考的坑,往下看。

2. once — wrapper 代理模式(面试核心考点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
once(event, fn) {
  const wrapper = (...args) => {
    this.off(event, wrapper);   // 先解绑自己,再执行(防止 fn 里再次 emit 导致重入)
    fn.apply(this, args);
  };
  wrapper._original = fn;      // 🔑 保存原始引用——用户能用原始函数来 off
  return this.on(event, wrapper);
}

// off 升级:同时匹配直接 on 的和 once 包装的
off(event, fn) {
  const fns = this._events[event];
  if (!fns) return this;
  this._events[event] = fns.filter(cb => cb !== fn && cb._original !== fn);
  return this;
}

核心技巧: wrapper._original = fn 建立包装→原始的桥。用户调 emitter.off('click', originalFn) 时,off 方法通过 _original 找到对应的 wrapper 并将其移除。这是面试官最爱追问的细节。

3. emit 遍历安全 — slice() 保命

1
2
3
4
5
6
7
8
9
10
emit(event, ...args) {
  const fns = this._events[event];
  if (!fns) return false;

  // 🔑 slice() 拷贝一份——原数组被 callback 修改不影响本次遍历
  for (const fn of fns.slice()) {
    fn.apply(this, args);
  }
  return true;
}

坑在哪: 回调 A 里调了 off('event', B) → 回调数组从 [A, B, C] 变成 [A, C] → 遍历器在原数组上继续走 → 索引 1 从 B 变成了 C,C 被静默跳过。

slice() 拷贝一份 → 回调随便怎么改原数组,遍历器永远走那一刻的快照。这是”事件循环里删监听器”场景下的经典 bug,Node.js 原生 EventEmitter 也用了同样的技巧。

4. maxListeners — 内存泄漏预警

1
2
3
4
5
6
7
8
9
10
11
12
13
constructor() {
  this._events = Object.create(null);
  this._maxListeners = 10;
}

on(event, fn) {
  const fns = (this._events[event] ??= []);
  fns.push(fn);
  if (fns.length > this._maxListeners) {
    console.warn(`⚠️ [${event}] has ${fns.length} listeners — possible memory leak`);
  }
  return this;
}

警告是信号:组件卸载忘了 off → 回调数组无限增长 → 闭包引用的 DOM/数据全被 EventEmitter 死抓着不放。Node 原生默认也是 10。

5. 发布订阅 vs 观察者 — 面试必问

1
2
3
4
5
6
7
8
9
10
11
// 发布订阅:中间有事件通道,双方互不认识
bus.on('data', cb);     // 订阅
bus.emit('data', val);  // 发布——不知道谁在听

// 观察者:Subject 直接持有 Observer 列表
subject.addObserver(obs);
subject.notify();       // subject 直接调 obs.update()

// 本质差异:
// 发布订阅 = 松耦合(通过事件名交互),代表:EventEmitter、EventBus、Redis Pub/Sub
// 观察者   = 紧耦合(Subject 认识每个 Observer),代表:Vue 响应式(Dep→Watcher)、MutationObserver

其实你每天都在用

  • Node.js http.createServer():server 继承 EventEmitter,server.on('request', handler) 就是发布订阅
  • Vue 2 $on/$emit/$once/$off:EventEmitter 直接复刻,Vue 3 弃用但在 mitt(200 字节)里重生
  • WebSocket ws.on('message', cb):事件驱动通信的标准范式
  • process.on('uncaughtException'):Node 全局异常捕获就是事件订阅
  • 微前端通信:跨子应用的全局事件总线底层就是 EventEmitter

常见误解(FAQ)

  • ❌ 误区:「emit 是异步的」 emit 完全同步——回调逐个立即执行。回调里做耗时操作会阻塞后续回调。如果需要异步调度,在回调里自己用 Promise.resolve().then() 或 process.nextTick——不在 emit 的实现里。

  • ❌ 误区:「emit 里 off 自己不碍事」 直接对原数组遍历时 off 自己 → 数组长度变了 → 索引错乱 → 后续回调静默跳过。解法:fns.slice() 先拍快照。Node 原生 EventEmitter 也是这么做的。

  • ❌ 误区:「maxListeners 设大就行」 掩耳盗铃。警告告诉你”有内存泄漏风险”——组件销毁忘 off,回调连带着闭包数据全被死抓着。治本之道:unmount/destroy 时清掉所有监听。

  • ❌ 误区:「EventEmitter 就是观察者模式」 差一个中间层。观察者的 Subject 直接维护 Observer 列表(如 Vue 的 Dep 维护 Watcher);发布订阅的双方通过事件名耦合,不直接持有对方引用。前者紧耦合交互明确,后者松耦合灵活——选择取决于你想要的耦合度。

一句话总结

一个 Map 存事件、一个数组存回调、四个方法 CRUD——EventEmitter 就这么简单。真正考的是三个边界:once 的 wrapper 代理、slice() 的遍历防御、_maxListeners 的预警意识。三关全过,面试官直接下一题。

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