如果面试官让你讲讲发布订阅设计模式?

开发 前端
发布订阅设计模式在程序中经常涉及,例如 Vue 中的 $on 和 $off、document.addEventListener()、document.removeEventListener()等,发布订阅模式可以降低程序的耦合度,统一管理维护消息、处理事件也使得程序更容易维护和扩展。

[[414875]]

本文转载自微信公众号「DYBOY」,作者DYBOY。转载本文请联系DYBOY公众号。

发布订阅设计模式在程序中经常涉及,例如 Vue 中的 $on 和 $off、document.addEventListener()、document.removeEventListener()等,发布订阅模式可以降低程序的耦合度,统一管理维护消息、处理事件也使得程序更容易维护和扩展。

有小伙伴问,该如何学习设计模式,设计模式本身是一些问题场景的抽象解决方案,死记硬背肯定不行,无异于搭建空中楼阁,所以得结合实际,从解决问题角度去思考、举一反三,如此便能更轻松掌握知识点。

最近在程序中使用到了 eventEmitter3 这个事件发布订阅库,该库可用于组件之间的通信管理,通过简单的 Readme 文档可学会如何使用,但同时了解这个库的设计也有助于大家了解认识发布订阅设计模式,不妨一起来看看。

一、定义

在软件架构中,发布订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者),而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

类比一个很好理解的例子,例如微信公众号,你关注(理解为订阅)了“DYBOY”公众号,当该公众号发布了新文章,微信就会通知你,而不会通知其他为订阅公众号的人,另外你还可以订阅多个公众号。

放到程序的组件中,多个组件的通信除了父子组件传值外,还有例如 redux、vuex 状态管理,另外就是本文所说的发布订阅模式,可以通过一个事件中心来实现。

发布订阅模式

二、手搓一个发布订阅事件中心

“纸上得来终觉浅,绝知此事要躬行”,所以根据定义,我们尝试实现一个JavaScript版本的发布订阅事件中心,看看会遇到哪些问题?

2.1 基本结构版

首先实现的 DiyEventEmitter 如下:

  1. /** 
  2.  * 事件发布订阅中心 
  3.  */ 
  4. class DiyEventEmitter { 
  5.   static instance: DiyEventEmitter; 
  6.   private _eventsMap: Map<string, Array<() => void>>; 
  7.  
  8.   static getInstance() { 
  9.     if (!DiyEventEmitter.instance) { 
  10.       DiyEventEmitter.instance = new DiyEventEmitter(); 
  11.     } 
  12.     return DiyEventEmitter.instance; 
  13.   } 
  14.  
  15.   constructor() { 
  16.     this._eventsMap = new Map(); // 事件名与回调函数的映射Map 
  17.   } 
  18.  
  19.   /** 
  20.    * 事件订阅 
  21.    * 
  22.    * @param eventName 事件名 
  23.    * @param eventFnCallback 事件发生时的回调函数 
  24.    */ 
  25.   public on(eventName: string, eventFnCallback: () => void) { 
  26.     const newArr = this._eventsMap.get(eventName) || []; 
  27.     newArr.push(eventFnCallback); 
  28.     this._eventsMap.set(eventName, newArr); 
  29.   } 
  30.  
  31.   /** 
  32.    * 取消订阅 
  33.    * 
  34.    * @param eventName 事件名 
  35.    * @param eventFnCallback 事件发生时的回调函数 
  36.    */ 
  37.   public off(eventName: string, eventFnCallback?: () => void) { 
  38.     if (!eventFnCallback) { 
  39.       this._eventsMap.delete(eventName); 
  40.       return
  41.     } 
  42.  
  43.     const newArr = this._eventsMap.get(eventName) || []; 
  44.     for (let i = newArr.length - 1; i >= 0; i--) { 
  45.       if (newArr[i] === eventFnCallback) { 
  46.         newArr.splice(i, 1); 
  47.       } 
  48.     } 
  49.     this._eventsMap.set(eventName, newArr); 
  50.   } 
  51.  
  52.   /** 
  53.    * 主动通知并执行注册的回调函数 
  54.    * 
  55.    * @param eventName 事件名 
  56.    */ 
  57.   public emit(eventName: string) { 
  58.     const fns = this._eventsMap.get(eventName) || []; 
  59.     fns.forEach(fn => fn()); 
  60.   } 
  61.  
  62. export default DiyEventEmitter.getInstance(); 

导出的 DiyEventEmitter 是一个“单例”,保证在全局中只有唯一“事件中心”实例,使用时候直接可使用公共方法

  1. import e from "./DiyEventEmitter"
  2.  
  3. const subscribeFn = () => { 
  4.   console.log("DYBOY订阅收到了消息"); 
  5. }; 
  6. const subscribeFn2 = () => { 
  7.   console.log("DYBOY第二个订阅收到了消息"); 
  8. }; 
  9.  
  10. // 订阅 
  11. e.on("dyboy", subscribeFn); 
  12. e.on("dyboy", subscribeFn2); 
  13.  
  14. // 发布消息 
  15. e.emit("dyboy"); 
  16.  
  17. // 取消第一个订阅消息的绑定 
  18. e.off("dyboy", subscribeFn); 
  19.  
  20. // 第二次发布消息 
  21. e.emit("dyboy"); 

输出 console 结果:

  1. DYBOY订阅收到了消息 
  2. 第二个订阅的消息 
  3. 第二个订阅的消息 

那么第一版的支持订阅、发布、取消的“发布订阅事件中心”就OK了。

2.2 支持只订阅一次once方法

在一些场景下,某些事件订阅可能只需要执行一次,后续的通知将不再响应。

实现的思路:新增 once 订阅方法,当响应了对应“发布者消息”,则主动取消订阅当前执行的回调函数。

为此新增类型,如此便于回调函数的描述信息扩展:

  1. type SingleEvent = { 
  2.   fn: () => void; 
  3.   once: boolean; 
  4. }; 

_eventsMap的类型更改为:

  1. private _eventsMap: Map<string, Array<SingleEvent>>; 

同时抽出公共方法 addListener,供 on 和 once 方法共用:

  1. private addListener( eventName: string, eventFnCallback: () => void, once = false) { 
  2.   const newArr = this._eventsMap.get(eventName) || []; 
  3.   newArr.push({ 
  4.     fn: eventFnCallback, 
  5.     once, 
  6.   }); 
  7.   this._eventsMap.set(eventName, newArr); 
  8.  
  9. /** 
  10.  * 事件订阅 
  11.  * 
  12.  * @param eventName 事件名 
  13.  * @param eventFnCallback 事件发生时的回调函数 
  14.  */ 
  15. public on(eventName: string, eventFnCallback: () => void) { 
  16.   this.addListener(eventName, eventFnCallback); 
  17.  
  18. /** 
  19.  * 事件订阅一次 
  20.  * 
  21.  * @param eventName 事件名 
  22.  * @param eventFnCallback 事件发生时的回调函数 
  23.  */ 
  24. public once(eventName: string, eventFnCallback: () => void) { 
  25.   this.addListener(eventName, eventFnCallback, true); 

与此同时,我们需要考虑在触发事件时候,执行一次就需要取消订阅

  1. /** 
  2.  * 触发:主动通知并执行注册的回调函数 
  3.  * 
  4.  * @param eventName 事件名 
  5.  */ 
  6. public emit(eventName: string) { 
  7.   const fns = this._eventsMap.get(eventName) || []; 
  8.   fns.forEach((evt, index) => { 
  9.     evt.fn(); 
  10.     if (evt.once) fns.splice(index, 1); 
  11.   }); 
  12.   this._eventsMap.set(eventName, fns); 

另外取消订阅中函数中比较需要替换对象属性比较:newArr[i].fn === eventFnCallback

这样我们的事件中心支持 once 方法改造就完成了。

2.3 缓存发布消息

在框架开发下,通常会使用异步按需加载组件,如果发布者组件先发布了消息,但是异步组件还未加载完成(完成订阅注册),那么发布者的这条发布消息就不会被响应。因此,我们需要把消息做一个缓存队列,直到有订阅者订阅了,并只响应一次缓存的发布消息,该消息就会从缓存出队。

首先梳理下缓存消息的逻辑流程:

UML时序图

发布者发布消息,事件中心检测是否存在订阅者,如果没有订阅者订阅此条消息,则把该消息缓存到离线消息队列中,当有订阅者订阅时,检测是否订阅了缓存中的事件消息,如果是,则该事件的缓存消息依次出队(FCFS调度执行),触发订阅者回调函数执行一次。

新增离线消息缓存队列:

  1. private _offlineMessageQueue: Map<string, number>; 

在emit发布消息中判断对应事件是否有订阅者,没有订阅者则向离线事件消息中更新

  1. /** 
  2.  * 触发:主动通知并执行注册的回调函数 
  3.  * 
  4.  * @param eventName 事件名 
  5.  */ 
  6. public emit(eventName: string) { 
  7.   const fns = this._eventsMap.get(eventName) || []; 
  8. +  if (fns.length === 0) { 
  9. +    const counter = this._offlineMessageQueue.get(eventName) || 0; 
  10. +    this._offlineMessageQueue.set(eventName, counter + 1); 
  11. +    return
  12. +  } 
  13.   fns.forEach((evt, index) => { 
  14.     evt.fn(); 
  15.     if (evt.once) fns.splice(index, 1); 
  16.   }); 
  17.   this._eventsMap.set(eventName, fns); 

然后在 addListener 方法中根据离线事件消息统计的次数,重新emit发布事件消息,触发消息回调函数执行,之后删掉离线消息中的对应事件。

  1. private addListener( 
  2.   eventName: string, 
  3.   eventFnCallback: () => void, 
  4.   once = false 
  5. ) { 
  6.   const newArr = this._eventsMap.get(eventName) || []; 
  7.   newArr.push({ 
  8.     fn: eventFnCallback, 
  9.     once, 
  10.   }); 
  11.   this._eventsMap.set(eventName, newArr); 
  12.  
  13. +  const cacheMessageCounter = this._offlineMessageQueue.get(eventName); 
  14. +  if (cacheMessageCounter) { 
  15. +    for (let i = 0; i < cacheMessageCounter; i++) { 
  16. +      this.emit(eventName); 
  17. +    } 
  18. +    this._offlineMessageQueue.delete(eventName); 
  19. +  } 

这样,一个支持离线消息的事件中心就写好了!

2.4 回调函数传参&执行环境

在上面的回调函数中,我们可以发现是一个没有返回值,没有入参的函数,这其实有些鸡肋,在函数运行的时候会指向执行的上下文,可能某些回调函数中含有this指向就无法绑定到事件中心上,因此针对回调函数需要绑定执行上下文环境。

2.4.1 支持回调函数传参

首先将TypeScript中的函数类型fn: () => void 改为 fn: Function,这样能够通过函数任意参数长度的TS校验。

其实在事件中心里回调函数是没有参数的,如有参数也是提前通过参数绑定(bind)方式传入。

另外如果真要支持回调函数传参,那么就需要在 emit() 的时候传入参数,然后再将参数传递给回调函数,这里我们暂时先不实现了。

2.4.2 执行环境绑定

在需要实现执行环境绑定这个功能前,先思考一个问题:“是应该开发者自行绑定还是应该事件中心来做?”

换句话说,开发者在 on('eventName', 回调函数) 的时候,是否应该主动绑定 this 指向?在当前设计下,初步认为无参数的回调函数自行绑定 this 比较合适。

因此,在事件中心这暂时不需要去做绑定参数的行为,如果回调函数内有需要传参、绑定执行上下文的,需要在绑定回调函数的时候自行 bind。这样,我们的事件中心也算是保证了功能的纯净性。

到这里我们自己手搓简单的发布订阅事件中心就完成了!

三、学习EventEmitter3的设计实现

虽然我们按照自己的理解实现了一版,但是没有对比我们也不知道好坏,因此一起看看 EventEmitter3 这个优秀“极致性能优化”的库是怎么去处理事件订阅与发布,同时可以学习下其中的性能优化思路。

首先,EventEmitter3(后续简称:EE3)的实现思路,用Events对象作为“回调事件对象”的存储器,类比我们上述实现的“发布订阅模式”作为事件的执行逻辑,另外addListener() 函数增加了传入执行上下文环境参数,emit() 函数支持最多传入5个参数,同时EventEmitter3中还加入了监听器计数、事件名前缀。

3.1 Events存储器

避免转译,以及为了提升兼容性和性能,EventEmitter3用ES5来编写。

在JavaScript中万物是对象,函数也是对象,因此存储器的实现:

  1. function Events() {} 

3.2 事件侦听器实例

同理,我们上述使用singleEvent对象来存储每一个事件侦听器实例,EE3 中用一个EE对象存储每个事件侦听器的实例以及必要属性

  1. /** 
  2.  * 每个事件侦听器实例的表示形式 
  3.  * 
  4.  * @param {Function} fn 侦听器函数 
  5.  * @param {*} context 调用侦听器的执行上下文 
  6.  * @param {Boolean} [once=false] 指定侦听器是否仅支持调用一次 
  7.  * @constructor 
  8.  * @private 
  9.  */ 
  10. function EE(fn, context, once) { 
  11.   this.fn = fn; 
  12.   this.context = context; 
  13.   this.once = once || false

3.3 添加侦听器方法

  1. /** 
  2.  * 为给定事件添加侦听器 
  3.  * 
  4.  * @param {EventEmitter} emitter EventEmitter实例的引用. 
  5.  * @param {(String|Symbol)} event 事件名. 
  6.  * @param {Function} fn 侦听器函数. 
  7.  * @param {*} context 调用侦听器的上下文. 
  8.  * @param {Boolean} once 指定侦听器是否仅支持调用一次. 
  9.  * @returns {EventEmitter} 
  10.  * @private 
  11.  */ 
  12. function addListener(emitter, event, fn, context, once) { 
  13.   if (typeof fn !== 'function') { 
  14.     throw new TypeError('The listener must be a function'); 
  15.   } 
  16.  
  17.   var listener = new EE(fn, context || emitter, once) 
  18.     , evt = prefix ? prefix + event : event; 
  19.  
  20.   // TODO: 这里为什么先是使用对象,多个的时候使用对象数组存储,有什么好处? 
  21.   if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++; 
  22.   else if (!emitter._events[evt].fn) emitter._events[evt].push(listener); 
  23.   else emitter._events[evt] = [emitter._events[evt], listener]; 
  24.  
  25.   return emitter; 

该“添加侦听器”的方法有几个关键功能点:

如果有前缀,给事件名增加前缀,避免事件冲突

每次新增事件名则 _eventsCount+1,用于快速读写所有事件的数量

如果事件只有单个侦听器,则 _events[evt] 指向这个 EE 对象,访问效率更高

3.4 清除事件

  1. /** 
  2.  * 通过事件名清除事件 
  3.  * 
  4.  * @param {EventEmitter} emitter EventEmitter实例的引用 
  5.  * @param {(String|Symbol)} evt 事件名 
  6.  * @private 
  7.  */ 
  8. function clearEvent(emitter, evt) { 
  9.   if (--emitter._eventsCount === 0) emitter._events = new Events(); 
  10.   else delete emitter._events[evt]; 

清除事件,只需要使用 delete 关键字,删除对象上的属性

另外这里一个很巧妙的地方在于,依赖事件计数器,如果计数器为0,则重新创建一个 Events 存储器指向 emitter 的 _events 属性。

这样做的优点是,假如需要清空所有事件,只需要将 emitter._eventsCount 的值赋值为1,然后调用 clearEvent() 方法就可以了,而不必遍历清除事件

3.5 EventEmitter

  1. function EventEmitter() { 
  2.   this._events = new Events(); 
  3.   this._eventsCount = 0; 

EventEmitter 对象参考 NodeJS 中的事件触发器,定义了最小的接口模型,包含 _events 和 _eventsCount属性,另外的方法都通过原型来增加。

EventEmitter 对象等同于上述我们的事件中心的定义,其功能梳理如下:

EventEmitter

其中有必要讲的就是 emit() 方法,而订阅者注册事件的on() 和 once() 方法,都是使用的 addListener() 工具函数。

emit() 方法实现如下:

  1. /** 
  2.  * 调用执行指定事件名的每一个侦听器 
  3.  * 
  4.  * @param {(String|Symbol)} event 事件名. 
  5.  * @returns {Boolean} `true` 如果当前事件名没绑定侦听器,则返回false
  6.  * @public 
  7.  */ 
  8. EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) { 
  9.   var evt = prefix ? prefix + event : event; 
  10.  
  11.   if (!this._events[evt]) return false
  12.  
  13.   var listeners = this._events[evt] 
  14.     , len = arguments.length 
  15.     , args 
  16.     , i; 
  17.  
  18.   // 如果只有一个侦听器绑定了该事件名 
  19.   if (listeners.fn) { 
  20.     // 如果是执行一次的,则移除侦听器 
  21.     if (listeners.once) this.removeListener(event, listeners.fn, undefined, true); 
  22.      
  23.     // Refrence:https://juejin.cn/post/6844903496450310157 
  24.     // 这里的处理是从性能上考虑,传入5个入参数的调用call方法处理 
  25.     // 超过5个参数的使用apply处理 
  26.     // 大部分场景超过5个参数的都是少数 
  27.     switch (len) { 
  28.       case 1: return listeners.fn.call(listeners.context), true
  29.       case 2: return listeners.fn.call(listeners.context, a1), true
  30.       case 3: return listeners.fn.call(listeners.context, a1, a2), true
  31.       case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true
  32.       case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true
  33.       case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true
  34.     } 
  35.  
  36.     for (i = 1, args = new Array(len -1); i < len; i++) { 
  37.       args[i - 1] = arguments[i]; 
  38.     } 
  39.  
  40.     listeners.fn.apply(listeners.context, args); 
  41.   } else { 
  42.     // 当有多个侦听器绑定了同一个事件名 
  43.     var length = listeners.length 
  44.       , j; 
  45.      
  46.     // 循环执行每一个绑定的事件侦听器 
  47.     for (i = 0; i < length; i++) { 
  48.       if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true); 
  49.  
  50.       switch (len) { 
  51.         case 1: listeners[i].fn.call(listeners[i].context); break; 
  52.         case 2: listeners[i].fn.call(listeners[i].context, a1); break; 
  53.         case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break; 
  54.         case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break; 
  55.         default
  56.           if (!args) for (j = 1, args = new Array(len -1); j < len; j++) { 
  57.             args[j - 1] = arguments[j]; 
  58.           } 
  59.           listeners[i].fn.apply(listeners[i].context, args); 
  60.       } 
  61.     } 
  62.   } 
  63.  
  64.   return true
  65. }; 

在 emit() 方法中显示的传入了五个入参:a1 ~ a5,同时优先使用 call() 方法绑定 this 指向并执行侦听器的回调函数。

这样处理的原因是,call 方法比 apply 方法效率更高,相关比较验证讨论可参考《call和apply的性能对比》

到这基本上 EventEmitter3 的实现就啃完了!

四、总结

EventEmitter3 是一个号称优化到极致的事件发布订阅的工具库,通过梳理可知晓:

 

  • call 与 apply 在效率上的差异
  • 对象和对象数组的存取性能考虑
  • 理解发布订阅模式,以及在事件系统中的应用实例

 

责任编辑:武晓燕 来源: DYBOY
相关推荐

2022-04-29 08:17:38

RPC远程代理代理模式

2021-11-08 11:32:01

观察

2020-11-06 07:11:40

内存虚拟Redis

2020-07-28 00:58:20

IP地址子网TCP

2015-08-13 10:29:12

面试面试官

2021-02-28 07:52:24

蠕虫数据金丝雀

2021-01-14 05:23:32

高并发消息中间件

2020-06-17 21:22:56

Serverless面试官架构

2020-12-09 05:18:17

面试观察者订阅模式

2020-07-03 07:39:45

查询语句

2023-07-13 08:19:30

HaspMapRedis元素

2024-08-16 13:59:00

2021-06-29 11:05:25

MySQLCPU数据库

2020-11-02 07:02:10

加载链接初始化

2023-07-11 08:50:34

2020-10-15 06:26:24

高并发场景冰河

2020-09-07 06:28:37

Nginx静态负载均衡动态负载均衡

2024-08-23 11:51:39

2021-10-29 09:40:21

设计模式软件

2019-05-14 08:44:13

面试面试官线程安全
点赞
收藏

51CTO技术栈公众号