前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >发布订阅模式

发布订阅模式

作者头像
一粒小麦
发布2019-11-05 01:53:08
1.2K0
发布2019-11-05 01:53:08
举报
文章被收录于专栏:一Li小麦一Li小麦

发布订阅模式又称为观察者模式,它用来定义一对多的依赖关系。当对象的状态改变时,所有依赖它的对象都会得到通知。在JavaScript的实现中,最常见的订阅发布模式应用就是事件模型。

我们常说nodeJs是事件驱动,如何理解?以去麦当劳点餐为例:

在基于线程的工作方式中(thread-based way)你到了柜台前,把你的点餐单给收银员或者给收银员直接点餐,然后等在那直到你要的食物准备好给你。收银员不能接待下一个人,除非你拿到食物离开。想接待更多的客户,那就加更多的收银员! 当然,我们知道快餐店其实不是这样工作的。他们其实就是基于事件驱动方式,这样收银员更高效。只要你把点餐单给收银员,某个人已经开始准备你的食物,而同时收银员在进行收款,当你付完钱,你就站在一边而收银员已经开始接待下一个客户。在一些餐馆,甚至会给你一个号码,如果你的食物准备好了,就呼叫你的号码让你去柜台取。关键的一点是,你没有阻塞下一个客户的订餐请求。你订餐的食物做好的事件会导致某个人做某个动作(某个服务员喊你的订单号码,你听到你的号码被喊到去取食物),在编程领域,我们称这个为回调(callback function)。

传统的web server多为基于线程模型。你启动Apache或者什么server,它开始等待接受连接。当收到一个连接,server保持连接连通直到页面或者什么事务请求完成。如果他需要花几微妙时间去读取磁盘或者访问数据库,web server就阻塞了IO操作(这也被称之为阻塞式IO).想提高这样的web server的性能就只有启动更多的server实例。

相反的,Node.Js使用事件驱动模型,当web server接收到请求,就把它关闭然后进行处理,然后去服务下一个web请求。当这个请求完成,它被放回处理队列,当到达队列开头,这个结果被返回给用户。这个模型非常高效可扩展性非常强,因为webserver一直接受请求而不等待任何读写操作。(这也被称之为非阻塞式IO或者事件驱动IO)。

var mcD = require('express')();
mcD.get('/chiken',(req,res)=>{  // ...})

可以发现,在这个例子中使用发布—订阅模式有着显而易见的优点。

(1) 用餐者不必排队守着前台一个个等待上一个服务结束,在合适的时间点,麦当劳作为发布者会通知这些消息订阅者取餐。

(2) 订阅者和麦当劳前台之间不再强耦合在一起,当有新的客人出现时,他只需下单取号,找个座位等待,麦当劳前台不关心客人的任何情况,不管ta是男是女还是一只猴子。而麦当劳的任何变动也不会影响购买者,比如某个服务员离职,售楼处从一楼搬到二楼,这些改变都跟客人无关。

第一点说明发布订阅模式可以广泛用于异步编程。以ajax请求为例,你可以订阅成功和失败的返回,前端就不必关心后端运行过程,报了什么错误。我就可以把自己的关注点放在错误怎么展示,成功怎么展示。

第二点说明,对象之间硬编码的通知机制是可以优化的。

事件和自定义事件

document.body.addEventListener('click',()=>{  alert(1)})
document.body.addEventListener('click',()=>{  alert(2)})
document.body.addEventListener('click',()=>{  alert(3)})

顾名思义,body标签监听(订阅)了点击事件。无论你添加几个订阅着,彼此都没有影响。可见发布订阅模式也是老生常谈。

那么问题来了,我如何给标签自定义一个事件,能够用类似addEventListener的形式写出来呢?

发布订阅模式实现有以下要点:

  • 指定发布者
  • 给发布者一个缓存列表:用于存放订阅者回调函数
  • 发布消息时,发布者遍历这个缓存列表,依次促发该订阅者的回调函数。

通常,还会给回调函数输入一些参数。

let announce={};// 定义订阅者
announce.listeners=[];// 增加订阅者
announce.addListener=function(fn){  this.listeners.push(fn);}// 发布触发
announce.trigger=function(...args){
    let fn=null;  
    this.listeners.forEach((item,index)=>{
        let fn= item;    
        fn.apply(this,args);  
    })
}

接下来测试一下:

const lilei=({sex})=>{
    console.log('lilei likes',sex)
}
const hanmeimei=({sex})=>{
    console.log('hanmeimei likes',sex)
}
announce.addListener(lilei);
announce.addListener(hanmeimei);
announce.trigger({sex:'male'});
/*
* lilei likes male
* hanmeimei likes male
*/

当发布者发布事件后,无论是李雷还是韩梅梅都接收到了统一的推送。然而这个匹配显示是不对的。李雷应该只接受male,韩梅梅应该是female,问题是他们两个都接收了推送,缺乏过滤机制。

解决方案之一是增加一个唯一的标示key。让订阅者只接受自己关心的信息。

let announce={};
// 定义订阅者
announce.listeners={};
// 增加订阅者
announce.addListener=function(key,fn){
    this.listeners[key]=this.listeners[key]?this.listeners[key]:[];  
    this.listeners[key].push(fn);
}
// 发布触发
announce.trigger=function(...args){
    //取出消息类型  
    var key=Array.prototype.shift.call(args);  
    if(!this.listeners[key]){
        return false; 
    }  
    this.listeners[key].forEach((item,index)=>{
        let fn= item;    
        fn.apply(this,args);  
    })
}
const lilei=(sex)=>{
    console.log('lilei likes',sex)
}
const hanmeimei=(sex)=>{
    console.log('hanmeimei likes',sex)
}
announce.addListener('lilei',lilei);
announce.addListener('hanmeimei',hanmeimei);

announce.trigger('lilei','female');
announce.trigger('hanmeimei','male');
// lilei like female
// hanmeimei like male

这样,李雷有了自己的专属事件"lilei",韩梅梅亦是如此,就可以让每个人订阅自己感兴趣的事件了。

通用实现

然而代码还是不可用, addListener已经很像传统的addEventListener,但是必须在announce对象上进行。通用的实现是希望一类对象都拥有属于自己的发布订阅功能。那可以给你需要的对象"安装"上这个功能。实际上就是一个mixin(混入)实现。

// annouce不用改
const installAnnounce=(obj)=>{
    Object.keys(announce).forEach((key,i)=>{
        obj[key]=announce[key];    
    });    
    return obj;
}
let _aaa={}let aaa=installAnnounce(_aaa);
aaa.addListener('event1',function(...args){
    console.log('event1 '+args)
});
aaa.trigger('event1','occured');

取消订阅

事件有绑定就有解绑,增加一个removeListener方法,

announce.removeListener=function(key,fn){
    let fns=this.listeners[key];    
    if(!fns||fns.length==0){
        return false;    
    }    
    fns.forEach((_fn,i)=>{
        if(_fn==fn){      
            fns.splice(i,1);        
        }    
    })
}

测试用例:

const e1=(...args)=>{
    console.log('e1 '+args)
}
const e2=(...args)=>{
    console.log('e2 '+args)
}
aaa.addListener('event',e1);
aaa.removeListener('event',e1);
aaa.addListener('event',e2);
aaa.trigger('event','occured');
// e2 occured

同理还能实现一个unbind方法,直接delete掉就行了:

announce.unbind=function(key){
  delete this.listeners[key];
}

登录的套路

假设我们在开发一个商城,需要根据登录后做以下刷新以下组件:

  • 头部header(用户名)
  • 导航nav
  • 消息列表msgList
  • 购物车Cart

….

这是我们最为熟悉的操作方式。通常也觉得挺合理。无可诟病。

用程序的表达就是:

const userInfo=await Login();
if(userInfo.success){
    header.setInfo(userInfo);  
    nav.setAvatar(useInfo);  
    msgList.refresh(userInfo);  
    cart.refresh(userInfo);
}

可以看到几个组件对userInfo产生了强耦合。而面向实现的编程常常是为人所诟病的。

小剧场:烂尾项目是怎样炼成的 项目做完了。客户要求增加一个切换账号的功能:你把这段代码几乎原封不动地copy到新的地方。 完事后需求又说,我们再加一个收货地址列表吧。你又不得不在切换账号和登录两个地方加上你的登录逻辑。 等不及搞完这个功能,测试说,每个组件用的方法名不符合规范(Ps:这种所谓规范不排除是当初信息沟通不畅或是无中生有的),统一改成refresh吧,于是你在项目不同文件中反复的查找替换检查。 随着时间流逝,你越来越疲于应付这种突入其来的业务需求,项目愈发难以维护。人也离跑路不远了。

但如果用发布订阅模式来重构这段代码,结局就不同了。

const userInfo=await Login();
if(userInfo.success){
    login.trigger('loginSucc',userInfo);
}

重构增加了一个login对象作为发布者,代码已经清爽了很多。接下来再到各个组件中订阅login:

const header = (function () {
    //header模块    
    login.listen('loginSucc', function (userInfo) {
        header.refresh(userInfo);    
    });    
    return {
        refresh: function (data) {
            console.log('刷新用户信息');        
        }    
    }
})();
// ...

同理,如果你的产品让让你根据登录做多一个刷新收货地址的功能,只要在对应的组件订阅login对象即可:

const address = (function () {
    //header模块    
    login.listen('loginSucc', function (userInfo) {
        header.refresh(userInfo);    
    });    
    return {
        refresh: function (data) {
            console.log('刷新地址');        
        }    
    }
})();

这样组件方法叫什么名字,在哪个业务场景使用完全不关登录的事。在多人协作中,你可以直接把功能扔给开发改功能的同事。到点就可以下班走人了。

全局的订阅发布对象

让我们用洁癖者的眼光来看之前的代码,登录订阅实现还有一些问题:

  • 我们给每个订阅者都添加了listen和trigger方法,以及一个缓存列表listeners,这是一种资源浪费。
  • 如果订阅者不止关心一件事情,还得去写多一个addListener。

为了节省资源,考虑用一个全局的对象来实现它。让订阅者和订阅发布对象接耦。

那就借鉴jq的写法:

const announce=(function(){
    const listenrs={};  
    const addListener=...  
    const removeListener=...  
    const trigger=...
    return {
        listeners,addListener,removeListener,trigger  
    }
})();

当然,笔者觉得还是用Es6语法更加舒适:

// Announce.js
let listeners={};
class announce {
    static addListener(key, fn) {
        listeners[key]= listeners[key] ? listeners[key] : [];
        listeners[key].push(fn);       
    }
    static removeListener(key, fn){
        let fns = listeners[key];        
        if (!fns || fns.length == 0) {
            console.warn('删除的事件未找到');            
            return false;        
        }        
        fns.forEach((_fn, i) => {
            if (_fn == fn) {
                fns.splice(i, 1);
            }        
        });    
    }
    static trigger(...args){
        //取出消息类型        
        const key = Array.prototype.shift.call(args);        
        let fns = listeners[key];        
        if (!fns) {
            console.warn('未配置的事件!')            
            return false;        
        }        
        fns.forEach((fn, index) => {
            fn.apply(this, args);        
        })    
    }
};
module.exports=announce;

自此,我们已经完成了一个伟大的工作了。

模块通信

上一节的代码中,不同的模块可以借助announce对象进行通信了。在流行的mvvm框架中都会使用这个模式。

在这里,我们借助node来跑这段程序。一共分为4个模块:index为全局入口,通用类Announce.js,负责派派发和展示的业务模块Show.js,执行事件的Add.js。

// Show.js
const announce = require('./Announce');
const show = () => {
    let count = 0;    
    const _show = (args) => {
        count += args        
        console.log(count)    
    }    
    announce.addListener('show', _show);
}
// 订阅
module.exports = show();
// Add.js
const announce=require('./Announce');
module.exports=()=>{
    announce.trigger('show',1)
}

在index.js中,只要add,就自动输出show。

// index.js
const show=require('./Show');
const add=require('./Add');
add();
add();
add();
// 1 2 3

自此就用es6完成了模块间的全局通信功能。

当然你可以对show方法解耦合:

// dispatchShow.js
const announce = require('./Announce');
const show=require('./methods/show')
const dispatchShow = () => {
    announce.addListener('show',show);
}
module.exports = dispatchShow();
// show.js
let count=0;
module.expors=show(args) {
    count += args        
    console.log(count)
}

发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是MVC还是MVVM,都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。但在这里要留意另一个问题,模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-11-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 一Li小麦 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 事件和自定义事件
  • 通用实现
  • 取消订阅
  • 登录的套路
  • 全局的订阅发布对象
  • 模块通信
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档