观察者模式
观察者模式广泛应用于客户端JavaScript编程中。所有的浏览器事件(鼠标悬停,按键等事件)是该模式的例子。它的另一个名字也称自定义事件,与那些由浏览器触发的相比,自定义事件表示是由你编程实现的事件。此外,该模式的另一个别名是订阅——发布模式。
设计这种模式背后的主要动机是促进形成松散耦合。在这种模式中,并不是一个对象调用另一个对象的方法,而是一个对象订阅另一个对象的特定活动并在状态改变后获得通知。订阅者也称之为观察者,而被观察者的对象称为发布者或主题。当发生了一个重要的事件时,发布者将会通知(调用)所有订阅者并且可能经常以事件对象形式传递消息。
1. 现实中的观察者模式
以售楼处为例,小明想要买房,于是招待人员记下小明的手机。小兵,小龙也买房,招待人员获得他们的手机通通给记在花名册上,过几天,有了他们中意的房子,工作人员便会翻开花名册,打电话伺候。
在这个例子中,小明,小龙,小兵是订阅者,他们订阅房子的信息。售楼处是发布者,一有消息便会依次打电话给购房者。
2. DOM事件
实际上,只要我们曾经在DOM节点上面绑定过事件函数,那我们就曾经使用过观察者模式,来看看下面这两句简单的代码发生了什么事情:
document.body.addEventListener('click', function () { alert('1'); }, false); document.body.click();//模拟用户点击
在这里需要监控用户点击document.body的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息。
3. 自定义事件
除了DOM事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的观察者模式可以用于任何JS代码中。
现在看看如何一步步实现观察者模式:
- 首先要指定好谁充当发布者(比如售楼处)
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(比如花名册)
- 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(打开花名册,依次打电话)
另外,我们还可以往回调函数里填入一些参数,订阅者可以接受这些参数。这是很有必要的,比如售楼处可以在发给订阅者的短信里加上房子的单价,面积等信息,订阅者接受到这些信息之后可以进行各自的处理:
var salesOffices = {}; //定义售楼处 salesOffices.clientList = []; //缓存列表,存放订阅者的回调函数 salesOffice.listen = function (fn) { //增加订阅者 this.clientList.push(fn); //订阅的消息添加进缓存列表 }; salesOffice.trigger = function () { //发布消息 for (var i = 0, fn; fn = this.clientList[i++];) { fn.apply(this, arguments); //arguments是发布消息时带上的参数 } };
下面我们来进行一些简单的测试:
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); //arguments是发布消息时带上的参数 } }; salesOffices.listen(function (price, squareMeter) { //小明订阅消息 console.log('a价格= ' + price); console.log('squareMeter= ' + squareMeter); }); salesOffices.listen(function (price, squareMeter) { //小龙订阅消息 console.log('b价格= ' + price); console.log('squareMeter= ' + squareMeter); }); salesOffices.trigger(2000000, 88); salesOffices.trigger(3000000, 110); /*输出: a价格= 2000000 squareMeter= 88 b价格= 2000000 squareMeter= 88 a价格= 3000000 squareMeter= 110 b价格= 3000000 squareMeter= 110 */
至此,我们已经实现了一个最简单的观察者模式,但这里还存在一些问题。我们看到订阅者接收到了发布者发布的每个消息,虽然小明只想买88平米的房子,但是发布者把110平米的信息也推送给了小明,这对小明来说很是麻烦。所以我们有必要增加一个标示key,让订阅者只订阅自己感兴趣的消息。改写后的代码如下:
var salesOffices = {}; //定义售楼处 salesOffices.clientList = []; //缓存列表,存放订阅者的回调函数 salesOffices.listen = function (key, fn) { //增加订阅者 if (this.clientList[key] === undefined) { //如果还没有订阅过此类消息,给该类消息创建一个缓存列表 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); //arguments是发布消息时带上的参数 } }; salesOffices.listen('squareMeter88', function (price) { //小明订阅88平米房子的消息 console.log('a价格= ' + price); }); salesOffices.listen('squareMeter110', function (price) { //小龙订阅110平米房子的消息 console.log('b价格= ' + price); }); salesOffices.trigger('squareMeter88', 2000000); salesOffices.trigger('squareMeter110', 3000000); /*输出: a价格= 2000000 b价格= 3000000 */
现在,订阅者可以只订阅自己感兴趣的事了。
4. 观察者模式的通用实现
现在我们已经看到了如何让售楼处拥有接受订阅和发布事件的功能。假设现在小明又去另一个售楼处买房子,那么这段代码是否必须在另一个售楼处对象上重写一次呢,有没有办法可以让所有对象都拥有发布——订阅功能呢?
答案是有的,JavaScript作为一门解释执行的语言,给对象动态添加职责是理所当然的事情。
所以我们把发布——订阅的功能提取出来,放在一个单独的对象内:
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); var fns = this.clientList[key]; if (!fns && fns.length === 0) { return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); } } };
再定义一个installEvent函数,这个函数可以给所有的对象都动态安装发布——订阅功能:
var installEvent = function (obj) { for (var i in event) { obj[i] = event[i]; } };
再来测试,我们给售楼处对象salesOffice是动态增加发布——订阅功能:
var salesOffices = {}; installEvent(salesOffices); salesOffices.listen('squareMeter88', function (price) { console.log('a价格= ' + price); }); salesOffices.listen('squareMeter110', function (price) { console.log('b价格= ' + price); }); salesOffices.trigger('squareMeter88', 2000000); //输出:2000000 salesOffices.trigger('squareMeter110', 3000000); //输出:3000000
5. 取消订阅事件
有时候,我们也许需要取消订阅事件的功能。比如小明突然不想买房子了,为了避免继续接到售楼处的电话,小明需要取消之前订阅的事件。现在我们给event对象增加remove方法:
event.remove = function (key, fn) { var fns = this.clientList[key]; if (!fns) { //如果key对应的消息没有被人订阅,则直接返回 return false; } if (!fn) { //如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅 fns.length = 0; }else{ for (var i = 0, _fn; _fn = fns[i]; i++) { if (_fn === fn) { fns.splice(i, 1); //删除订阅者的回调函数 } } } }; var installEvent = function (obj) { for (var i in event) { obj[i] = event[i]; } }; var salesOffices = {}; installEvent(salesOffices); salesOffices.listen('squareMeter88', fn1 = function (price) { console.log('a价格= ' + price); }); salesOffices.listen('squareMeter88', fn2 = function (price) { console.log('b价格= ' + price); }); salesOffices.remove('squareMeter88', fn1); salesOffices.trigger('squareMeter88', 2000000); //输出:2000000
6. 全局的发布——订阅对象
在现实中,买房子未必要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司来发布房子信息。这样一来,我们不用关心消息是来自哪个房产公司,我们在一的是能否顺利接受消息。当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
同样在程序中,发布——订阅模式可以用一个全局的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); var 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 = this.clientList[key]; if (!fns) { return false; } if (!fn) { fns.length = 0; }else{ for (var i = 0, _fn; _fn = fns[i]; i++) { if (_fn === fn) { fns.splice(i, 1); } } } }; return { listen: listen, trigger: trigger, remove: remove }; })(); Event.listen('squareMeter88', function(price) { console.log('a价格= ' + price); }); Event.trigger('squareMeter88', 2000000); //输出:2000000
7. 模块间的通信
上一节中实现的发布——订阅模式的实现,是基于一个全局的Event对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。就如同有了中介公司之后,我们不再需要知道房子的消息来自哪个售楼处。
比如现在有两个模块,a模块里面有一个按钮,每次点击按钮之后,b模块里的div中会显示按钮的总点击次数,我们用全局发布——订阅模式完成下面的代码,使得a模块和b模块可以在保持封装性的前提下进行通信。
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <title></title> </head> <body> <button id="count">dian我</button> <div id="div"></div> <script> var a = (function () { var count = 0; var button = document.getElementById('count'); button.onclick = function() { Event.trigger('add', count++); }; })(); var b = (function () { var div = document.getElementById('div'); Event.listen('add', function (count) { div.innerHTML = count; }); })(); </script> </body> </html>
但在这里我们要留意另一个问题,模块之间用了太多的全局发布——订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些借口给其他模块调用。
参考书目:《JavaScript模式》,《JavaScript设计模式与开发实践》
来源:https://www.cnblogs.com/fxycm/p/4868458.html