8发布-订阅模式

来源:JavaScript设计模式与开发实践
【8发布-订阅模式】发布-订阅模式:又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象发生改变时,所有依赖于它的对象都将得到通知。在JS中,一般用事件模型来代替传统的发布-订阅模式。
2. 发布-订阅模式的作用
  • 发布订阅模式可以广泛应用于异步编程中,比如ajax中的succ,error等。
  • 发布订阅模式可以取代对象之间硬编码的通知机制。一个对象不用再显式的调用另外一个对象的接口,让两个对象松耦合的联系在一起。
3. DOM事件即发布-订阅模式
document.body.addEventListener( 'click', function(){ alert(2); }, false ); document.body.click();

4. 自定义事件
//简单的发布-订阅模式 var subscribe = {}; //定义发布者 subscribe.clientList = []; //缓存列表,存放订阅者的回调函数 subscribe.listen = function(fn) { //增加订阅者 subscribe.clientList.push(fn); //订阅的消息添加进缓存列表 }; subscribe.trigger = function() { for (var i = 0, fn; fn = this.clientList[i++]; ) { fn.apply(this, arguments); } }; subscribe.listen(function(price, squareMeter) { console.log(squareMeter + ':' + price); }); subscribe.trigger(200, 1); subscribe.trigger(300, 2); //缺点:订阅者接收到了发布者发布的每一个消息

增加标识Key,使订阅者只获取自己想要的消息
var subscribe = {}; //定义发布者 subscribe.clientList = []; //缓存列表,存放订阅者的回调函数 subscribe.listen = function(key,fn) { //增加订阅者 if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); //订阅的消息添加进缓存列表 }; subscribe.trigger = function() { var key = [].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); //发送消息时附送的参数,第一个参数消息类型已通过shift方法去除 } }; subscribe.listen('click', function(status) { console.log('click ' + status); }); subscribe.listen('move', function(status) { console.log('move ' + status); }); subscribe.trigger('click', 'success'); subscribe.trigger('move', 'success too');

5. 发布-订阅模式的通用实现
//动态让对象都拥有发布-订阅模式 var event = { clientList: [], listen: function(key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); }, trigger: function() { var key = [].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); } } } var installEvent = function(obj) { for (const key in event) { obj[key] = event[key]; } } //installEvent 函数可以为所有对象动态安装发布-订阅模式 var subscribe = {}; installEvent(subscribe); subscribe.listen('eventClick', function(res) { console.log('eventClick ' + res); }); subscribe.trigger('eventClick','success!');

6. 取消订阅
subscribe.remove = function(key, fn) { var fns = this.clientList[key]; if (!fns) { //对应的消息没有订阅,则直接返回 return false; }; if (!fn) { //若没有传具体的回调函数,表示要取消key对应的所有订阅 fns && (fns.length = 0); } else { for (var l = fns.length -1; l >= 0; l--) { //反向遍历订阅的 var _fn = fns[l]; if (_fn === fn) { fns.splice(l, 1); } } } }subscribe.listen('example', fn1 = function(res) { console.log(1, res); }); subscribe.listen('example', fn2 = function(res) { console.log(2, res); }); subscribe.remove('example',fn1); subscribe.trigger('example', 'success');

7. example —— 网站登录
login.success(function(data) { header.setAvatar(); // 设置header模块头像 nav.setAvatar(); //设置nav模块头像 message.refresh(); //刷新消息列表 cart.refresh(); //刷新购物车列表 });

上面这种情况,通过回调函数解决异步时,不同模块与登录模块产生了强耦合,这种耦合会使程序变得僵硬,这是针对具体实现编程的典型例子。
$.ajax('//xxx.login.com',function(data){ login.trigger('loginsucc',data); }); var header = (function() { login.listen('loginsucc', function(data) { header.setAvatar(data.avatar); }); return { loginsucc: function(avatar) { console.log('set header avatar success!'); } } })(); var nav = (function() { login.listen('loginsucc', function(data) { nav.setAvatar(data.avatar); }); return { nav: function(avatar) { console.log('set nav avatar success!'); } } })();

用发布-订阅模式重写之后,对用户登录感兴趣的业务将自行订阅登录成功的事件,登录成功时,登录模块只需要发布登录成功的消息,业务方接收到消息之后开始进行各自的业务处理,登录模块不必再处理业务方的业务细节。
8.全局的 发布-订阅对象
var Event = (function() { var clientList = [], listen, remove, trigger; listen = function (key, fn) { if (!clientList[key]) { clientList[key] = []; } clientList[key].push(fn); }; remove = function (key, fn) { var fns = clientList[key]; if (!fns) return false; if (!fn) { fns.length = 0; }; for (var i = fns.length; i >= 0; i--) { var _fn = fns[i]; if (fn === _fn) { fns.splice(i, 1); } } }; trigger = function() { var key = Array.prototype.shift.apply(arguments); var fns = clientList[key]; if (!fns) return; for( var i = 0; i < fns.length; i++) { fn = fns[i]; fn.apply(this, arguments); //改变this指向,使listen(key,fn)中fn的this指向Event } } return { listen: listen, remove: remove, trigger: trigger }; /*研究后认为,作者之所以不写成 var Event = {clientList:[],listen:fn,remove:fn,trigger:fn}, * 而写成闭包方式,可能是不想暴露clientList,使Event只暴露出listen,remove,trigger三个方法 */ })(); Event.listen('globalPublish', function(success) { //first subscribe console.log(this); console.log('first subscribe' + success); }); Event.listen('globalPublish', function(success) { //second subscribe console.log('second subscribe' + success); }); Event.trigger('globalPublish', '1'); //the second publish setTimeout(function() { Event.trigger('globalPublish', '2s'); //the second publish },2000); /* 全局发布使得发布与订阅直接相互脱离。 * 从5的例子中可以看出具有以下两个问题: * 每个发布者对象都需要listen,remove,trigger方法和一个缓存队列,十分浪费资源 * 发布者与订阅者还是具有一定的耦合性,订阅者必须知道发布者的名称,才可以进行订阅 */

9.模块间通信
var a = (function() { var count = 0; botton = document.getElementById('a'); botton.onclick = function() { Event.trigger('getCount',count++); } })(); var b = (function() { var text = document.getElementById('b'); Event.listen('getCount', function(data) { text.innerHTML = data; }); })();

10. 先订阅后发布,与先发布与后订阅
正常情况:订阅者先订阅一个消息,发布者再发布消息,否则没有订阅,发布者发布的消息就没有对象来接收。
例外:某些情况,需要先将发布者发布的消息保存下来,等到有对象订阅时,再重新推给订阅者。
这种需求在实际项目中是存在的,比如在之前的商城网站中,获取到用户信息之后才能渲染用户导航模块,而获取用户信息的操作是一个 ajax 异步请求。当 ajax 请求成功返回之后会发布 一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息。
而ajax请求时异步的,我们不能确定异步的请求时间,有可能在ajax请求成功时,nav模块还没有加载完成,此时还没有订阅事件,特别是在运用了一些模块化惰性加载的技术后,这种情况更加可能发生。
此时,我们需要建立一个存放离线事件的堆栈,如果此时还没有订阅事件,我们把发布事件的动作包裹在一个函数里,这些包装函数将被存放进堆栈中,等有订阅对象来订阅的时候,遍历堆栈并依次执行这些包装函数,即重新发布事件。并且注意离线事件的生命周期只有一次。
11. 全局事件的命名冲突
为Event对象提供创建命名空间的功能,防止事件命名冲突
var Event = (function() { var glabal = this, Event, _default = 'default'; Event = (function() { var _shift = Array.prototype.shift,//移除数组头部第一个元素并返回该元素 _unshift = Array.prototype.unshift,//在头部插入元素生成新的数组 nameSpaceCache = {}, //命名空间缓存存储 find; var each = function(arys, fn) { //arys:一组函数 var ret; for (var i = 0; i < arys.length; i++) { varary = arys[i]; ret = fn.call(ary, i, ary); //this指向ary所代表的函数本身 } return ret; }; var _listen = function(key, fn, cache) { if (!cache[key]) { //是否存在该类消息订阅,若无,创建缓存列表 cache[key] = []; } cache[key].push(fn); }; var _trigger = function() { var cache = _shift.call(arguments), //订阅者的回调函数缓存队列 key = _shift.call(arguments), //订阅的事件类型 args = arguments, // 订阅事件发布的结果 _self = this, ret, stack = cache[key]; // 缓存的关于key的订阅者的回调函数if (!stack || !stack.length) { //若没有相关订阅 return; } return each(stack, function() { // _self 此时指的是 _create函数所返回的对象,因为 _trigger被调用时,通过_trigger.apply(_self, args); 改变了this的指针 return this.apply(_self, args); }); }; var _remove = function(key, cache, fn) { if (cache[key]) { if (fn) { for (var i = cache[key].length; i >=0; i--) { if (cache[key][i] === fn) { cache[key].splice(i, 1); } } } else { cache[key] = []; } } }; var _create = function(namespace) { var namespace = namespace || _default, //命名空间 cache = {}, //缓存列表,存放订阅者的回调事件 offlineStack = []; //离线发布者缓存 var ret = { listen: function(key, fn, last) { /*_create函数返回一个可以调动函数的对象,生成一个闭包, *每一个命名空间内的订阅相同内容即(key)的订阅事件都被存储近cache, *cache 在闭包中被保存起来,每生成不同的 */ _listen(key, fn, cache); // 向缓存列表插入订阅者的回调事件 if (offlineStack === null) { // 判断是否有还存在离线事件,如果没有,则不再执行离线事件 return; } if (last === 'last') { //执行离线事件队列中的最后一个 offlineStack.length && offlineStack.pop()(); //pop()方法 remove the last element from the array and return the element //offlineStack.pop()是函数,offlineStack.pop()()代表执行该函数 } else { //依次执行离线事件 each(offlineStack, function() { this(); // this是包裹发布事件的函数 }); } offlineStack = null; //离线事件的生命周期只有一次 }, one: function(key, fn, last) { _remove(key, cache); this.listen(key, fn, last); }, remove: function(key, fn) { _remove(key, cache, fn); }, trigger: function() { var fn, args, _self = this; //this指_create函数返回的对象;作为对象属性调用,指向当前对象 _unshift.call(arguments, cache); // 往arguments头部加入cache,生成一个新数组args = arguments; fn = function() { // 把发布事件的动作包裹在函数里,在有事件订阅后再调用 return _trigger.apply(_self, args); } if (offlineStack) { // 如果listen方法还未被调用,即离线事件还未被执行,将离线事件存储中插入包装好的函数 return offlineStack.push(fn); } return fn(); // 若已有订阅,则调用fn函数,处理订阅事件发布的函数 } }; return namespace ? (nameSpaceCache[namespace] ? nameSpaceCache[namespace] : nameSpaceCache[namespace] = ret) : ret; }; return { create: _create, one: function(key, fn, last) { // one函数指订阅key的事件保证只有一个订阅者 var event = this.create(); event.one(key, fn, last); }, listen: function(key, fn, last) { var event = this.create(); event.listen(key, fn, last); }, remove: function(key, fn) { var event = this.create(); event.remove(key, fn); }, trigger: function() { var event = this.create(); event.trigger.apply(this.arguments); } } })(); return Event; })(); Event.create('namespace1').trigger('click','namespace1 success'); Event.create('namespace1').listen('click', function(res) { console.log(res); }); Event.create('namespace2').listen('click', function(res) { console.log(res,222); }); Event.create('namespace2').one('click', function(res) { console.log(res,111); }); Event.create('namespace2').one('click', function(res) { console.log(res,123); }); Event.create('namespace2').trigger('click', 'success2'); Event.create('namespace2').trigger('click', 'success2');

12 JS实现发布-订阅模式的便利性
js中实现发布-订阅模式与别的语言实现方式不同。在Java中,实现发布订阅模式,通常会把订阅者对象自身当成引用传入发布者对象中,同时订阅者对象还需要提供一个名为例如update的方法,供发布者对象在合适的时候调用。在js中,用注册回调函数的形式来替代传统的发布-订阅模式,显得更加简洁和优雅。
另外,在JS中无需选择使用推模式还是拉模式。推模式是指事件发生时,发布者一次性把所有更改状态和数据都退送给订阅者;而拉模式是发布者仅仅通知订阅者事件发生了,此外发布者要提供一些公开的接口供订阅者主动拉取数据,拉模型好处是可用让订阅者按需获取,但同时有可能让发布者变成一个门户大开的对象,同时增加了代码量和复杂度。
刚好在js中,arguments可用很方便的表示参数列表,因为我们一般会选择推模型,使用function.prototype.apply来把所有参数推给订阅者。
13小结
优点:一是时间上的解耦,二是对象上的解耦。
缺点:创建订阅要消耗一定时间和内存,而当订阅一件时间后,也可能此消息最后都没有发生,但这个订阅会始终存在内存中。并且发布-订阅模式虽然可以弱化对象之间的联系,但过度使用的话,对象与对象间的必要联系也将深埋在背后,会导致程序难以跟踪维护和理解。

    推荐阅读