发布-订阅模式是什么?
发布-订阅模式又叫做观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变的时候,所有依赖于它的对象都将得到通知。
作为一名JavaScript开发者,我100%相信你已经使用过了这个模式,不信你看如下代码:
document.body.addEventListener('click',function(){ console.log('执行了点击事件'); })
在这里我们为body加上了一个点击事件,相当于我们订阅了点击事件,但是我们不关系,它什么时候触发,但是一旦触发点击事件,那么就会执行我们所写功能函数。
这个就是一个简单的应用。我们在来看一个例子:
var obj = {name: 'ydb'}; Object.defineProperty(obj,'name',{ set: function(){ console.log('更新了'); } }) obj.name = 'ydb11';
在这里我们订阅了name属性的更新,一旦name发生改变,就会执行set函数,同样我们并不关心name什么时候更新,但是只要更新,就会触发我们定义的set函数,从而执行相关的操作。
仔细想一下,你在日常开发中除了使用DOM事件外,有没有使用过自定义事件,比如vue中子组件向父组件通信,看代码:
假设有那么一个场景:小明要去买房,但是没有他喜欢的房源,所以他就留下了自己的联系方式和要求给售房处,一旦有了符合自己要求的房子,就打电话给他。这个时候小红也来买房子,和小明一样没有喜欢的房子,于是也留下了自己的联系方式和要求。
1.有了符合自己要求的,售房处就会主动联系自己,不需要自己每天打电话问有没有符合自己的房子。
2.售房处只要记得有了房子,通知这些买家就行了,其他的因素影响不了这个操作。比如售房处搬家了,之前的员工辞职了,这些都无关紧要,只要在新的地方或者新的员工记得打电话通知就行了。
3.最后发布消息的时候,遍历缓存列表,依次触发里面的回调函数(遍历花名册,挨个打电话通知)
看代码:
// 定义售房处 var salesOffices = {}; // 定义花名册 salesOffices.clientList = []; // 留下联系方式 订阅消息 salesOffices.on = function (callback) { this.clientList.push(callback); } salesOffices.emit = function () { for (var i = 0, fn; fn = this.clientList[i++];) { fn.apply(this, arguments); // arguments 发布消息时所带的参数 } } // 下面进行订阅消息 // 小明 salesOffices.on(function (price, squareMetar) { console.log('价格:' + price + '万'); console.log('面积:' + squareMetar); }); // 小红 价格300万,面积110平方米 salesOffices.on(function (price, squareMetar) { console.log('价格:' + price + '万'); console.log('面积:' + squareMetar); }); // 发布消息 小明(价格200万,面积88平方米) salesOffices.emit(200, 88); // 发布消息 小红(价格200万,面积88平方米) salesOffices.emit(300, 110);
这里我们基本上实现了这个场景,当有满足要求的房子时候,发布者只要发布消息,订阅者就能做出相关的事情,挺好的,看一下测试结果:
结果正确,但是注意现在的代码中,不管哪个订阅者被满足的时候,其他订阅者也会收到消息,这也就是为什么会出现四次打印结果的原因。设想一下假如有100个买房子的人,只要其中一个满足条件了,其他的买房子的人也会收到电话。我擦这谁顶的住啊,别人买的房子给我打什么电话,我tm一天都被电话轰炸了,所以必须修改上面的代码。
且看代码:
// 定义售房处 var salesOffices = {}; // 定义花名册 salesOffices.clientList = {}; // 留下联系方式 订阅消息 salesOffices.on = function (key, callback) { if (!this.clientList[key]) { // 如果没有订阅此类消息,就给该类消息创建一个缓存列表 this.clientList[key] = []; } this.clientList[key].push(callback); // 消息加入缓存列表 } salesOffices.emit = function () { var key = Array.prototype.shift.call(arguments); //取出消息类型 var fns = this.clientList[key]; // 取出该消息类型下的回调函数的集合 if (!fns || fns.length === 0) { // 如果没有订阅消息,则返回 return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); // arguments 发布消息时所带的参数 } } // 下面进行订阅消息 // 小明 salesOffices.on('squareMeter88', function (price) { console.log('价格:' + price + '万'); }); // 小红 价格300万,面积110平方米 salesOffices.on('squareMetar110', function (price) { console.log('价格:' + price + '万'); }); // 发布消息 小明(价格200万,面积88平方米) salesOffices.emit('squareMeter88', 88); // 发布消息 小红(价格200万,面积88平方米) salesOffices.emit('squareMetar110', 110);
现在只有符合自己要求的订阅者,才会收到电话,这样子就合理多了。
在我们日常开发中,增加需求是很常见的事情,这里也是,小明有点不放心这个售房处,期间他又找了许多售房处,并登记了信息。通过上面测例子我们可以看出,售房处的代码还是有点多的,多个售房处,就有多个相同的操作,那是不是每一个售房处,都要这样子写?可以是可以,但是太麻烦了,我们想着如果把订阅发布那部分统一出来,那岂不是很简单了。
看代码:
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加缓存列表 }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果没有绑定对应的消息 } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this.arguments); // arguemnts是emit时候带上的参数 } } }
这里我们封装了一个发布-订阅的对象,里面具备完整的功能,现在只要有新的售房处出现,就可以直接复用里面的代码:
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加缓存列表 }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果没有绑定对应的消息 } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this,arguments); // arguemnts是emit时候带上的参数 } } } var initalEvent = function (obj) { for (key in event) { obj[key] = event[key]; } } var salesOffices1 = {}; // 给售房处添加发布-订阅功能 initalEvent(salesOffices1); salesOffices1.on('squareMeter88', function (price) { console.log('价格:' + price + '万'); }) salesOffices1.emit('squareMeter88', 200)
就这样子操作,所有售房处都能发布消息了,initalEvent相当于售房处的电话,只要买了电话,那么就可以打电话了。
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加缓存列表 }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果没有绑定对应的消息 } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this,arguments); // arguemnts是emit时候带上的参数 } }, remove: function(key,fn){ var fns = this.clientList[key]; if (!fns) { // 如果没有订阅的消息,则返回 return false; } if (!fn) { // 没有传入具体的回调函数,标示需要取消key对应的所有订阅 fns && (fns.length = 0); } else { for (var i=fns.length-1;i>=0;i--) { if (fn === fns[i]) { fns.splice(i,1) // 删除订阅的回调函数 } } } } } var initalEvent = function (obj) { for (key in event) { obj[key] = event[key]; } } var salesOffices1 = {}; // 给售房处添加发布-订阅功能 initalEvent(salesOffices1); var fn1 = function(price) { console.log('价格:' + price + '万'); } salesOffices1.on('squareMeter88', fn1); salesOffices1.emit('squareMeter88', 200); // 删除小明的订阅 salesOffices1.remove('squareMeter88',fn1); salesOffices1.emit('squareMeter88', 200);
测试如下:
嗯,没毛病老铁。
有一天小明中了五千万,想要出国买房,但是想如果能在国内买一套别墅,放在那儿升值也可以。由于之前的矛盾,他对售房处产生了不好的印象,说只给你们一次机会给我找好房子,一次过后我不满意我就要出国了,你们就联系不到我了。所以现在我们就需要实现一次订阅的事件,看看代码:
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加缓存列表 }, onece: function (key, fn) { this.on(key, fn); // 标志只订阅一次 fn.onece = true; }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果没有绑定对应的消息 } for (var i = fns.length - 1; i >= 0; i--) { var fn = fns[i]; fn.apply(this, arguments); // arguemnts是emit时候带上的参数 if (!!fn.onece) { // 删除订阅的消息所对应的回调函数 fns.splice(i, 1); } } }, remove: function (key, fn) { var fns = this.clientList[key]; if (!fns) { // 如果没有订阅的消息,则返回 return false; } if (!fn) { // 没有传入具体的回调函数,标示需要取消key对应的所有订阅 fns && (fns.length = 0); } else { for (var i = fns.length - 1; i >= 0; i--) { if (fn === fns[i]) { fns.splice(i, 1) // 删除订阅的回调函数 } } } } } var initalEvent = function (obj) { for (key in event) { obj[key] = event[key]; } } var salesOffices1 = {}; // 给售房处添加发布-订阅功能 initalEvent(salesOffices1); var fn1 = function (price) { console.log('价格:' + price + '万'); } // 小明只订阅一次 salesOffices1.onece('squareMeter88', fn1); salesOffices1.emit('squareMeter88', 200); salesOffices1.emit('squareMeter88', 200);
测试如下:
现在一看,我们这个发布-订阅功能还是很完美的,对吧!但是还存在一些问题的:
1. 我们给米一个发布者都添加了on,emit,clientList,这其实是一种浪费资源的现象
2.小明跟售房处对象还存在一定的耦合性,小明至少要知道售房处对象名字是salesOffice,才能顺利订阅事件。
想一想我们平时找房子很少直接跟房东联系的,我们大多数是跟各种各样的中介公司联系的,我们留下联系方式给中介,房东通过中介发布房源信息。
所以我们需要定制一个中介公司,也就是全局的发布-订阅对象,看代码:
var event = (function () { var clientList = {}, on, emit, remove, onece; on = function (key, fn) { if (!clientList[key]) { clientList[key] = []; } clientList[key].push(fn); }; onece = function (key, fn) { this.on(key, fn); fn.onece = true; } emit = function () { var key = Array.prototype.shift.call(arguments); var fns = clientList[key]; if (!fns || fns.length === 0) { return false; } for (var i = fns.length - 1; i >= 0; i--) { var fn = fns[i]; fn.apply(this, arguments); if (!!fn.onece) { fns.splice(i, 1); } } } remove = function (key, fn) { var fns = clientList[key]; if (!fns) { return false; } if (!fn) { fns && (fns.length === 0); } for (var i = fns.length - 1; i >= 0; i--) { if (fns[i] === fn) { fns.splice(i, 1); } } } return { on, emit, onece, remove } })(); var fn1 = function (price) { console.log('价格:' + price + '万'); } console.log('一直订阅'); event.on('squareMeter88', fn1); event.emit('squareMeter88', 200); event.emit('squareMeter88', 200); console.log('订阅一次'); event.onece('squareMeter120', fn1); event.emit('squareMeter120', 300); event.emit('squareMeter120', 300); console.log('取消订阅'); event.on('squareMeter160', fn1); event.remove('squareMeter160', fn1); event.emit('squareMeter160', 500);
看看测试结果:
果然如此。
但是在这里我们又遇到了新的问题,模块之间如果用了太多的全局发布-订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向那些模块,这个又会对我们的维护带来一定的麻烦,也许某个模块的作用就是暴露一些接口给其他模块使用。具体使用还是要根据业务场景来的。
到这里我们基本实现来发布-订阅功能,但是我们想几个问题:
我们QQ离线的时候,我们登陆QQ是不是会收到之前的离线消息,而且只能收到一次,所以说不是必须先订阅在发布,也可以先发布,之后在订阅与否是自己的事情。
我们在全局使用发布-订阅对象很方便,但是随着使用的次数增多,难免会出现事件名冲突的情况,所以我们可以给event对象提供创建命名空间的空能。
这两个需求只是我们为了更加完善我们全局的发布-订阅对象,对之前的event对象不是去颠覆,而是去升级,使其更健壮。
再加入这两个需求之后,我们最终的全局的发布-订阅对象如下:
var event = (function () { // 全局的命名空间缓存数据 var namesapceCaches = {}; var _default = 'default'; var shift = Array.prototype.shift; var hasNameSpace = function (namespace, key) { // 不存在命名空间 if (!namesapceCaches[namespace]) { namesapceCaches[namespace] = {} } // 命名空间下不存在该key的订阅对象 if (!namesapceCaches[namespace][key]) { namesapceCaches[namespace][key] = { // 该key下的订阅的事件缓存列表 cache: [], // 该key下的离线事件 offlineStack: [] } } } // 使用命名空间 var _use = function (namespace) { var namespace = namespace || _default; return { // 订阅消息 on: function (key, fn) { hasNameSpace(namespace, key); namesapceCaches[namespace][key].cache.push(fn); // 没有订阅之前,发布者发布的信息保存在offlineStack中,现在开始显示离线消息(只发送一次) var offlineStack = namesapceCaches[namespace][key].offlineStack; if (offlineStack.length === 0) { return; } for (var i = offlineStack.length - 1; i >= 0; i--) { // 一次性发送所有的离线数据 fn(offlineStack[i]); } offlineStack.length = 0; }, // 发布消息 emit: function () { // 获取key var key = shift.call(arguments); hasNameSpace(namespace, key); // 获取该key对应缓存的订阅回调函数 var fns = namesapceCaches[namespace][key].cache; if (fns.length === 0) { var data = shift.call(arguments); // 还没有订阅,保存发布的信息 namesapceCaches[namespace][key].offlineStack.push(data); return; } for (var i = fns.length - 1; i >= 0; i--) { fns[i].apply(this, arguments); if (fns.onece) { fns.splice(i, 1); } } }, remove: function (key, fn) { // 获取key var key = shift.call(arguments); // 不存在命名空间和订阅对象 if (!namesapceCaches[namespace] || !namesapceCaches[namespace][key]) { return; } // 获取该key对应缓存的订阅回调函数 var fns = namesapceCaches[namespace][key].cache; if (fns.length === 0) { return; } for (var i = fns.length - 1; i >= 0; i--) { if (fn === fns[i]) { fns.splice(i, 1); } } }, onece: function (key, fn) { this.on(key, fn); fn.onece = true; } } } return { // 用户的命名空间 use: _use, /** * 默认的命名空间 * on,emit,remove,onece都为代理方法。 */ on: function (key, fn) { var event = this.use(); event.on(key, fn); }, emit: function () { var event = this.use(); event.emit.apply(this, arguments); }, remove: function (key, fn) { var event = this.use(); event.remove(key, fn); }, onece: function (key, fn) { var event = this.use(); event.onece(key, fn); }, show: function () { return namesapceCaches; } } })();
看就是那么简单,但是这里有一个不好的地方,那就是离线消息,只要有一个对应的订阅者订阅,那么离线消息就会全部发送完毕。聪明的你可以自己再去改造一下。
下面的是我的测试代码:
console.log('先发布后订阅测试'); event.emit('111', '离线数据1'); event.emit('111', '离线数据2'); setTimeout(function () { event.on('111', function (data) { console.log(data); }) }, 2000); setTimeout(function () { event.emit('111', '在线数据'); }, 3000); console.log('默认命名空间测试----'); var fn1 = function (data) { console.log(data) } event.on('default', fn1); event.emit('default', '默认命名空间测试'); event.remove('default', fn1); event.emit('default', '默认命名空间测试'); console.log('自定义命名空间测试'); var fn1 = function (data) { console.log(data) } event.use('ydb').on('111', fn1); event.emit('ydb', '默认命名空间发布消息'); event.use('ydb').emit('111', 'ydb空间发送数据1'); event.use('ydb').remove('111', fn1); event.use('ydb').emit('111', 'ydb空间发送数据1(现在是离线数据)'); event.use('ydb').emit('111', '离线数据'); event.use('ydb').on('111', fn1); event.use('ydb').emit('111', '在线数据');
可以自己下去测试一下,看看结果是怎么样子的。用这个模式我们完全可以在自己的spa应用中实现跨组件通信。那就再见了。