面试官为什么爱问发布订阅者模式?

前言

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

一、设计模式的试金

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

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

二、编程能力的多棱镜

手写实现发布订阅

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

全部评论

相关推荐

04-24 21:56
深圳大学 C++
有一些难题的【单选】5. 2^n 与 n^100 谁渐进增长率高===犹豫了一下,最后设n 为 2^k 次方可以推导8. MySQL 中以下查询不会用到 composite_index 索引的是 ()表结构如下:CREATE TABLE `teacher_table` (  `id` bigint NOT NULL AUTO_INCREMENT,  `name` char(10) DEFAULT NULL,  `birth` varchar(20) DEFAULT '',  `sex` varchar(10) DEFAULT NULL COMMENT '性别',  `age` int DEFAULT NULL,  PRIMARY KEY (`id`),  KEY `composite_index` (`name`, `sex`, `age`),  KEY `index_birth` (`birth`)) ENGINE=InnoDB ;A. SELECT * FROM teacher_table WHERE age = 20 AND name = '张三';B. SELECT * FROM teacher_table WHERE name = '张三' AND sex = '男' AND age = 20;C. SELECT * FROM teacher_table WHERE sex = '男' AND name = '张三';D. SELECT * FROM teacher_table WHERE sex = '男' AND age = 20 ;E. SELECT * FROM teacher_table WHERE sex = '男' AND age = 20 AND name = '张三';===在AD之间犹豫,最后选了A。下来查资料得知:实际上组合索引是严格从左到右的,所以只要有name,就能用到。16.在一台大型数据中心里,一位年轻的软件开发 者正在处理一个小根堆。已知关键字序列 [1, 11, 18, 36, 89, 28, 21, 78] 是小根堆,他尝试插入关键字 87,并在调整后得到的小根堆是()A. [1, 11, 18, 21, 28, 36, 78, 89, 87]B. [1, 11, 18, 36, 89, 28, 21, 78, 87]C. [1, 11, 18, 21, 89, 28, 36, 78, 87]D. [1, 11, 18, 21, 28, 36, 78, 87, 89]===数组模拟二叉树,(我认为是插入堆头部,先把 头部放数组尾部,然后向上更新,那么87要么在根左,要么在根右。)实际上是直接插入尾部向上更新就够的,诶哟...---【填空】是两个关于向量的代码,让给出打印结果。一些变量名:velocity 速度 (速度向量mass 质量“impulse magnitude”(脉冲幅度)impulse 冲力作用---【编程题】1.前缀和2.每个数字代表一个积木高度。我们可以向左或者向右推倒积木,如果积木靠向的积木比自己低,也会跟着倒。问最少推几次?(我试着根据状态分析,太难搞了,最后想到根据一个当前vector去贪心计数过了,共做了40min)3.数学题,我测:有 n 个同学,和一个长 l 的绳子。我们切绳子,给每个同学一段。每个同学去围成一个 ai 边形或者任意图形,他们围成的图形的面积中最小的是 s ,求 s 最大值。保留5位小数。题目也给出了已知 顶角α 和 底边x 的等腰三角形的面积公式: S = x^2 / ( 4 * tan(α/2) )pai = acos(-1) ,(我不知道这个 acos 是反余弦,我累个高数)(任意的话围成圆面积最大,其他多边形是正多边形最大。问了下deepseek,可以作一个求和公式的)
查看7道真题和解析 投递吉比特等公司6个岗位
点赞 评论 收藏
分享
评论
8
63
分享

创作者周榜

更多
牛客网
牛客企业服务