本文转载自微信公众号“DYBOY”,作者DYBOY。转载本文请联系DYBOY公众号。程序中经常会涉及到发布订阅设计模式,比如Vue中的$on和$off,document.addEventListener(),document.removeEventListener()等,发布订阅模式可以降低程序的耦合度,统一管理和维护消息,处理事件也使得程序更易于维护和扩展。有朋友问如何学习设计模式。设计模式本身就是一些问题场景的抽象解决方案。死记硬背肯定不行。掌握知识点会更容易。最近在程序中使用了事件发布订阅库eventEmitter3。该库可用于组件之间的通信管理。您可以通过一个简单的Readme文档了解如何使用它,但同时了解这个库的设计也将有助于您理解发布和订阅设计。模式,一起来看看吧。1.定义在软件架构中,发布-订阅是一种消息范式。消息的发送者(称为发布者)不会直接将消息发送给特定的接收者(称为订阅者),而是将消息发布到不同的类别中,而不知道可能存在哪些订阅者(如果有)。同样,订阅者可以表达对一个或多个类别的兴趣,并且只接收感兴趣的消息,而无需知道存在哪些发布者(如果有)。打个比方很好理解的例子,比如微信公众号,你关注(解读为订阅)“DYBOY”公众号,当这个公众号发表新文章时,微信会通知你,但不会通知其他人谁订阅了公众号,还可以订阅多个公众号。在程序的组件中,除了父子组件之间的传值,多个组件的通信还包括redux、vuex等状态管理。另外,本文提到的发布-订阅模式可以通过一个事件中心来实现。发布订阅模式2、用手搓一个发布订阅事件中心。有什么问题?2.1基础结构版本首先实现的DiyEventEmitter如下:{if(!DiyEventEmitter.instance){DiyEventEmitter.instance=newDiyEventEmitter();}returnDiyEventEmitter.instance;}constructor(){this._eventsMap=newMap();//事件名称和回调函数的映射}/***eventsubscription**@parameventName事件名称*@parameventFnCallback事件发生时的回调函数*/publicon(eventName:string,eventFnCallback:()=>void){constnewArr=this._eventsMap.get(eventName)||[];newArr.push(eventFnCallback);this._eventsMap.set(eventName,newArr);}/***取消订阅**@parameventName事件名称*@parameventFnCallback事件发生时的回调函数*/publicoff(eventName:string,eventFnCallback?:()=>void){if(!eventFnCallback){this._eventsMap.delete(eventName);return;}constnewArr=this._eventsMap.get(eventName)||[];for(leti=newArr.length-1;i>=0;i--){if(newArr[i]===eventFnCallback){newArr.splice(i,1);}}this._eventsMap.set(eventName,newArr);}/***主动通知并执行注册的回调函数**@parameventName事件名*/publicmit(eventName:字符串){constfns=this._eventsMap.get(eventName)||[];fns.forEach(fn=>fn());}}exportdefaultDiyEventEmitter.getInstance();导出的DiyEventEmitter是一个“单例”,保证在全世界只有一个“事件中心”实例,可以直接使用公共方法importefrom"./DiyEventEmitter";constsubscribeFn=()=>{console.log("DYBOY订阅收到一条消息");};constsubscribeFn2=()=>{console.log("DYBOY的第二个订阅收到一条消息");};//订阅e.on("dyboy",subscribeFn);e.on("dyboy",subscribeFn2);//发布消息e.emit("dyboy");//解绑第一条订阅消息e.off("dyboy",subscribeFn);//发布第二条消息时间e.emit("dyboy");输出控制台结果:DYBOY订阅收到第二条订阅消息的消息,则支持订阅、发布、取消的第一版“发布订阅事件中心”就OK了。2.2部分场景只支持一次订阅方式,部分事件订阅可能只需要执行一次,后续通知将不再响应。实现思路:添加once订阅方法,当相应的“发布者消息”被响应时,会主动取消订阅当前正在执行的回调函数。为此,新增了一个类型,方便回调函数的描述信息扩展:typeSingleEvent={fn:()=>void;once:boolean;};_eventsMap的类型改为:private_eventsMap:Map>;同时提取on和once方法的公共方法addListener共享:privateaddListener(eventName:string,eventFnCallback:()=>void,once=false){constnewArr=this._eventsMap.get(eventName)||[];newArr.push({fn:eventFnCallback,once,});this._eventsMap.set(eventName,newArr);}/***事件订阅**@parameventName事件名称*@parameventFnCallback事件发生时的回调函数*/publicon(eventName:string,eventFnCallback:()=>void){this.addListener(eventName,eventFnCallback);}/***事件订阅一次**@parameventName事件名称*@parameventFnCallback事件发生时的回调函数*/publiconce(eventName:string,eventFnCallback:()=>void){this.addListener(eventName,eventFnCallback,true);}同时需要考虑的是,当一个事件被触发时,一旦触发就需要取消订阅executed/***trigger:主动通知和执行注册回调函数**@parameventName事件名称*/publicmit(eventName:string){constfns=this._eventsMap.get(eventName)||[];fns.forEach((evt,index)=>{evt.fn();if(evt.once)fns.splice(index,1);});this._eventsMap.set(eventName,fns);}另外,取消订阅函数中的比较需要替换对象属性比较:newArr[i].fn===eventFnCallback这样我们的事件中心就支持once方法的改造了。通常使用异步按需加载组件。如果发布者组件先发布消息,但是异步组件还没有加载(订阅注册完成),那么发布者发布的消息将不会被响应。因此,我们需要将消息做成缓存队列,直到有订阅者订阅,并且只响应一次缓存的发布消息,消息就会从缓存中出队。先梳理一下缓存消息的逻辑流程:UML时序图发布者发布消息,事件中心检测是否有订阅者。如果没有订阅者订阅该消息,则将该消息缓存在离线消息队列中。当有订阅者订阅时,检查缓存中的事件消息是否被订阅,如果订阅则事件的缓存消息依次出队(FCFS调度执行),触发订阅者回调函数执行一次。新增离线消息缓存队列:private_offlineMessageQueue:Map;在emit发布消息中判断对应事件是否有订阅者,如果没有订阅者则更新下线事件消息/***trigger:主动通知并执行注册回调函数**@parameventName事件名*/publicmit(eventName:字符串){constfns=this._eventsMap.get(eventName)||[];+if(fns.length===0){+constcounter=this._offlineMessageQueue.get(eventName)||0;+this._offlineMessageQueue.set(eventName,counter+1);+return;+}fns.forEach((evt,index)=>{evt.fn();if(evt.once)fns.splice(index,1);});this._eventsMap.set(eventName,fns);}然后在addListener方法中,根据离线事件消息的计数,重新发射并发布事件消息,并触发消息回调函数Exec??ute,然后删除离线消息中对应的事件。privateaddListener(eventName:string,eventFnCallback:()=>void,once=false){constnewArr=this._eventsMap.get(eventName)||[];newArr.push({fn:eventFnCallback,once,});这。_eventsMap.set(eventName,newArr);+constcacheMessageCounter=this._offlineMessageQueue.get(eventName);+if(cacheMessageCounter){+for(leti=0;ivoid改为fn:Function,这样就可以通过函数任意参数长度的TS校验。其实事件中心的回调函数是没有参数的。如果有参数,则通过参数绑定(bind)提前传入。另外,如果真的要支持回调函数传递参数,那么需要在emit()的时候传入参数,然后再将参数传递给回调函数。我们这里暂时不去实现。2.4.2执行环境绑定在实现执行环境绑定功能之前,想一个问题:“开发者自己绑定还是事件中心来绑定?”也就是说,开发者在on('eventName',callbackfunction)中,是否应该主动绑定这个点?目前的设计下,初步认为不带参数的回调函数自己绑定this比较合适。所以在事件中心,暂时不需要做绑定参数的行为。如果回调函数需要传递参数和绑定执行上下文,则需要在绑定回调函数时自行绑定。这样我们的活动中心也算是保证了功能的纯正性。至此,我们就自己完成了简单的发布订阅事件中心!3、学习EventEmitter3的设计与实现虽然我们按照自己的理解实现了一个版本,但是不对比就不知道好坏,下面一起来看看EventEmitter3吧。优秀的“极致性能优化”库是如何处理事件订阅和发布的,同时可以从中学习性能优化思想。首先,EventEmitter3(以下简称:EE3)的实现思路是使用Events对象作为“回调事件对象”的存储,类似于我们上面实现的“发布订阅方式”作为执行逻辑事件。另外,addListener()函数增加了传入的Execution上下文参数,emit()函数最多支持5个参数,EventEmitter3也增加了监听器计数和事件名前缀。3.1Events存储避免了翻译,为了提高兼容性和性能,EventEmitter3是用ES5写的。在JavaScript中,一切都是对象,函数也是对象,所以内存的实现:functionEvents(){}3.2事件监听器实例同样,我们上面使用一个singleEvent对象来存储每个事件监听器实例,其中使用了一个EEEE3对象存储每个事件侦听器的实例以及所需的属性/***每个事件侦听器实例的表示**@param{Function}fn侦听器函数*@param{*}contextcalllistener*@param{Boolean的执行上下文}[once=false]指定监听器是否只支持调用一次*@constructor*@private*/functionEE(fn,context,once){this.fn=fn;this.context=context;this.once=once||false;}3.3添加监听器方法/***为给定事件添加监听器**@param{EventEmitter}emitterEventEmitter实例引用.*@param{(String|Symbol)}event事件名.*@param{Function}fnlistenerfunction.*@param{*}context调用监听器的上下文。*@param{Boolean}once指定监听器是否只支持c所有一次。*@returns{EventEmitter}*@private*/functionaddListener(emitter,event,fn,context,once){if(typeoffn!=='function'){thrownewTypeError('Thelistenermustbeafunction');}varlistener=newEE(fn,上下文||emitter,once),evt=prefix?prefix+event:event;//TODO:为什么这里先用对象,多了就用对象数组存储。有什么好处?如果(!emitter._events[evt].fn)emitter._events[evt].push(监听器);elseemitter._events[evt]=[emitter._events[evt],listener];returnemitter;}“addlistener”方法有几个关键作用:如果有前缀,在事件名前加上前缀,避免事件冲突添加事件名称时,_eventsCount+1用于快速读写所有事件的个数。如果事件只有一个监听器,_events[evt]指向这个EE对象,访问效率更高。3.4清除事件/***通过事件名清除事件**@param{EventEmitter}emitterEventEmitter实例引用*@param{(String|Symbol)}evt事件名*@private*/functionclearEvent(emitter,evt){if(--emitter._eventsCount===0)emitter._events=newEvents();elsedeleteemitter._events[evt];}清除事件只需要使用delete关键字删除对象上的属性即可。这里的另一个非常聪明的事情是它取决于事件计数器。如果计数器为0,则重新创建一个Events内存,指向emitter的_events属性。这样做的好处是,如果需要清除所有事件,只需要将emitter._eventsCount的值赋值为1,然后调用clearEvent()方法即可。无需遍历和清除事件3.5EventEmitterfunctionEventEmitter(){this._events=newEvents();this._eventsCount=0;}EventEmitter对象是指NodeJS中的事件触发器,定义了最小的接口模型,包括_events和_eventsCount属性,在addition方法是通过原型添加的。EventEmitter对象相当于我们上面定义的事件中心,其作用总结如下:EventEmitter必须讲emit()方法,订阅者注册事件的on()和once()方法都是用到的addListener()效用函数。emit()方法实现如下:/***调用每个执行指定事件名的监听器**@param{(String|Symbol)}event事件名。事件名称如果没有绑定监听器,则返回false。*@public*/EventEmitter.prototype.emit=functionemit(event,a1,a2,a3,a4,a5){varevt=prefix?prefix+event:event;if(!this._events[evt])returnfalse;varlisteners=this._events[evt],len=arguments.length,args,i;//如果事件名只绑定一个监听器if(listeners.fn){//如果执行一次,则移除监听器是基于性能的考虑。5个入参的call方法处理//使用5个以上参数的apply处理//5个以上参数的场景大多是少量switch(len){case1:returnlisteners.fn.call(listeners.context),true;case2:returnlisteners.fn.call(listeners.context,a1),true;case3:returnlisteners.fn.call(listeners.context,a1,a2),true;case4:returnlisteners.fn.call(listeners.context,a1,a2,a3),true;case5:returnlisteners.fn.call(listeners.context,a1,a2,a3,a4),true;case6:returnlisteners.fn。调用(listeners.context,a1,a2,a3,a4,a5),true;}for(i=1,args=newArray(len-1);i