1.发布-订阅模式又叫做观察者模式,他定义对象件的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖他的对象都将得到通知
再现实生活中,送发短信通知就是一个典型的发布-订阅模式,小明,小红等购买者都是订阅者,他们订阅房子开售信息。售楼处作为发布者,会在何时的时候遍历花名册上的电话号码,依次给购房者发布消息
- 首先要指定好谁充当发布者(比如售楼处)
- 然后给发布者添加一个缓存列表,用于存放回掉函数以便通知订阅者(售楼处花名册)
- 最后发布消息时,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回掉函数(遍历花名册,挨个发短信)
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(11,'价格='+price);
console.log('squareMeter='+squareMeter);
})
salesOffices.listen(function(price,squareMeter){ // 小红订阅消息
console.log('价格='+price);
console.log('squareMeter='+squareMeter);
})
salesOffices.trigger(20000,88)
salesOffices.trigger(30000,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); // 取出消息类型
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.listen('squqreMeter88',function(price){ // 小明订阅消88平米房子信息
console.log('价格='+price);
})
salesOffices.listen('squqreMeter110',function(price){ // 小红订阅110平米房子消息
console.log('价格='+price);
})
salesOffices.trigger('squqreMeter88',20000)
salesOffices.trigger('squqreMeter110',30000)
现在订阅者可以只订阅自己感兴趣的事件了。假设现在小明又去另一个售楼处买房子,那么这段代码是否必须在另一个售楼处对象上重写一次呢,有没有办法可以让所有对象都拥有发布订阅功能呢,答案是肯定的,js给对象动态添加职责是理所当然,因此我们把发布订阅模式的功能提取出来,放在一个单独的对象内
var events = {
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); // arguments是发布消息时带上的参数
}
}
}
// 在定义一个installEvent函数,这个函数可以给所有对象都动态添加发布订阅功能
var installEvent = function(obj){
for(var i in events) {
obj[i]=events[i]
}
}
var salesOffices = {};
installEvent(salesOffices);
salesOffices.listen('squqreMeter88',function(price){ // 小明订阅消88平米房子信息
console.log('价格='+price);
})
salesOffices.listen('squqreMeter110',function(price){ // 小红订阅110平米房子消息
console.log('价格='+price);
})
salesOffices.trigger('squqreMeter88',20000)
salesOffices.trigger('squqreMeter110',30000)
有时,我们也许需要取消订阅事件功能,现在我们给events对象添加remove方法
events.remove = function (key, fn) {
var fns = this.clientList[key];
if (!fns) { // 如果key对应的消息没有人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回掉函数,表示取消key对应消息的所有订阅
fns && (fns.length = 0);
} else {
for (var i = fns.length - 1; i >= 0; i--) { // 反向遍历订阅的回掉函数列表
var _fn = fns[i];
if (_fn === fn) {
fns.splice(i, 1); // 删除订阅者的回掉函数
}
}
}
}
// 在定义一个installEvent函数,这个函数可以给所有对象都动态添加发布订阅功能
var installEvent = function (obj) {
for (var i in events) {
obj[i] = events[i]
}
}
var salesOffices = {};
installEvent(salesOffices);
salesOffices.listen('squqreMeter88', fn1 = function (price) { // 小明订阅消88平米房子信息
console.log('价格=' + price);
})
salesOffices.listen('squqreMeter110', fn2 = function (price) { // 小红订阅110平米房子消息
console.log('价格=' + price);
})
salesOffices.remove('squqreMeter88', fn1) //删除小明的订阅
salesOffices.trigger('squqreMeter110', 30000)
我们再来看另外一个应用场景,假如我们正在开发一个商城网站,网站里有header头部、nav导航、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须先用ajax异步请求获取用户登录信息,拿到用户名字和头像在显示到header里。至于ajax什么时候能成功返回用户信息,我们没办法确定,这个很像售楼处的例子。但现在不足以说服我们在此使用发布订阅模式,因为一部问题也可以通过回掉函数来解决,更重要的一点是,我们不知道除了header头部、、nav导航、消息列表、购物车之外,将来还有哪些模块需要使用这些用户信息。如果他们和用户信息模块产生了强耦合,比如下面:
login.succ(function(data){
header.setAvatar(data.avatar); //设置header模块头像
nav.setAvatar(data.avatar);//设置导航模块头像
message.refresh(); // 刷新消息列表
cart.refresh(); // 刷新购物车列表
})
现在登录模块是我们负责编写的,但我们必须了解header模块里设置头像的方法叫setAvatar、购物车模块里刷新方法叫refresh,这种耦合性会使程序变得僵硬,header模块不能随意在更改setAvatar的方法名,他自身也不能改成header1、header2,这是针对具体实现编程的典型例子,针对具体实现编程是不被赞同的。
等到有一天,项目又新增了一个收获地址管理的模块,这个模块本来是另一个同事所写的,而此时你在马来西亚度假,但是他却不得不给你打电话:“hi,登录之后麻烦刷新一下收货地址列表“,于是你又翻开3该月前写的登录模块,在最后部分加上这行代码:
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(){ // header模块
login.listen('loginSucc',function(data){
nav.setAvatar(data.avatar);
})
return {
setAvatar: function(data){
console.log('设置nav模块头像')
}
}
})()
如上所述,我们随时可以把setAvatar的方法名改为setTouxiang。如果有一天在登录完成后,又增加一个刷新收获地址列表的行为,那么只要在收获地址模块里加上监听消息的方法即可,而这可以让开发模块同事自己完成,你作为登录模块开发中,永远不用在关心这些行为了,代码如下:
var address = (function(){ // header模块
login.listen('loginSucc',function(data){
address.setAvatar(data.avatar);
})
return {
setAvatar: function(data){
console.log('刷新收获地址列表')
}
}
})()
2.全局的发布-订阅对象
回想一下刚刚实现的发布订阅模式,我们给售楼处对象和登录对象都添加了订阅和发布的功能,这里还存在两个小问题
- 我们给每个发布者对象都添加了listen和trigger方法,以及一个缓存列表处理clientList,这其实是一种资源浪费
- 小明和售楼处对象还是存在一定的耦合性,小明至少要知道售楼处对象的名字是salesOffices,才能顺利的订阅到事件,见如下代码:
salesOffices.listen('squqreMeter88', fn1 = function (price) { // 小明订阅消88平米房子信息
console.log('价格=' + price);
})
如果小明还关心300平米房子,而这套房子的买家是salesOffices2,这意味着小明还要开始订阅salesOffices2对象。
salesOffices2.listen('squqreMeter300', fn1 = function (price) { // 小明订阅消300平米房子信息
console.log('价格=' + price);
})
其实在现实中,买房子未必要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司来发布房子信息。这样以来,我们不用关心消息来自哪个房产公司,我们在意的是否能顺利收到消息当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
同样在程序中,发布订阅模式可以用一个全局的Event对象来实现,订阅者不需要了解信息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event作为一个类似‘中介者’的角色,把订阅者和发布者联系起来。见如下代码:
var Event = (function () {
var clientList = {};
var listen;
var trigger;
var 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); // arguments是发布消息时带上的参数
}
}
remove = function (key, fn) {
var fns = clientList[key];
if (!fns) { // 如果key对应的消息没有人订阅,则直接返回
return false;
}
if (!fn) { // 如果没有传入具体的回掉函数,表示取消key对应消息的所有订阅
fns && (fns.length = 0);
} else {
for (var i = fns.length - 1; i >= 0; i--) { // 反向遍历订阅的回掉函数列表
var _fn = fns[i];
if (_fn === fn) {
fns.splice(i, 1); // 删除订阅者的回掉函数
}
}
}
}
return {
listen: listen,
trigger: trigger,
remove: remove
}
})()
Event.listen('squqreMeter88', function (price) { // 小明订阅消88平米房子信息
console.log('价格=' + price);
})
Event.trigger('squqreMeter88', 200000); // 售楼处发布消息
3.全局事件的命名冲突
全局的发布订阅对象里只有一个clientList来存放消息名和回掉函数,大家都通过它来订阅发布各种消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给Event对象提供创建命名空间的功能。
var Event = (function () {
var global=this;
var Event;
_default='default';
Event = (function(){
var _listen;
var _trigger;
var _remove;
var _slice=Array.prototype.slice;
var _shift=Array.prototype.shift;
var _unshift=Array.prototype.unshift;
var namespaceCache = {};
var _create;
var find;
var 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);
},
_trigger = function () {
var cache = _shift.call(arguments);
var key = _shift.call(arguments);
var args=arguments;
var _self=this;
var ret;
var stack = cache[key];
if(!stack || !stack.length){
return;
}
return each(stack,function(){
return this.apply(_self,args)
})
};
_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][i].splice(i, 1); // 删除订阅者的回掉函数
}
}
}else {
cache[key]=[]
}
}
};
_create = function(namespace){
var namespace = namespace || _default;
var cache = {};
var 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;
var args;
var _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.trigger('click',1);
Event.listen('click',function(a){
console.log(a)
})
// 使用命名空间
Event.create('namespace1').listen('click',function(a){
console.log(a)
})
Event.create('namespace1').trigger('click',2)
Event.create('namespace2').listen('click',function(a){
console.log(a)
})
Event.create('namespace2').trigger('click',3)