发布-订阅模式又叫观察者模式,它用来定义一种一对多的依赖关系。当某个对象发生改变的时候,所有依赖于它的对象都将得到通知。在js中,通常用事件模型来代替传统的发布订阅模式(因为js没有类,可以直接传递函数)。
1.现实世界中的发布订阅模式
小a最近看上了一套房子,到了售楼处被告知房子卖完了。售楼MM告诉小明,以后还会推出新的楼盘,但不知道什么时候推出。于是小明记下了售楼MM的电话,每天打电话去问售楼MM新楼盘的情况。
同样操作的还有小b、小c等。
这种情况在现实中当然不会出现,现实中更可能的是小a、小b、小c留下自己的电话给售楼MM,一旦新的楼盘出来了,售楼MM就会一个一个打电话去通知小a、小b、小c。
在这个情景中,小a、小b、小c就是订阅者,售楼MM就是发布者。
在异步编程中短轮询和长轮询就对应上述的两种情况。
2.发布订阅模式的作用
在上面这个例子中,发布订阅模式有着明显的优点。
- 购房者不需要每天打电话给售楼MM询问新房开售时间,在合适的时间,售楼MM会通知这些消息订阅者。
- 购房者和售楼处不再紧密耦合在一起。当有新的购房者出现时,他只需要把电话号码留在售楼处,售楼处也不会关心购房者的任何情况。以后无论购买者还是售楼处发生了什么事情,都不会影响这个过程。只要售楼处记得在合适的时间通知购房者。
第一点广泛用于异步编程中,这是一种代替传统回调函数的手段。比如我们监听异步请求的success和error事件。当事件来临的时候,发布一个状态,那么对此感兴趣的订阅者就会收到这个状态并执行相关操作。
第二点在程序方面带来的好处是可以改变对象之间的硬编码的通知机制。一个对象不再显式地去调用另外一个对象的某个接口。发布订阅模式将两个对象松耦合地联系在一起,虽然不清除彼此细节,但并不影响彼此通信。无论发布者还是订阅者发生了变化,只要它们之间的约定没有变,就没有关系。
3.常见的发布订阅模式--DOM事件
window.addEventListener
就是一个典型的例子。
document.body.addEventListener('click', fn1);
document.body.addEventListener('click', fn2);
document.body.addEventListener('click', fn3);
用户可能会点击页面,但不知道什么时候点击。所以我们订阅body的click事件,当body被点击的时候,body节点便会向订阅者发布这个消息。
当然我们还可以随意移除订阅者,通过removeEventListener
事件。
4.自定义事件
除了内置的DOM事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的发布-订阅模式可以用于任何js的代码中。现在来实现一个简单的发布订阅模式。
- 首先需要一个发布者对象
- 发布者需要维护一个缓存列表,用于存放订阅者的订阅函数。
- 订阅者可以往事件列表添加一个事件,表示订阅。
- 发布消息的时候,遍历事件列表,去执行所有事件。
const publish = {}; // 发布者
publish.clientList = []; // 事件列表
// 订阅者往事件列表添加事件
publish.listen = function(fn) {
this.clientList.push(fn);
}
// 发布者发布事件
publish.trigger = function(...args) {
this.clientList.forEach(event => {
event.apply(publish, args);
})
}
publish.listen((area, price) => {
console.log(area, price); // 60 120
});
publish.trigger('60', '120');
这是最简单的发布订阅模式了。但是它存在一个问题,它没有区分订阅类型。比如小a只需要订阅一个60平米的房子,而小b需要订阅一个80平米的房子。但是在上述代码中,并没有区分,接下来改写一下。
const publish = {}; // 发布者
publish.clientList = {}; // 事件列表
publish.listen = function(type, fn) {
// 订阅者往事件列表添加事件
if (!this.clientList[type]) {
this.clientList[type] = [];
}
this.clientList[type].push(fn);
};
publish.trigger = function(type, ...args) {
const fns = this.clientList[type];
if (!fns || fns.length === 0) {
return false;
}
fns.forEach(fn => {
fn.apply(publish, args);
});
};
publish.listen('60', price => {
console.log(price); // 120
});
publish.trigger('60', '120');
现在可以对订阅的事件类型加以区分了。
5.发布订阅模式的通用实现
现在存在一个问题,如果小a现在要去另一个售楼处买房子,另外一个售楼处还有一些其它的行为,那么对于上面的代码又要重复写一遍,这是没有必须要的,所以对上面的代码可以提取一个共同实现,并且增加取消订阅的功能。
const event = {
clientList: {},
listen: function(type, fn) {},
trigger: function(type, ...args) {},
remove: function(type, fn) {
const fns = this.clientList[type];
// 没有该类型的事件
if (!fns || fns.length === 0) {
return false;
}
// 如果不传入具体的事件,表示取消该类型的所有事件
if (!fn) {
fns.length = 0;
} else {
for (let i = 0, len = fns.length; i < len; i++) {
let _fn = fns[i];
if (_fn === fn) {
fns.splice(i, 1);
break;
}
}
}
}
};
const installEvent = obj => {
Object.assign(obj, event);
};
const publish = {};
installEvent(publish);
6.全局的Event对象
上面的代码中,可能会存在多个发布者。如果小a还要订阅300平米的房子,但是这个房子只有售楼处2才有卖,那么我们还需要再创建一个publish2对象。
- 每个发布者对象的创建都需要资源,这是没有必要的。
- 小a和售楼处还存在一定的耦合,至少小a要知道是哪个售楼处。
- 代码中发布者对象可以直接操作clientList对象,这不是很安全。
所以换个思路,买房不一定一定要去售楼处,我们可以委托中介。中介代替小a订阅消息,中介代替售楼处发布消息,中介不能直接操作客户对象列表。那么在程序中,发布订阅模式可以用一个全局的Event对象来实现,它表示中介。其实就是一个单例。为了不能让Event直接操作clientList,肯定需要通过IIFE来实现。
const Event = (() => {
const clientList = {};
const listen = (key, fn) => {
if (!clientList[key]) {
clientList[key] = [];
}
clientList[key].push(fn);
};
const trigger = (type, ...args) => {
const fns = clientList[type];
if (!fns || fns.length === 0) {
return false;
}
fns.forEach(fn => {
fn.apply(this, args);
});
};
const remove = (type, fn) => {
const fns = clientList[type];
// 没有该类型的事件
if (!fns || fns.length === 0) {
return false;
}
// 如果不传入具体的事件,表示取消该类型的所有事件
if (!fn) {
fns.length = 0;
} else {
for (let i = 0, len = fns.length; i < len; i++) {
let _fn = fns[i];
if (_fn === fn) {
fns.splice(i, 1);
break;
}
}
}
};
return {
listen,
trigger,
remove
};
})();
Event.listen('xiaoming-60', price => {
console.log('小a', price);
});
Event.listen('xiaohong-80', price => {
console.log('小b', price);
});
Event.trigger('xiaoming-60', 120);
Event.remove('xiaohong-80');
Event.remove('xiaoming-60');
Event.trigger('xiaohong-80', 160);
Event.trigger('xiaoming-60', 140);
如果全局都统一使用一个Event对象的话,可能随着应用的增大,Event对象的clientList会越来越庞大。这时候需要提供命令空间功能。
7.必须先订阅后发布吗
上面的例子中,都是先订阅后发布的。如果先发布后订阅,那么会导致订阅者收不到发布者的消息。
在某些情况下,我们需要将发布的消息保存下来,当有订阅者来订阅的时候,再重新把消息发送给订阅者。当售楼处发给小a消息的时候,如果小a的手机关机,那么在小a开机后应该仍然能收到这条消息,而不是这条消息消失了。
为了满足这个场景,我们需要创建一个存放离线消息的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,那么就将发布时间的动作包裹在一个函数中,这个函数将会被存入堆栈中。等到有订阅者来订阅这个事件的时候,就遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,一旦订阅者收到事件之后,这些事件就不能再发布了。
8.全局事件的命名冲突
全局的发布订阅模式中只有一个clientList来保存消息名和回调函数,大家都通过它来订阅和发布各种消息,久而久之,难免会出现事件名冲突的情况,所以我们还可以给Event对象提供创建命名空间的功能。
7、8点的两个功能如下:
// 先发布,后订阅
Event.trigger('click', 1);
Event.listener('click', a => {
console.log(a); // 1
})
// 使用命名空间
Event.create('namespace1').listen('click', a => {
console.log(a); // 1
})
Event.create('namespace1').trigger('click', 1);
Event.create('namespace2').listen('click', a => {
console.log(a); // 2
})
Event.create('namespace2').trigger('click', 2);
// 下面是完整代码实现
const Event = (() => {
let Event;
let _default = 'default';
Event = (() => {
const namespaceCache = {};
const each = (ary, fn) => {
let ret;
for (let i = 0, l = ary.length; i < l; i++) {
let n = ary[i];
ret = fn.call(n, i, n);
}
return ret;
};
const _listen = (key, fn, cache) => {
if (!cache[key]) {
cache[key] = [];
}
cache[key].push(fn);
};
const _remove = (key, cache, fn) => {
if (cache[key]) {
if (fn) {
for (let i = 0, _fn, len = cache[key].length; i < len; i++) {
_fn = cache[key][i];
if (_fn === fn) {
cache[key].splice(i, 1);
break;
}
}
} else {
cache[key] = [];
}
}
};
const _trigger = (cache, key, ...args) => {
const stack = cache[key];
const _self = this;
if (!stack || stack.length === 0) {
return;
}
return each(stack, function() {
return this.apply(_self, args);
});
};
const _create = (namespace = _default) => {
const cache = {};
let offlineStack = [];
const ret = {
listen(key, fn, last) {
_listen(key, fn, cache);
if (offlineStack === null) {
return;
}
if (last === 'last') {
offlineStack.length && offlineStack.pop()();
} else {
each(offlineStack, function(...args) {
console.log(this === args[1]); // true
this();
});
}
offlineStack = null;
},
one(key, fn, last) {
_remove(key, cache);
this.listen(key, fn, last);
},
remove(key, fn) {
_remove(key, cache, fn);
},
trigger(...args) {
args.unshift(cache);
let fn;
const _self = this;
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) {
const event = this.create();
event.one(key, fn, last);
},
remove: function(key, fn) {
const event = this.create();
event.remove(key, fn);
},
listen: function(key, fn, last) {
var event = this.create();
event.listen(key, fn, last);
},
trigger: function(...args) {
const event = this.create();
event.trigger.apply(this, args);
}
};
})();
return Event;
})();
9.JS实现发布订阅模式的便利性
由于js没有类的概念,所以js中的发布订阅模式和Java中的实现还是有区别的。在Java中实现一个自己的发布订阅模式,通常会把订阅者对象当做引用传入发布者对象中,同时订阅者对象还需哟提供一个名为诸如update的方法,供发布者对象在合适的时机调动。发布者对象的clientList保存的是订阅者对象,而不是js中的函数。如果要移除订阅者,就从clientList中直接移除掉订阅者。在js中,我们通过回调函数的形式来代替传统的发布订阅模式,更加优雅和简单。
10.小结
发布订阅者模式在实际开发中非常有用。
发布订阅的优点非常明显,一是时间上的解耦,而是对象间的解耦。
- 时间上的解耦: 在异步编程中,由于无法确定异步加载的时间,有可能订阅事件的模块还没有初始化完毕而异步加载就完成了,发布者就已经发布事件了。通过发布订阅模式,可以将发布者的事件提前保存起来,等到发布者加载完毕再执行。
- 对象间的解耦:发布订阅模式中,发布者和订阅者可以不必知道对方的存在,而是通过中介对象来通信。
发布订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上看,无论是MVC还是MVVM,都少不了发布订阅模式的参与,而且js语言本身也是一门基于事件驱动的语言。
当然,发布订阅模式也不是没有缺点。
- 创建订阅者本身需要一定的时间和内存,而当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。
- 另外,发布订阅模式将对象间完全解耦,如果过度使用的话,对象和对象之间的必要联系就会被掩盖,会导致程序难以追踪和理解。