发布-订阅模式
本文参考书籍:《javascript设计模式》、《javascript设计模式与开发实践》
发布-订阅模式简介
发布-订阅模式又称为观察者模式。既然是观察者模式,那么就存在两个角色:观察者和被观察者,也被称为发布者和订阅者。该模式描述的是对象及其行为和状态之间的关系。在javascript中的实质就是:你可以对程序中某个对象(对象即状态的发布者)的状态进行观察,在其发生改变时收到通知并做一些后续的处理。
我们首先以生活中的实际例子来描述一下发布者和订阅者这两种角色。
小明最近看上了一套房子,到了售楼处之后才被告知,该楼盘的房子早已售罄。好在售楼MM告 诉小明,不久后还有一些尾盘推出,开发商正在 办理相关手续,手续办好后便可以购买。但到底是什么时候,目前还没有人能够知道。
于是小明离开之前,把电话号码留在了售楼处。售楼MM答应他,新楼盘一推出就马上发信息通知小明。小明的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼MM会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知小明和其他买房子的人。小明收到消息后可以对信息做进一步处理等。
发布订阅模式的优缺点
优点:
- 支持简单的广播通信,当对象状态发生改变时,会自动通知已经订阅过的对象。比如上面的例子,小明,小红不需要天天去售楼处看有没有新房源,在合适的时间点,售楼处有新房源的时候,售楼MM会通知该订阅者(小明)。
- 发布者与订阅者耦合性降低,发布者只管发布一条消息出去,它不关心这条消息如何被订阅者使用,同时,订阅者只监听发布者的事件名,只要发布者的事件名不变,它不管发布者如何改变;同理售楼处(发布者)它只需要将有新房源这件事告诉订阅者(小明),他不管小明到底买还是不买,还是买其他售楼处的。只要有新房源就通知订阅者即可。
对于第一点,我们日常工作中也经常使用到,比如我们的ajax请求,请求有成功(success)和失败(error)的回调函数,我们可以订阅ajax的success和error事件。我们并不关心对象在异步运行的状态,我们只关心success的时候或者error的时候我们要做点我们自己的事情就可以了。
缺点:
- 创建订阅者需要消耗一定的时间和内存。
- 虽然可以弱化对象之间的联系,如果过度使用的话,反而使代码不好理解及代码不好维护等等。
实现一个发布者-订阅者模式
由上面的例子我们可以知道,实现一个发布-订阅模式主要分三步。
- 首先要指定好谁充当发布者(比如售楼处);
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售楼处的花名册);
- 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历花名册,挨个发短信)。
另外,我们还可以往回调函数里填入一些参数, 订阅者可以接收这些参数。这是很有必要的,比如售楼处可以在发给订阅者的短信里加上房子的单价、面积、容积率等信息,订阅者接收到这些信息之后可以进行各自的处理。
发布-订阅模式具体实现方式如下:
//定义售楼处 let salesOffices = {}; //缓存列表,存放订阅者的回调函数 salesOffices.clientList = []; //添加订阅者 salesOffices.listen = function(fn) { salesOffices.clientList.push(fn); } //发布消息 salesOffices.trigger = function(){ for (let i = 0; i < salesOffices.clientList.length; i++) { salesOffices.clientList[i].apply(this,arguments); } } salesOffices.listen( function( price, squareMeter ){ // 小明订阅消息 console.log( '价格= ' + price ); console.log( 'squareMeter= ' + squareMeter ); }); salesOffices.listen( function( price, squareMeter ){ // 小红订阅消息 console.log( '价格= ' + price ); console.log( 'squareMeter= ' + squareMeter ); }); salesOffices.trigger( 2000000, 88 ); salesOffices.trigger( 3000000, 110 );
至此,我们已经实现了一个最简单的发布—订阅 模式,但这里还存在一些问题。我们看到订阅者接收到了发布者发布的每个消息。
但是小明只想买88平方米的房子,发布者把110平方米的信息也推送给了小明,这对小明来说是不必要的困扰。所以我们有必要增加一个标志key,让订 阅者只订阅自己感兴趣的消息。
这样的话,缓存列表中还需要存储一个key的值来标识订阅某一类消息的订阅者,因此列表应该改为对象。具体实现如下所示:
//定义售楼处 let salesOffices = {}; //缓存对象,存放订阅者的回调函数 salesOffices.clientList = {}; //添加订阅者 salesOffices.listen = function(key,fn) { if (!this.clientList[key]){ // 如果还没有订阅过此类消息,给该类消息创建一个缓存列表 this.clientList[key] = []; } this.clientList[ key ].push( fn ); // 订阅的消息添加进消息缓存列表 } //发布消息 salesOffices.trigger = function(){ let key = arguments[0]; //获得发布的消息类型 let fns = this.clientList[key]; if (!fns || fns.length == 0) { return; } for (let i = 0; i < fns.length; i++) { //console.log(arguments); fns[i].apply(this,Array.prototype.slice.call(arguments,1)) } } salesOffices.listen( 'squareMeter88', function( price ){ // 小明订阅88平方米房子的消息 console.log( '价格= ' + price ); }); salesOffices.listen( 'squareMeter110', function( price ){ // 小红订阅110平方米房子的 消息 console.log( '价格= ' + price ); }); salesOffices.trigger( 'squareMeter88', 2000000 ); // 发布88平方米房子的价格 salesOffices.trigger( 'squareMeter110', 3000000 ); // 发布110平方米房子的价格
发布-订阅模式的封装实现
如果其他对象也需要用到发布-订阅模式,比较好的方法是:对于需要在多处地方运行的代码,我们通常使用一个函数把其封装起来。
let event = { //实现发布订阅功能的通用框架 clientList: {}, //事件的缓存列表 listen: function(key,fn) { //订阅消息并添加至缓存列表 if (!this.clientList[key]){ // 如果还没有订阅过此类消息,给该类消息创建一个缓存列表 this.clientList[key] = []; } this.clientList[ key ].push( fn ); // 订阅的消息添加进消息缓存列表 }, trigger: trigger = function(){ //发布指定类型的消息给订阅者 let key = arguments[0]; //获得发布的消息类型 let fns = this.clientList[key]; if (!fns || fns.length == 0) { return; } for (let i = 0; i < fns.length; i++) { //console.log(arguments); if (fns[i] == null) { fns.slice(i,1); i--; } fns[i].apply(this,Array.prototype.slice.call(arguments,1)) } }, remove: function(key,fn) { //移除指定的订阅事件 let fns = this.clientList[key]; if (!fns || fns.length == 0) { // 如果key对应的消息没有被人订 阅,则直接返回 return ; } if (!fn) { // 如果没有传入具体的回调函数, 表示需要取消key对应消息的所有订阅 fns.length = 0; return ; } for (let i = 0; i < fns.length; i++) { if (fns[i] == fn) { fns[i] = null; } } } }; //实现给一个发布者对象绑定发布订阅功能的函数 let installEvent = function(obj){ for (let key in event) { if (event.hasOwnProperty(key)) { obj[key] = event[key]; } } }
发布-订阅模式的适用场景
如果希望实现模块与模块之间的高内聚、低耦合化,以提高代码的可扩展性和复用性。则可以考虑使用该模式。具体以网站登录为例。
假如我们正在开发一个商城网站,网站里有 header头部、nav导航、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件, 就是必须先用ajax异步请求获取用户的登录信息。这是很正常的,比如用户的名字和头像要显示在header模块里,而这两个字段都来自用户登录后返回的信息。
至于ajax请求什么时候能成功返回用户信息,这点我们没有办法确定。现在的情节看起来像极了售楼处的例子,小明不知道什么时候开发商的售楼手续能够成功办下来。
但现在还不足以说服我们在此使用发布—订阅模 式,因为异步的问题通常也可以用回调函数来解决。比如下面这样的形 式:
login.succ(function(data){ header.setAvatar( data.avatar); // 设置 header模块的头像 nav.setAvatar( data.avatar ); // 设置导 航模块的头像 message.refresh(); // 刷新消 息列表 cart.refresh(); // 刷新购 物车列表 });
上面的代码除了返回登陆成功后的打他,还处理了header头 部、nav导航、消息列表、购物车等模块的信息,这使得登录模块和其他模块之间产生了强耦合的关系。
现在登录模块是我们负责编写的,但我们还必须了解header模块里设置头像的方法叫setAvatar、购物车模块里刷新的方法叫refresh,这种耦合性会使程序变得僵硬, header模块不能随意再改变setAvatar的方法名,它自身的名字也不能被改为header1、 header2,因为改了的话则登录模块里的名字也必须一起改。
这显然增加了业务的复杂性,也不利于模块功能的扩展。如果以后有其它模块。比如项目中又新增了一个收货地址管理的模块,这时还要再改动登录模块里的代码,如下所示:
login.succ(function(data){ header.setAvatar( data.avatar); // 设置 header模块的头像 nav.setAvatar( data.avatar ); // 设置导 航模块的头像 message.refresh(); // 刷新消 息列表 cart.refresh(); // 刷新购 物车列表 address.refresh(); // 增加这行代码 });
我们就会越来越疲于应付这些突如其来的业务要求,要么跳槽了事,要么必须来重构这些代码。
用发布—订阅模式重写之后,对用户信息感兴趣的业务模块将自行订阅登录成功的消息事件。当登录成功时,登录模块只需要发布登录成功的消息,而业务方接受到消息之后,就会开始进行各自的业务处理,登录模块并不关心业务方究竟要做什么,也不想去了解它们的内部细节。改善后的代码如下:
$.ajax( 'http:// xxx.com?login', function(data){ // 登录成功 login.trigger( 'loginSucc', data); // 发布登录成功的消息 });
各模块监听登录成功的消息:
var header = (function(){ // header模块 login.listen( 'loginSucc', function( data) { header.setAvatar( data.avatar ); }); return { setAvatar: function( data ){ console.log( '设置header模块的头像' ); } } })(); var nav = (function(){ // nav模块 login.listen( 'loginSucc', function( data ){ nav.setAvatar( data.avatar ); }); return { setAvatar: function( avatar ){ console.log( '设置nav模块的头像' ); } } })();