第八章 发布-订阅模式
发布—订阅模式描述
- 发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
- 发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。
- 发布—订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。
发布—订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。
DOM 事件:在DOM 节点上面绑定过事件函数就是发布—订阅模式。
自定义事件:我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的发布—订阅模式可以用于任何JavaScript 代码中。
实现发布—订阅模式
- 首先要指定好谁充当发布者(比如售楼处);
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售楼处的花名册);
- 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历花名册,挨个发短信)。
- 另外,我们还可以往回调函数里填入一些参数,订阅者可以接收这些参数。
var salesOffices = {}; // 定义售楼处 salesOffices.clientList = []; // 缓存列表,存放订阅者的回调函数 salesOffices.listen = function (fn) { // 增加订阅者 this.clientList.push(fn); // 订阅的消息添加进缓存列表 }; salesOffices.trigger = function () { // 发布消息 for (var i = 0, fn; fn = this.clientList[i++];) { fn.apply(this, arguments); // (2) // 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); // 输出:200 万,88 平方米 salesOffices.trigger(3000000, 110); // 输出:300 万,110 平方米
上面的代码还是有点问题我们看到订阅者接收到了发布者发布的每个消息,虽然小明只想买88 平方米的房子,但是发布者把110 平方米的信息也推送给了小明,这对小明来说是不必要的困扰。所以我们有必要增加一个标示key,让订阅者只订阅自己感兴趣的消息。优化代码为:
var salesOffices = {}; // 定义售楼处 salesOffices.clientList = {}; // 缓存列表,存放订阅者的回调函数 salesOffices.listen = function (key, fn) { if (!this.clientList[key]) { // 如果还没有订阅过此类消息,给该类消息创建一个缓存列表 this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加进消息缓存列表 }; salesOffices.trigger = function () { // 发布消息 var key = Array.prototype.shift.call(arguments), // 取出消息类型 fns = this.clientList[key]; // 取出该消息对应的回调函数集合 if (!fns || fns.length === 0) { // 如果没有订阅该消息,则返回 return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); // (2) // arguments 是发布消息时附送的参数 } }; salesOffices.listen('squareMeter88', function (price) { // 小明订阅88 平方米房子的消息 console.log('价格= ' + price); // 输出: 2000000 }); salesOffices.listen('squareMeter110', function (price) { // 小红订阅110 平方米房子的消息 console.log('价格= ' + price); // 输出: 3000000 }); salesOffices.trigger('squareMeter88', 2000000); // 发布88 平方米房子的价格 salesOffices.trigger('squareMeter110', 3000000); // 发布110 平方米房子的价格
发布-订阅模式的通用实现:所以我们把发布—订阅的功能提取出来,放在一个单独的对象内
var event = { clientList: [], listen: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加进缓存列表 }, trigger: function () { var key = Array.prototype.shift.call(arguments), // (1); fns = this.clientList[key]; if (!fns || fns.length === 0) { // 如果没有绑定对应的消息 return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); // (2) // arguments 是trigger 时带上的参数 } } }; var installEvent = function (obj) { for (var i in event) { obj[i] = event[i]; } }; var salesOffices = {}; installEvent(salesOffices); salesOffices.listen('squareMeter88', function (price) { // 小明订阅消息 console.log('价格= ' + price); }); salesOffices.listen('squareMeter100', function (price) { // 小红订阅消息 console.log('价格= ' + price); }); salesOffices.trigger('squareMeter88', 2000000); // 输出:2000000 salesOffices.trigger('squareMeter100', 3000000); // 输出:3000000
取消订阅的事件
event.remove = function (key, fn) { var fns = this.clientList[key]; if (!fns) { // 如果key 对应的消息没有被人订阅,则直接返回 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); // 删除订阅者的回调函数 } } } }; var salesOffices = {}; var installEvent = function (obj) { for (var i in event) { obj[i] = event[i]; } } installEvent(salesOffices); salesOffices.listen('squareMeter88', fn1 = function (price) { // 小明订阅消息 console.log('价格= ' + price); }); salesOffices.listen('squareMeter88', fn2 = function (price) { // 小红订阅消息 console.log('价格= ' + price); }); salesOffices.remove('squareMeter88', fn1); // 删除小明的订阅 salesOffices.trigger('squareMeter88', 2000000); // 输出:2000000
真实的例子——网站登录
假如我们正在开发一个商城网站,网站里有header 头部、nav 导航、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须先用ajax 异步请求获取用户的登录信息。但现在还不足以说服我们在此使用发布—订阅模式,因为异步的问题通常也可以用回调函数来解决。更重要的一点是,我们不知道除了header 头部、nav 导航、消息列表、购物车之外,将来还有哪些模块需要使用这些用户信息,需要发布—订阅模式方便后期扩展。
var login = { clientList: [], listen: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 订阅的消息添加进缓存列表 }, trigger: function () { var key = Array.prototype.shift.call(arguments), // (1); fns = this.clientList[key]; if (!fns || fns.length === 0) { // 如果没有绑定对应的消息 return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); // (2) // arguments 是trigger 时带上的参数 } } }; $.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 模块的头像'); } } })(); var address = (function () { // nav 模块 login.listen('loginSucc', function (obj) { address.refresh(obj); }); return { refresh: function (avatar) { console.log('刷新收货地址列表'); } } })();
全局的发布-订阅对象
实现发布—订阅模式例子中还有两个问题:
- 我们给每个发布者对象都添加了listen 和trigger 方法,以及一个缓存列表clientList,这其实是一种资源浪费。
- 订阅对象跟发布对象还是存在一定的耦合性,订阅对象至少要知发布对象的名字是salesOffices,才能顺利的订阅到事件。
发布—订阅模式可以用一个全局的Event 对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event 作为一个类似“中介者”的角色,把订阅者和发布者联系起来。
var Event = (function () { var clientList = {}, listen, trigger, remove; listen = function (key, fn) { if (!clientList[key]) { clientList[key] = []; } clientList[key].push(fn); }; trigger = function () { var key = Array.prototype.shift.call(arguments), fns = clientList[key]; if (!fns || fns.length === 0) { return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); } }; remove = function (key, fn) { var fns = clientList[key]; if (!fns) { return false; } if (!fn) { 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); } } } }; return { listen: listen, trigger: trigger, remove: remove } })(); Event.listen('squareMeter88', function (price) { // 小红订阅消息 console.log('价格= ' + price); // 输出:'价格=2000000' }); Event.trigger('squareMeter88', 2000000); // 售楼处发布消息
模块间通信
基于一个全局的Event 对象实现发布—订阅模式,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。比如现在有两个模块,a 模块里面有一个按钮,每次点击按钮之后,b 模块里的div 中会显示按钮的总点击次数,我们用全局发布—订阅模式完成下面的代码,使得a 模块和b 模块可以在保持封装性的前提下进行通信。
先订阅再发布
- 我们所了解到的发布—订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,这条消息无疑将消失在宇宙中。在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就如同QQ 中的离线消息一样,离线消息被保存在服务器中,接收人下次登录上线之后,可以重新收到这条消息。
为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像QQ 的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。
全局事件的命名冲突
全局的发布—订阅对象里只有一个clinetList 来存放消息名和回调函数,大家都通过它来订阅和发布各种消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给Event 对象提供创建命名空间的功能。
var Event = (function () { var global = this, Event, _default = 'default'; Event = function () { var _listen, _trigger, _remove, _slice = Array.prototype.slice, _shift = Array.prototype.shift, _unshift = Array.prototype.unshift, namespaceCache = {}, _create, find, each = function (ary, fn) { var ret; for (var i = 0, l = ary.length; i < l; i++) { var n = ary[i]; ret = fn.call(n, i, n); } return ret; }; _listen = function (key, fn, cache) { if (!cache[key]) { cache[key] = []; } cache[key].push(fn); }; _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] = []; } } }; _trigger = function () { var cache = _shift.call(arguments), key = _shift.call(arguments), args = arguments, _self = this, ret, stack = cache[key]; if (!stack || !stack.length) { return; } return each(stack, function () { return this.apply(_self, args); }); }; _create = function (namespace) { var namespace = namespace || _default; var cache = {}, offlineStack = [], // 离线事件 ret = { listen: function (key, fn, last) { _listen(key, fn, cache); if (offlineStack === null) { return; } if (last === 'last') { offlineStack.length && offlineStack.pop()(); } else { each(offlineStack, function () { 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; _unshift.call(arguments, cache); args = arguments; fn = function () { return _trigger.apply(_self, args); }; if (offlineStack) { return offlineStack.push(fn); } return fn(); } }; return namespace ? (namespaceCache[namespace] ? namespaceCache[namespace] : namespaceCache[namespace] = ret) : ret; }; return { create: _create, one: function (key, fn, last) { var event = this.create(); event.one(key, fn, last); }, remove: function (key, fn) { var event = this.create(); event.remove(key, fn); }, listen: function (key, fn, last) { var event = this.create(); event.listen(key, fn, last); }, trigger: function () { var event = this.create(); event.trigger.apply(this, arguments); } }; }(); return Event; })(); Event.create('namespace1').listen('click', function (a) { console.log(a); // 输出:1 }); Event.create('namespace1').trigger('click', 1); Event.create('namespace2').listen('click', function (a) { console.log(a); // 输出:2 }); Event.create('namespace2').trigger('click', 2);