面试官为什么爱问发布订阅者模式?这可能是前端领域最隐秘的照妖镜

9,320 阅读5分钟

前言

在无数前端面试中,总有一个问题如同幽灵般反复出现:"手写一个发布订阅模式"。这道看似简单的题目,却让许多候选人在白板上进退维谷。当我们拆解这道面试题背后的逻辑,会发现它像一把精巧的瑞士军刀,能够同时考察候选人多维度的能力。本文将带您深入剖析面试官钟爱这道题目的六大原因,并揭示如何用这个模式照亮前端开发的迷雾。

一、设计模式的试金石

发布订阅模式(Pub/Sub)作为23种设计模式中的行为型模式,其重要性远超表面。在Vue.js中,EventBus的实现正是该模式的典型应用;React的Redux通过store.subscribe实现状态订阅;甚至浏览器原生的addEventListener也暗合其道。面试官通过此题,可以快速判断候选人:

  1. 基础设计能力:能否识别模式中的三要素(发布者、订阅者、调度中心)
  2. 模式对比能力:与观察者模式的关键差异(解耦程度、中间介质)
  3. 实际应用经验:是否在项目中处理过跨组件通信或模块解耦

image.png

二、编程能力的多棱镜

手写实现发布订阅

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方法将包装后的处理函数注册到事件名下。

详细解释一下最后两句代码

   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时,这道题的价值才真正显现——它不仅是代码实现题,更是工程素养的试金石。