《js 设计模式与开发实践》读书笔记 8
发布订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。发布订阅模式又显而易见的优点。这个模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案,比如,我们可以订阅 ajax 请求中的 err,success 事件。第二点说明一个对象不再显式调用另外一个对象的某个接口,当新的订阅者出现时,发布者的代码不需要任何修改,同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由改变它们。
document.body.addEventListener('click', function () {
console.log('1')
})
document.body.addEventListener('click', function () {
console.log('2')
})
document.body.click()
实际上,我们在 dom 上绑定事件函数就是发布订阅模式。上面的代码我们需要监控用户点击 document.body 的动作,但是我们没有办法预先知道用户将在上面时候点击。所以我们订阅 document.body 上的 click 事件,当 body 节点被点击时,订阅者会接收到这个消息。我们可以随意增加或者删除订阅者,增加任何订阅者不会影响发布者的代码编写。
我们假设正在开发一个商城网站,网站有 header,nav,消息列表和购物车等模块。这几个模块的渲染有一个共同的前提条件,就是必须先用到 ajax 异步请求获取用户的登录信息。ajax 请求什么时候能成功返回用户信息,我们没有办法确定。但现在还是可以使用回调函数来解决。
login.succ(function (data) {
header.setAvatar(data)
nav.setAvatar(data)
message.refresh()
cart.refresh()
})
现在登录模块是我们负责编写的,但我们还必须了解 header 模块里设置头像的方法叫 setAvatar,购物车模块里刷新的方法叫 refresh,这种耦合性使程序变得僵硬,header 模块不能随意再改变 setAvatar 的方法名,它自身的名字也不能被改为 header1,header2。这是针对具体实现编程的典型例子,针对具体实现编程是不被赞同的。等有一天项目中又新增了一个收货地址管理的模块,这个模块本来是另外一个同事所写的,而此时你正在马来西亚度假,但是他却不得不给你打电话。登录之后麻烦刷新一下收货地址列表。于是你又翻开你 3 个月之前写的登录模块,在最后加上这段代码。
login.succ(function (data) {
header.setAvatar(data)
nav.setAvatar(data)
message.refresh()
cart.refresh()
address.refresh()
})
用发布订阅模式重写之后,对用户信息感兴趣的业务模块将自行订阅登录成功的消息事件。当登录成功后时,登录模块只需要发布登录成功的消息,而业务方接受道消息之后,就会开始进行各自的业务处理,登录模块并不关系业务方要做什么,也不想去了解它们的内部细节。
$.ajax('xxx', function (data) {
login.trigger('loginsucc', data)
})
// 各个模块监听登录成功后的消息
var header = (function () {
login.listen('loginsucc', function (data) {
header.setAvatar(data.avatar)
})
return {
setAvatar: function (data) {
console.log('设置header模块的头像')
}
}
})()
上面实现的发布订阅模式,给每个发布者对象都添加了 listen 和 trigger 方法,以及一个缓存列表 clientList,这其实是一种资源浪费。在程序中,发布订阅模式可以用一个全局的 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)
})
Event.trigger('squareMeter88', 200000)
上面实现的发布订阅模式,是基于一个全局的 Event 对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。比如现在又两个模块,a 模块中有一个按钮,每次点击按钮之后,b 模块里的 div 中会显示按钮的总点击次数,我们用全局发布订阅模式完成代码。使得 a 模块和 b 模块可以在保持封装性的前提下通信。
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('show')
Event.listen('add', function (count) {
div.innerHTML = count
})
})()
模块之间如果用了太多的全局发布订阅模式来通信,那么模块与模块之间的联系就被隐藏道了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这会给我们的维护带来一些麻烦。
以上的发布订阅模式,都是订阅者先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,就无效了。为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像qq的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。
这里要提出的是,我们一直讨论的发布订阅模式,跟java中的实现还是有区别的。在js中,我们用注册回调函数的形式来替换传统的发布订阅模式,显得更加优雅和简单。