真实面经题目 · 原创解析
手撕发布订阅
这题考察发布订阅的核心数据结构和边界处理。高质量实现要包含 on、off、emit、once,并处理执行中增删监听器的问题。
真实面经题目 · 原创解析
这题考察发布订阅的核心数据结构和边界处理。高质量实现要包含 on、off、emit、once,并处理执行中增删监听器的问题。
发布订阅可以用 Map 保存事件名到监听器集合。on(event, fn) 注册监听器并返回取消函数;off(event, fn) 删除指定监听器;emit(event, ...args) 取出当前事件的监听器并按顺序执行;once(event, fn) 用包装函数执行一次后自动 off。关键细节是 emit 时要复制监听器快照,避免回调执行过程中 off 或 on 影响本轮遍历;off 要能删除 once 包装前的原函数;监听器为空时要清掉事件 key,避免长期泄漏。复杂度上,注册和删除通常接近 O(1),emit 与当前事件监听器数量成正比。
Map<eventName, Set<listener>> 能表达一个事件对应多个监听器,Set 可避免同一个函数重复注册,并保留插入顺序。事件名可以是字符串,也可以扩展成 symbol。
on 负责注册并返回 unsubscribe,off 负责删除指定函数,emit 负责传参调用所有监听器。emit 的返回值可以设计成是否有监听器,或返回每个监听器的执行结果。
once 注册的不是原函数,而是 wrapper。wrapper 执行时先 off 自己,再调用原函数。为了支持 off(event, originalFn),wrapper 上要保存原函数引用,或维护原函数到 wrapper 的映射。
如果直接遍历可变 Set,某个监听器执行中新增或删除监听器,会影响本轮触发顺序甚至漏调。复制 Array.from(listeners) 后遍历,可以让本轮 emit 的行为稳定。
一个监听器抛错时是中断 emit、继续执行并收集错误,还是异步抛出,需要按业务约定。监听器删除后事件集合为空要 delete key,避免事件名长期堆积。
class EventBus {
constructor() {
this.events = new Map();
}
on(type, listener) {
if (!this.events.has(type)) this.events.set(type, new Set());
this.events.get(type).add(listener);
return () => this.off(type, listener);
}
off(type, listener) {
const listeners = this.events.get(type);
if (!listeners) return;
for (const fn of listeners) {
if (fn === listener || fn.origin === listener) listeners.delete(fn);
}
if (listeners.size === 0) this.events.delete(type);
}
once(type, listener) {
const wrapper = (...args) => {
this.off(type, wrapper);
listener(...args);
};
wrapper.origin = listener;
return this.on(type, wrapper);
}
emit(type, ...args) {
const listeners = this.events.get(type);
if (!listeners) return false;
for (const listener of Array.from(listeners)) {
listener(...args);
}
return true;
}
} 观察者通常是 subject 直接维护 observer;发布订阅多了事件中心,发布者和订阅者通过事件名解耦,彼此不直接依赖。
如果使用快照,本轮仍按快照稳定执行;删除会影响后续 emit。直接遍历可变数组或 Set 时容易出现跳项或顺序不确定。
还必须在第一次执行后移除包装函数,否则第二次 emit 仍会触发。还要支持用原 listener 主动 off。
返回 unsubscribe,组件卸载时调用;off 后空集合删除事件 key;对全局事件总线要避免匿名函数无法移除。