手写一个简易的EventEmitter,理清订阅发布机制。
订阅发布是前端最熟悉的设计模式之一:DOM 事件、Vue 的 $on/$emit、Node 的 EventEmitter,背后都是同一套思路。它解决的核心问题很简单——让事件的产生方和消费方解耦,产生方只管“喊一嗓子”,谁关心谁自己来听。不过,真正自己动手写一个 EventEmitter,才会发现 once、off、this 指向这些细节并不像想象中那么理所当然。
这篇文章不依赖任何库,从零实现一个可用的 EventEmitter,顺便把订阅发布机制里几个容易模糊的点彻底理清。
核心只有两件事:存和取
无论 API 设计成什么样,EventEmitter 内部始终在做两件事:
- 存:把「事件名 → 回调列表」的映射关系保存起来,对应
on、once; - 取:根据事件名找到回调列表,依次执行,对应
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 印象都深。
转载请注明出处,版权归原作者所有。
正式会员
认证极客






