怎样手写一个支持 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。