手写发布订阅 / EventEmitter
发布订阅是前端最常见的解耦模式:组件通信、Node 的
events模块、Vue 的$emit/$on、消息总线都基于它。本文实现一 个功能完备的EventEmitter,包括on/once/emit/off/removeAllListeners,再扩展命名空间与通配符匹配。
题目描述
实现 EventEmitter,满足:
on(event, listener)注册监听,返回自身以支持链式。once(event, listener)注册一次性监听,触发后自动移除。emit(event, ...args)同步触发,按注册顺序调用监听器,返回是否有监听。off(event, listener)移除指定监听。removeAllListeners(event?)移除某事件或全部监听。listenerCount(event)统计监听器数量。- 进阶:支持命名空间(如
ns:event)和通配符(*/ns:*/*:event)。
思路分析
核心数据结构是一个 Map<eventName, listener[]>。要点:
- 同事件多监听:数组保存,
emit时遍历调用;遍历前拷贝一份,避免监听器内部off时索引错乱。 once实现:包装一个 wrapper,执行后调off(event, wrapper)自我移除。注意要保存原始 listener 引用,方便外部off(event, originalListener)也能移除。off去重:默认只移除第一个匹配项,与 Node 的EventEmitter.off一致。- 错误隔离:单个监听器抛错不应中断其他监听器,可加
try/catch与error事件(Node 行为是抛出,这里作为可选项)。 - 通配符:把
*视为分隔符级别的占位符,常见写法order:created、order:*、*:created、*。匹配时对每个已注册的 pattern 做分词比对。
完整实现
基础版(含 once / off / removeAllListeners)
class EventEmitter {
constructor() {
// 用对象而不是 Map,方便和通配符扩展一起用
this._events = Object.create(null);
}
_getListeners(event) {
if (!this._events[event]) this._events[event] = [];
return this._events[event];
}
on(event, listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener must be a function');
}
this._getListeners(event).push(listener);
return this;
}
once(event, listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener must be a function');
}
const wrapper = (...args) => {
// 先移除再调用,避免 listener 内部再 emit 同事件导致重复触发
this.off(event, wrapper);
return listener.apply(this, args);
};
// 保留原始引用,让外部 off(originalListener) 也能移除
wrapper._origin = listener;
this.on(event, wrapper);
return this;
}
emit(event, ...args) {
const list = this._events[event];
if (!list || list.length === 0) return false;
// 拷贝一份:遍历过程中可能有人 off/on,避免索引错乱
const snapshot = list.slice();
for (let i = 0; i < snapshot.length; i++) {
// 单个监听器出错不影响后续,但抛错让调用方感知
try {
snapshot[i].apply(this, args);
} catch (err) {
// Node 的行为是抛 'error' 事件或直接抛出
if (event !== 'error') {
this.emit('error', err);
} else {
throw err;
}
}
}
return true;
}
off(event, listener) {
if (!this._events[event]) return this;
if (!listener) {
// 不传 listener:清空该事件全部监听
this._events[event] = [];
return this;
}
const list = this._events[event];
// 倒序遍历,方便删除
for (let i = list.length - 1; i >= 0; i--) {
const fn = list[i];
// 同时支持移除 once 包装过的监听器
if (fn === listener || fn._origin === listener) {
list.splice(i, 1);
break; // Node 行为:只移除第一个匹配
}
}
return this;
}
removeAllListeners(event) {
if (arguments.length === 0) {
this._events = Object.create(null);
} else if (this._events[event]) {
delete this._events[event];
}
return this;
}
listenerCount(event) {
const list = this._events[event];
return list ? list.length : 0;
}
}
进阶版:命名空间 + 通配符
约定事件名用 : 分隔层级,如 user:login。支持 * 匹配单层、** 匹配多层(类似 glob)。
class WildcardEmitter extends EventEmitter {
constructor() {
super();
this._delimiter = ':';
}
// 把事件名或 pattern 拆成段
_split(name) {
return typeof name === 'string' ? name.split(this._delimiter) : [name];
}
// 判断实际事件是否匹配 pattern
_match(segments, pattern) {
const ps = this._split(pattern);
let i = 0,
j = 0;
while (i < segments.length) {
const seg = ps[j];
if (seg === '**') {
// 匹配到结尾
if (j === ps.length - 1) return true;
// 尝试在剩余 segments 中找下一个匹配位置
const next = ps[j + 1];
while (i < segments.length && segments[i] !== next) i++;
j++;
continue;
}
if (seg === '*' || seg === segments[i]) {
i++;
j++;
continue;
}
return false;
}
// pattern 是否已经消费完(剩余只能是 ** 之类)
while (j < ps.length && ps[j] === '**') j++;
return j === ps.length;
}
emit(event, ...args) {
const segments = this._split(event);
let fired = false;
// 遍历所有 pattern,找到匹配项并触发
for (const pattern of Object.keys(this._events)) {
if (pattern === 'error' && event !== 'error') continue;
if (pattern.includes('*') || pattern.includes('**')) {
if (!this._match(segments, pattern)) continue;
} else if (pattern !== event) {
continue;
}
const list = this._events[pattern];
if (list && list.length) {
const snapshot = list.slice();
for (const fn of snapshot) {
try {
fn.apply(this, args);
fired = true;
} catch (err) {
if (event !== 'error') this.emit('error', err);
else throw err;
}
}
}
}
return fired;
}
}
边界与测试用例
// 1. 基础 on / emit
const bus = new EventEmitter();
let called = 0;
bus.on('click', () => called++);
bus.emit('click');
bus.emit('click');
console.log('case1', called); // 2
// 2. 多监听器按注册顺序执行
const order = [];
bus.on('ev', () => order.push(1));
bus.on('ev', () => order.push(2));
bus.emit('ev');
console.log('case2', order); // [1, 2]
// 3. once 只触发一次
let times = 0;
bus.once('start', () => times++);
bus.emit('start');
bus.emit('start');
console.log('case3', times); // 1
// 4. off 移除指定监听
const cb = () => order.push(99);
bus.on('x', cb);
bus.off('x', cb);
bus.emit('x');
console.log('case4 last item', order[order.length - 1]); // 不是 99
// 5. off 也能移除 once 注册的监听(用原始引用)
let onceCount = 0;
const original = () => onceCount++;
bus.once('y', original);
bus.off('y', original);
bus.emit('y');
console.log('case5', onceCount); // 0
// 6. emit 不存在的 event 返回 false
console.log('case6', bus.emit('nope')); // false
// 7. 遍历中 off 不影响本次回调
const temp = [];
bus.on('z', function a() {
temp.push('a');
bus.off('z', a);
});
bus.on('z', () => temp.push('b'));
bus.emit('z');
bus.emit('z');
console.log('case7', temp); // ['a', 'b', 'b']
// 8. 通配符匹配
const wb = new WildcardEmitter();
const logs = [];
wb.on('user:login', () => logs.push('login'));
wb.on('user:*', () => logs.push('user-wildcard'));
wb.on('*:login', () => logs.push('login-wildcard'));
wb.on('**', () => logs.push('global'));
wb.emit('user:login', { id: 1 });
console.log('case8', logs);
// ['login', 'user-wildcard', 'login-wildcard', 'global']
// 9. 多层通配符
wb.on('order:**', () => logs.push('order-recursive'));
wb.emit('order:created:paid');
console.log('case9 has', logs.includes('order-recursive')); // true
// 10. removeAllListeners
bus.removeAllListeners('ev');
console.log('case10', bus.listenerCount('ev')); // 0
注意以下边界:
emit时必须拷贝数组(slice())再遍历,否则监听器中off会让后续索引错位。off必须支持用原始 listener 引用移除once包装的监听器,否则once注册后无法取消。error事件没有监听器时应抛出(Node 行为),避免错误被吞掉。- 通配符匹配要兼容多段路径,
*只匹配单段、**匹配多段,类比 shell glob。 removeAllListeners()不传参应清空所有事件,传参只清空指定事件。
进阶追问
- 为什么
emit要slice后再遍历? 防止监听器在执行中调用off/removeAllListeners改变数组长度,导致跳过或重复执行后续监听器。 once为什么要先off再执行 listener? 如果 listener 内部又emit同名事件,先移除可以避免递归触发死循环;执行顺序上先off也不影响 listener 拿到正确参数。- 如何做异步
emit(返回 Promise,等待所有监听器完成)?emitAsync返回Promise.all(list.map(fn => fn(...args))),要求 listener 返回 Promise。 - 如何做最大监听器数限制? 像 Node 的
setMaxListeners,超过阈值打 warning,防止内存泄漏。 - 如何实现事件拦截 / 中断后续? 让 listener 返回特殊值(如
EventEmitter.STOP)或抛StopPropagationError,遍历中检测到就 break。 removeAllListeners传event与不传的区别? 不传清空整个_events,传则只清该事件;不传参容易误伤其他模块注册的监听器,谨慎使用。- 与 Observable / RxJS 的区别? EventEmitter 是 push-based 的多播;Observable 是冷启动可订阅的流,支持背压、操作符组合,能力更强但概念更重。