面试官为什么爱问发布订阅者模式?
前言
在无数前端面试中,总有一个问题如同幽灵般反复出现:"手写一个发布订阅模式"。这道看似简单的题目,却让许多候选人在白板上进退维谷。当我们拆解这道面试题背后的逻辑,会发现它像一把精巧的瑞士军刀,能够同时考察候选人多维度的能力。本文将带您深入剖析面试官钟爱这道题目的六大原因,并揭示如何用这个模式照亮前端开发的迷雾。
一、设计模式的试金
发布订阅模式(Pub/Sub)作为23种设计模式中的行为型模式,其重要性远超表面。在Vue.js中,EventBus的实现正是该模式的典型应用;React的Redux通过store.subscribe实现状态订阅;甚至浏览器原生的addEventListener也暗合其道。面试官通过此题,可以快速判断候选人:
- 基础设计能力:能否识别模式中的三要素(发布者、订阅者、调度中心)
- 模式对比能力:与观察者模式的关键差异(解耦程度、中间介质)
- 实际应用经验:是否在项目中处理过跨组件通信或模块解耦
二、编程能力的多棱镜
手写实现发布订阅
constructor() { // 使用Map存储事件队列(比Object更高效) this.eventMap = new Map(); // 用于once的WeakMap(防止内存泄漏) this.onceWrapperMap = new WeakMap(); }
eventMap
: 使用Map
对象来存储事件名到其对应的处理函数列表的映射。相比于普通对象,Map
允许键为任何类型的值,并且在某些情况下性能更好。onceWrapperMap
: 使用WeakMap
来存储原始的一次性处理函数与它们被包装后的版本之间的映射。这有助于在执行完一次性事件后正确地清理相关资源,避免内存泄漏。
订阅事件 (on
)
on(eventName, handler) { if (typeof handler !== 'function') { throw new TypeError('Handler must be a function'); } const handlers = this.eventMap.get(eventName) || []; handlers.push(handler); this.eventMap.set(eventName, handlers); }
- 检查传入的
handler
是否为函数类型,如果不是,则抛出类型错误。 - 获取当前事件名对应的处理函数列表,如果不存在则初始化为空数组。
- 将新的处理函数添加到列表中,并更新到
eventMap
。
发布事件 (emit
)
emit(eventName, ...args) { const handlers = this.eventMap.get(eventName); if (!handlers) return false; // 创建副本执行(防止执行过程中修改队列) handlers.slice().forEach(handler => { // 异步执行更贴近实际场景(面试加分点) Promise.resolve().then(() => { handler.apply(this, args); }); }); return true; }
- 获取对应事件名的所有处理函数列表,如果不存在则直接返回
false
。 - 使用
.slice()
创建处理函数列表的一个副本,以避免在执行过程中对原列表进行修改。 - 对于每个处理函数,使用
Promise.resolve().then()
异步调用它们。这样做可以模拟真实的异步行为,提高代码的灵活性和可维护性。 - 返回
true
表示事件成功触发。
取消订阅 (off
)
off(eventName, handler) { const handlers = this.eventMap.get(eventName); if (!handlers) return; // 双保险删除(直接删除+通过once包装删除) const index = handlers.findIndex( h => h === handler || h === this.onceWrapperMap.get(handler) ); if (index > -1) { handlers.splice(index, 1); // 清理空数组 if (handlers.length === 0) { this.eventMap.delete(eventName); } } }
- 获取指定事件名的处理函数列表,若不存在则直接返回。
- 查找要移除的处理函数的位置,考虑了两种情况:直接匹配处理函数或匹配通过
once
方法包装后的处理函数。 - 如果找到了匹配项,则从列表中移除该处理函数。
- 如果移除后列表为空,则从
eventMap
中删除该事件名。
一次性订阅 (once
)
once(eventName, handler) { const onceHandler = (...args) => { // 先执行再清理(避免中途报错导致未清理) try { handler.apply(this, args); } finally { this.off(eventName, onceHandler); this.onceWrapperMap.delete(handler); } }; // 建立原始handler与包装后的映射 this.onceWrapperMap.set(handler, onceHandler); this.on(eventName, onceHandler); }
- 定义一个
onceHandler
,它会在首次被调用时执行原始的handler
,然后自动取消订阅自身。 - 使用
try...finally
确保无论handler
执行期间是否发生异常,都会进行后续的清理工作。 - 在
onceWrapperMap
中记录原始处理函数与包装后的处理函数之间的映射关系。 - 调用
on
方法将包装后的处理函数注册到事件名下。
看新机会
技术大厂,多地base,给的待遇还不错,感兴趣可以试试~
前、后端or测试>>>直通机会
详细解释一下最后两句代码
this.onceWrapperMap.set(handler, onceHandler); this.on(eventName, onceHandler);
1. this.onceWrapperMap.set(handler, onceHandler);
这行代码是在onceWrapperMap
中存储原始的handler
函数和为其包装后的onceHandler
函数之间的映射关系。
- 为什么需要这样做?当你使用once方法订阅一个事件时,实际上你提供的是一个原始的handler函数,但为了实现“仅执行一次”的功能,这个原始的handler会被包裹在一个新的函数onceHandler中。这个onceHandler不仅会调用原始的handler,还会在调用后自动取消订阅自己(即从事件监听器列表中移除),从而保证只触发一次。然而,在某些情况下(例如当你想手动取消订阅某个once事件),你需要通过原始的handler找到对应的onceHandler以便进行正确的移除操作。这就是onceWrapperMap存在的原因:它帮助你在原始handler和它的onceHandler之间建立联系,便于后续的操作如off方法来准确地定位并移除特定的一次性监听器。
2. this.on(eventName, onceHandler);
这行代码则是将包装后的onceHandler
作为监听器添加到指定的事件名下。
- 具体做了什么?在前面提到过,onceHandler是一个特殊的函数,它包含了原始handler的逻辑以及在执行后自我移除的功能。通过调用on方法并将onceHandler而不是原始的handler注册为事件监听器,可以确保当该事件被触发时,只会执行一次,并且之后会自动取消订阅。
END
当你手写出了代码并理解了发布订阅为什么被如此重视,并解释WeakMap的内存回收机制和为什么要用ES6的Map时,这道题的价值才真正显现——它不仅是代码实现题,更是工程素养的试金石。
——转载自作者:大海是蓝色blue