小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
前言
记录自己学习设计模式,内容来自
《JavaScript设计模式与开发实践》
发布订阅模式的定义
它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在JavaScript开发中,我们一般用事件模型来替代传统的发布-订阅模式。
现实中的发布订阅模式
小明看上了一套房子,到了售楼处才被告知,该楼盘的房子早已售完。好在售楼MM告诉小明,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买,但到底时什么时候,目前还没有人能够知道。
小明离开之前,把电话号码留在了售楼处,售楼MM答应他,新楼盘一推出立刻通知小明。小红、小强和小龙也是一样,它们的店面号码都被记在了后名册上,新楼盘推出的时候,售楼MM会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知它们。
发布订阅模式的作用
可以发现,上面这个例子有显而易见的优点
-
购房者不用天天给售楼处打电话咨询开售世界,在合适的时间点,售楼处作为发布者会通知这些消息订阅者
-
购房者和售楼处不再强耦合在一起,当有新的购房者出现时,只需要把手机号码留在售楼处,售楼处不关心购房者情况。售楼处的任何变动也不会影响购买者,比如售楼MM离职,售楼处从一楼搬到二楼,这些改变都与购房者无关,只要售楼处记得发短信这件事情
第一点说明发布订阅模式可以广泛应用在异步编程中,这是一种代替回调函数得到方案。
第二点说明发布订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另一个对象的某个接口。
DOM事件
document.body.addEventListener('click', () => {
alert(2)
})
document.body.addEventListener('click', () => {
alert(3)
})
自定义事件
- 指定谁是发布者(售楼处)
- 然后给发布者添加一个缓存列表,用于存放回调以便通知订阅者(售楼处的花名册)
- 然后发布消息的时候,发布者会遍历这个列表,依次触发里面存放的订阅者回调函数(遍历花名册,挨个发短信)
另外,我们还可以往回调函数里填入一些参数,订阅者可以接收这些参数。这是很有必要的,比如售楼处可以在发给订阅者的短信里加上房子的单价、面积、容积率等信息,订阅者接受到这些消息之后进行各自的处理
const salesOffices = {} // 定义售楼处
salesOffices.clientList = [] // 缓存列表,存放订阅者的回调函数
salesOffices.listen = function(fn) { // 增加订阅者
this.clientList.push(fn) // 添加进缓存列表
}
salesOffices.trigger = function() { // 发布消息
for(let i = 0, fn; fn = this.clientList[i++];) {
fn.apply(this, arguments) // arguments 是发布消息时带上的参数
}
}
// 测试
salesOffices.listen((price, squareMeter) => { // 小明订阅消息
console.log('价格=' + price)
console.log('squareMeter=' + squareMeter)
})
salesOffices.listen((price, squareMeter) => { // 小红订阅消息
console.log('价格=' + price)
console.log('squareMeter=' + squareMeter)
})
salesOffices.trigger(2000000, 88)
salesOffices.trigger(3000000, 110)
至此,我们已经实现了一个最简单的发布订阅模式,但这里还存在一些问题。我们看到订阅者接受到了发布者发布的每个信息,小明只想买88平方米的房子,但是发布者把110平方米的信息也推送给了小明,这对小明来说是不必要的困扰。所以我们有必要增加一个标示key,让订阅者只订阅自己感兴趣的消息。
改写后的代码如下
const salesOffices = {} // 定义售楼处
salesOffices.clientList = {} // 缓存列表,存放订阅者的回调函数
salesOffices.listen = function(key, fn) { // 增加订阅者
if (!this.clientList[key]) { // 如果还没有订阅过此类消息,给该类创建一个缓存列表
this.clientList[key] = []
}
this.clientList[key].push(fn) // 添加进缓存列表
}
salesOffices.trigger = function() { // 发布消息
const key = Array.prototype.shift.call(arguments), // 取出消息类型
fns = this.clientList[key]; // 取出该消息对应的回调函数集合
if (!fns || fns.length === 0) {
return false;
}
for(let i = 0, fn; fn = fns[i++];) {
fn.apply(this, arguments) // arguments 是发布消息时带上的参数
}
}
// 测试
salesOffices.listen('squareMeter88', price => { // 小明订阅消息
console.log('价格=' + price)
})
salesOffices.listen('squareMeter110', price => { // 小红订阅消息
console.log('价格=' + price)
})
salesOffices.trigger('squareMeter88', 2000000)
salesOffices.trigger('squareMeter110', 3000000)
发布订阅的通用实现
假设现在小明又去另一家售楼处买房子,那么这段代码是否必须在另一个售楼处对象上重写一次呢,有没有办法可以让所以对象都拥有发布订阅功能呢?
请看下面代码
const event = {
clientList: {},
listen(key, fn) {
!this.clientList[key] && (this.clientList[key] = [])
this.clientList[key].push(fn)
},
trigger(key, ...args) {
const fns = this.clientList[key]
if (!fns || fns.length === 0) {
return false
}
for(let i = 0, fn; fn = fns[i++];) {
fn.apply(this, args)
}
}
}
const installEvent = function(obj) {
for(let i in event) {
obj[i] = event[i]
}
}
// 使用
const salesOffices = {}
installEvent(salesOffices)
salesOffices.listen('squareMeter88', price => { // 小明订阅消息
console.log('价格=' + price)
})
salesOffices.listen('squareMeter110', price => { // 小红订阅消息
console.log('价格=' + price)
})
salesOffices.trigger('squareMeter88', 2000000)
salesOffices.trigger('squareMeter110', 3000000)
取消订阅的事件
有时候,我们也许需要取消订阅事件的功能。比如小明突然不想买房子了,为了避免继续接受到售楼处的短信,小明需要取消之前订阅的事件。现在我们给event对象增加remove方法
const event = {
clientList: {},
listen(key, fn) {
!this.clientList[key] && (this.clientList[key] = [])
this.clientList[key].push(fn)
},
trigger(key, ...args) {
const fns = this.clientList[key]
if (!fns || fns.length === 0) {
return false
}
for(let i = 0, fn; fn = fns[i++];) {
fn.apply(this, args)
}
},
remove(key, fn) {
const fns = this.clientList[key]
if (!fns) {
return false
}
if (!fn) { // 如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
fns.length = 0
return false
}
for (let l = fns.length - 1; l >= 0; l--) { // 反向遍历
const _fn = fns[l];
if (_fn === fn) {
fns.splice(l, 1) // 函数订阅者的回调
}
}
}
}
const installEvent = function(obj) {
for(let i in event) {
obj[i] = event[i]
}
}
const salesOffices = {}
let fn1, fn2;
installEvent(salesOffices)
salesOffices.listen('squareMeter88', fn1 = price => { // 小明订阅消息
console.log('价格=' + price)
})
salesOffices.listen('squareMeter88', fn2 = price => { // 小红订阅消息
console.log('价格hhh' + price)
})
salesOffices.remove('squareMeter88', fn2)
salesOffices.trigger('squareMeter88', 2000000)
真实例子——网站登录
假设我们开发一个商城网站,网站里有header头部,nav导航、消息列表、购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须有用户的登录信息。
如果它们和用户信息模块产生了强耦合,比如下面这样的形式:
login.succ(data => {
header.setAvatar(data.avatar)
nav.setAvatar(data.avatar)
message.refresh() // 刷新信息列表
cart.refresh() // 刷新购物车列表
})
现在登录模块是我们编写的,但我们还必须了解header模块里设置头像的方法叫做setAvatar,购物车的方法叫refresh,这种耦合性会使程序变得僵硬,header模块不能随意改变setAvatar的方法名,它自身的名字也不能被改成header1、header2。
等到有一天,项目中新增了一个收获管理的模块,我们就需要在最后加上这行代码:
login.succ(data => {
header.setAvatar(data.avatar)
nav.setAvatar(data.avatar)
message.refresh()
cart.refresh()
address.refresh() // 增加的这行代码
})
我们用发布订阅来重构这块代码
$.ajax('http://login.com', data => { // 登录
login.trigger('loginSuccess', data) // 发布登录成功的消息
})
// 各模块增加登录成功的消息:
const header = (() => {
login.listen('loginSuccess', (data) => {
header.setAvatar(data.avatar)
})
return {
setAvatar(data) {
console.log('设置了header的头像')
}
}
})()
const address = (() => {
login.listen('loginSuccess', (data) => {
address.refresh(obj)
})
return {
refresh(avatar) {
console.log('刷新收货列表')
}
}
})
全局的发布订阅对象
回想下刚刚实现的发布订阅模式,我们给售楼处对象和登录对象都添加了订阅和发布的功能,这里还存在两个小问题。
- 我们给每个发布者都添加了listen和trigger方法,以及一个缓存列表clientList,这其实是一种资源浪费
- 小明跟售楼处存在耦合性,小明至少要知道售楼处对象的名字是salesOffices,才能顺利的订阅到事件。见如下代码
salesOffices.listen('squareMeter100', price => {
console.log(price, '价格')
})
如果小明还关心300平方米的房子,而这套房子的卖家是salesOffices2, 这意味着小明要开始订阅salesOffices2对象。见如下代码
salesOffices.listen('squareMeter300', price => {
console.log(price, '价格')
})
其实在现实中,买房子未必要亲自去售楼处,我们只要把订阅的请求交给中介公司,而各大房产公司也只需要通过中介公司来发布房子信息。这样一来,我们不用关系是来自哪个房产公司,我们在意的是能否顺利收到消息。当然,为了保证订阅者和发布者能顺利通信,订阅者和发布者都必须知道这个中介公司。
同样在程序中,发布订阅可以用一个全局的Event对象来实现,订阅者不需要了解消息来自哪个发布者,发布者要不知道消息会推送给哪些订阅者,Event作为一个类似的“中介者”的角色,把订阅者和发布者联系起来。见如下代码:
const Event = (function() {
let clientList = {},
listen,
trigger,
remove;
listen = (key, fn) => {
!clientList[key] && (clientList[key] = [])
clientList[key].push(fn)
}
trigger = (key, ...args) => {
fns = clientList[key]
if (!fns || fns.length === 0) {
return false
}
for (let i = 0, fn; fn = fns[i++];) {
fn.apply(this, args)
}
}
remove = (key, fn) => {
const fns = clientList[key]
if (!fns) {
return false
}
if (!fn) {
fns.length = 0 // 清空fns
return
}
for (let l = fns.length - 1; l >= 0; l--) {
const _fn = fns[l]
if (_fn === fn) {
fns.splice(l, 1)
}
}
}
return {
listen,
trigger,
remove
}
})()
Event.listen('squareMeter88', price => { // 小明订阅消息
console.log('价格=' + price)
})
Event.trigger('squareMeter88', 2000000) // 售楼处发布消息
模块间通信
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="count">点我</button>
<div id="show">hh</div>
<script src="./全局的发布订阅.js"></script>
<script>
const a = (() => {
let count = 0;
var button = document.getElementById('count')
button.onclick = function() {
Event.trigger('add', count++)
}
})()
const b = (() => {
var div = document.getElementById('show')
div.onclick = function() {
console.log('点击')
Event.listen('add', count => {
div.innerHTML = count
})
}
})()
</script>
</body>
</html>
但在这里我们要留意另一个问题,模块之间如果用了太多的全局发布订阅来通信,那么模块之间的联系会被隐藏到背后。最终会搞不清楚消息来自哪个模块,或者消息会流自哪个模块,会给我们的维护带来麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用
必须先订阅在发布吗?
我们了解到的发布订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,这条消息无疑消失在宇宙之中。
在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就如同QQ中的离线消息一样,离校消息被保存到服务器中,接收人下次登录上线之后,可以重新收到这条消息
为了满足这个需求,我们要建立存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有依次,就像QQ的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次
全局对象的命名冲突
久而久之,难免会出现事件名冲突的情况,所以我们还可以给Event对象提供创建命名空间的功能
先来感受下怎么使用这两个新增的功能
- 先发布后订阅
Event.trigger('click', 1)
Event.listen('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 global = this,
Event,
_default = 'default';
Event = function() {
let _listen,
_trigger,
_remove,
namespaceCache = {},
_create,
find,
each = function(ary, fn) {
let ret;
for (let i = 0, l = ary.length; i < l; i ++) {
const 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, fn, cache) {
if (!cache[key]) {
return false
}
if (!fn) {
cache[key] = []
return false
}
for (let i = cache[key].length - 1; i >= 0; i--) {
if (cache[key][i] === fn) {
cache[key].splice(i, 1)
}
}
}
_trigger = function(cache, key, ...args) {
const stack = cache[key]
if (!stack || !stack.length) {
return
}
// 写法1
// return each(stack, () => this.apply(this, args))
// 写法2
const that = this
return each(stack, function() {
return this.apply(that, args)
})
}
_create = function(namespace = _default) {
let cache = {},
offlineStack = [] // 离线事件
const ret = {
listen(key, fn, last) {
_listen(key, fn, cache)
if (offlineStack === null) {
return;
}
if (last === 'last') {
offlineStack.length && offlineStack.pop();
return
}
each(offlineStack, function() {
this()
})
offlineStack = null
},
one(key, fn, last) {
_remove(key, null, last)
},
remove(key, fn) {
_remove(key, fn, cache)
},
trigger(...args) {
args.unshift.call(args, cache)
const that = this
const fn = function() {
return _trigger.apply(that, args)
}
if (offlineStack) {
return offlineStack.push(fn)
}
return fn()
}
}
return namespace ?
(namespaceCache[namespace] ? namespaceCache[namespace] : namespaceCache[namespace] = ret)
: ret
};
return {
create: _create,
one(key, fn, last) {
const event = this.create()
event.one(key, fn, last)
},
remove(key, fn) {
const event = this.create();
event.remove(key, fn)
},
listen(key, fn, last) {
const event = this.create()
event.listen(key, fn, last)
},
trigger(...args) {
const event = this.create()
event.trigger.apply(this, args)
}
}
}
return Event()
})()
Event.trigger('click', 1)
Event.listen('click', a => {
console.log(a)
})
Event.create('namespace1').listen('click', a => {
console.log(a)
})
Event.create('namespace1').trigger('click', 1)
Event.create('namespace2').listen('click', a => {
console.log(a)
})
Event.create('namespace2').trigger('click', 2)