5_发布订阅模式
1 简介
作为一种优秀的设计模式,发布订阅者模式被广泛应用在前端领域
eg: 在 vue 的源码中,为了让数据劫持和视图驱动解耦 就是通过架设一层消息管理层实现的,而这一层消息管理层实现的原理就是发布订阅模式
现在举个现实中的例子来感受一下:你看上了一套房,到了售楼处被告知该楼盘的房子早已售罄。然后售楼处就说有一批尾盘开发商正在办理相关手续,办好就可以购买了。但是具体什么时候,目前还不知道
- 方案A:你记下售楼处的电话,每天打电话问什么时候可以买(张三李四也每天打电话问)
- 方案B:你留下电话号码,新楼盘推出的时候售楼处打电话通知你
售楼处收集意向购房者电话,有新楼盘推出则一一通知
显然方案B才是合理的,在这个场景中,售楼处扮演了被观察者(Subject) 的角色,你就扮演了其中一个观察者(Observer)
存在的问题:
- 你需要知道售楼处的名字才能顺利订阅--存在一定耦合性
- 售楼处要在新楼盘推出的时候挨个通知你们--存在一定资源浪费
解决方案:
- 将购房需求交给中介公司,而售楼处也通过中介来发布房子信息 -- 你跟售楼处解耦
我们只需要关心能否顺利收到消息
在这个场景中,售楼处扮演了被消息发布者(Publisher)角色 的角色,中介扮演了消息中心角色,你就扮演了其中一个订阅者(Subscriber)
2 概念
- 发布订阅者模式定义对象间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
优点
- 在合适的时间点售楼处作为发布者会通知消息订阅者
说明发布订阅者模式可广泛应用于异步编程,这是一种替代传递回调函数的方案。
- 购房者与售楼处解耦,售楼处无需关注购房者的任何情况,只要售楼处记得通知即可
说明发布订阅者模式可以取代对象之间硬编码的通知机制,一个对象不再显式地调用另一个对象的某个接口。发布者不需要知道有多少订阅者,以及订阅者接收到消息之后会干什么,而订阅者也不需要关心发布者会在什么时候发布消息,两者相互独立运行。
3 DOM 事件
document.body.addEventListener('click', () => alert(2))
document.body.addEventListener('click', () => alert(3))
// 模拟用户点击
document.body.click()
需要监控用户点击documet.body的动作,但是无法预知用户何时点击 --> 所以订阅了document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息
- 用户点击 -- 发布者
- 绑定点击事件 -- 订阅者
- document.body处理绑定事件 -- 消息中心
滚动、按键
4 自定义事件
如何一步步实现发布订阅者模式
实现思路
4.1 简单实现
// 定义售楼处
let salesOffices = {
// 缓存列表 -- 存放订阅者的回调函数
clientList: [],
// 增加订阅者
listen(fn) {
// 订阅的消息添加到缓存列表
this.clientList.push(fn)
},
// 发布消息
trigger() {
for(let fn in this.clientList) {
// arguments是发布时带上的参数
fn.apply(this, arguments)
}
}
}
4.2 简单测试
// A订阅
salesOffices.listen((price, squareMeter) => {
console.log('A订阅 价格=' + price)
console.log('A订阅 面积=' + squareMeter)
})
// B订阅
salesOffices.listen((price, squareMeter) => {
console.log('B订阅 价格=' + price)
console.log('B订阅 面积=' + squareMeter)
})
salesOffices.trigger(2000000, 88)
salesOffices.trigger(3000000, 110)
// A订阅 价格=2000000
// A订阅 面积=88
// B订阅 价格=2000000
// B订阅 面积=88
// A订阅 价格=3000000
// A订阅 面积=110
// B订阅 价格=3000000
// B订阅 面积=110
A只想订阅88平方米的消息,但是发布者将110平方的消息也推送给了A
4.3 增加标识 key
// 定义售楼处
let salesOffices = {
// 缓存列表--存放订阅者的回调消息
clientList: {},
// 增加订阅者
listen(key, fn) {
// 若还没订阅过此类消息,给该类消息创建一个缓存列表
if(!this.clientList[key]) {
this.clientList[key] = []
}
// 订阅的消息添加到缓存列表
this.clientList[key].push(fn)
},
// 发布消息
trigger() {
// 取出消息类型 -- 第一个参数对应消息类型key
let key = Array.prototype.shift.call(arguments)
// 取出该消息对应的回调函数数组
let fns = this.clientList[key]
// 若没有订阅过相关消息则返回
if(!fns || fns.length === 0) return false
for(let fn in fns) {
// 执行对应列表的回调
fn.apply(this, arguments)
}
}
}
4.4 测试
// A订阅
salesOffices.listen('squareMeter88', price => {
console.log('A订阅 价格=' + price)
})
// B订阅
salesOffices.listen('squareMeter110', price => {
console.log('B订阅 价格=' + price)
})
salesOffices.trigger('squareMeter88', 2000000)
salesOffices.trigger('squareMeter110', 3000000)
// A订阅 价格=2000000
// B订阅 价格=3000000
此时,订阅者可以只订阅自己感兴趣的事件了
5 发布—订阅模式的通用实现
已经实现了售楼处接受订阅和发布事件的功能,此时A想去另一个售楼处买房子,是否必须给另一个售楼处copy一份代码?有没有办法让所有对象都拥有发布订阅功能?
- 给对象动态添加职责
5.1 提取发布订阅功能放在单独的对象内
let event = {
// 缓存列表
clientList: {},
listen(key, fn) {
if(!this.clientList[key]) {
this.clientList[key] = []
}
this.clientList[key].push(fn)
},
// 发布消息
trigger() {
let key = Array.prototype.shift.call(arguments)
let fns = this.clientList[key]
if(!fns || fns.length === 0) return false
for(let fn in fns) {
// arguments 是发布消息时带上的参数
fn.apply(this, arguments)
}
}
}
5.2 定义 installEvent 函数
- 该函数给所有对象动态安装发布订阅功能
function installEvent(obj) {
for(let i in event) {
obj[i] = event[i]
}
}
5.3 测试
- 给售楼处对象salesOffices动态增加发布订阅者功能
let salesOffices = {}
installEvent(salesOffices)
// A订阅
salesOffices.listen('squareMeter88', price => {
console.log('A订阅 价格=' + price)
})
// B订阅
salesOffices.listen('squareMeter110', price => {
console.log('B订阅 价格=' + price)
})
salesOffices.trigger('squareMeter88', 2000000)
salesOffices.trigger('squareMeter110', 3000000)
// A订阅 价格=2000000
// B订阅 价格=3000000
5.4 取消订阅的事件
有时也需要取消订阅事件的功能,比如A突然不想买房了,为了避免收到售楼处推送来的消息,需要取消之前的订阅事件
- 给event对象增加remove事件
event.remove = function(key, fn) {
let fns = this.clientList[key]
if(!fns) return false
// 若没有传入具体回调,则表示取消key对应消息的所有订阅
if(!fn) {
fns && (fns.length = 0)
} else {
for(let i = fns.length - 1; i >= 0; i--) {
if(fns[i] === fn) {
fns.splice(i, 1)
}
}
}
}
- 测试
let salesOffices = {}
installEvent(salesOffices)
// A订阅
salesOffices.listen('squareMeter88', fn1 = price => {
console.log('A订阅 价格=' + price)
})
// B订阅
salesOffices.listen('squareMeter88', fn2 = price => {
console.log('B订阅 价格=' + price)
})
salesOffices.remove('squareMeter88', fn1) // 删除A订阅
salesOffices.trigger('squareMeter88', 3000000)
// B订阅 价格=3000000
6 真实的例子 -- 网站登录
6.1 前置条件
我们正在开发一个商城网站,网站里有header、nav、消息列表、购物车等模块,这几个模块的渲染有一个共同的前提,就是必须先用ajax异步请求获取用户的登录信息
你负责编写登录模块
login.succ(data => {
header.setAvatar(data.avatar); // 设置 header 模块的头像
nav.setAvatar(data.avatar); // 设置导航模块的头像
message.refresh(); // 刷新消息列表
cart.refresh(); // 刷新购物车列表
});
需要了解到
- header模块里设置头像的方法叫setAvatar
- 购物车模块里刷新的方法叫refresh
这是针对具体实现编程 -- 模块之间高耦合
将来可能还有其它模块要使用用户的登录信息
- +收货地址管理模块 --> 往登录模块添加信息
login.succ(data => {
header.setAvatar(data.avatar);
nav.setAvatar(data.avatar);
message.refresh();
cart.refresh();
address.refresh(); // 增加这行代码
});
疲于应付业务,不得不重构代码
- 业务维护--开放封闭原则
- 登录模块只需发布登录成功的消息,并不关心业务方的内部细节
- 异步通知
6.2 实现
- 用发布订阅模式重写后,对用户信息感兴趣的业务模块将自行订阅登陆成功的消息事件
- 当登录成功时,登录模块只需要发布登陆成功的消息
- 而业务放接收到消息之后,自行进行各自的业务处理
改善后的登录模块代码
$.ajax('http://xxx/login', data => {
// 登录成功-发布登录成功的消息
login.trigger('loginSucc', data)
})
各模块监听登录成功的消息
let header =(() => {
login.listen('loginSucc', data => {
header.setAvatar(data.avatar)
})
return {
setAvatar(data) {
console.log(data, '设置header模块的头像')
}
}
})()
let nav = (() => {
login.listen('loginSucc', data => {
nav.setAvatar(data.avatar)
})
return {
setAvatar(data) {
console.log(data, '设置nav模块的头像')
}
}
})()
扩展--新增刷新收货地址列表行为
let address = (() => {
login.listen('loginSucc', data => {
address.refresh(data)
})
return {
refresh(avatar) {
console.log('刷新收货地址列表')
}
}
})()
7 必须先订阅再发布吗
- 在商城网站中,获取用户信息之后才能渲染nav模块,而获取用户信息是一个ajax异步请求。当ajax请求成功返回之后会发布一个事件,在此之前订阅了此事件的nav模块便可以接收到这些用户信息。-- 先订阅后发布
- 懒加载模块需要用到用户信息 -- 在发布后订阅
这种情况下需要将发布的消息保存起来,等有对象订阅时再重新把消息发布给订阅者
- 建立一个存放离线事件的堆栈,将发布事件包裹在一个函数中,暂存在堆栈中
- 当有对象来订阅时,遍历堆栈并且依次执行这些包装函数,即重新发布里面的事件。
8 全局的发布—订阅对象
已经实现了给售楼处对象添加订阅和发布的功能,但还存在两个问题
8.1 存在的问题
- 给每个发布者都添加了listen和trigger方法、缓存列表clientList,造成了一定的资源浪费
- 订阅者需要知道发布者的名字才能顺利订阅消息 -- 存在一定耦合性
// A订阅salesOffices卖家的100平
salesOffices.listen('squareMeter100', price => {
console.log('价格=' + price)
})
// A订阅salesOffices1卖家的88平
salesOffices1.listen('squareMeter88', price => {
console.log('价格=' + price)
})
8.2 解决方案
- 将订阅请求交给中介公司,而售楼处也通过中介来发布房子信息
改进
- 使用全局Event对象实现发布订阅模式
- 订阅者无需了解消息来自哪个发布者
- 发布者无需了解消息推送给哪些订阅者
- 全局Event对象将订阅者和发布者联系起来
class Event {
constructor() {
this.clientList = {}
}
listen(key, fn) {
if(!this.clientList[key]) {
this.clientList[key] = []
}
this.clientList[key].push(fn)
}
trigger() {
let key = Array.prototype.shift.call(arguments)
let fns = this.clientList[key]
if(!fns || fns.length === 0) return false
for(let fn in fns) {
fn.apply(this, arguments)
}
}
remove(key, fn) {
let fns = this.clientList[key]
if(!fns) return false
if(!fn) {
fns && (fns.length = 0)
} else {
for(let i = fns.length - 1; i >= 0; i--) {
if(fns[i] === fn) {
fns.splice(i, 1)
}
}
}
}
}
let e = new Event()
// A订阅消息
e.listen('squareMeter100', price => {
console.log('价格=' + price)
})
// 各售楼处发布消息
e.trigger('squareMeter100', 2000000)
9 全局事件的命名冲突 -- 命名空间
全局的发布订阅对象里只有一个clientList来存放消息名和回调函数 -> 事件名冲突
- 使用命名空间如何实现监听
/************** 先发布后订阅 ********************/
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);
9.1 实现 EventBase 类
class EventBase {
constructor() {
this._default = 'default'
this._slice = Array.prototype.slice
this._shift = Array.prototype.shift
this._unshift = Array.prototype.unshift
this.namespaceCache = {}
}
// 迭代执行回调
each(arr, fn) {}
// 消息订阅
_listen(key, fn, cache) {}
// 移除消息订阅
_remove(key, cache, fn) {}
// 消息发布
_trigger() {}
// 创建命名空间
_create(namespace) {}
}
1. 迭代执行回调
/**
* 迭代执行回调
* @param arr key对应的单个缓存栈
* @param fn 可执行的回调函数
* @return ret
*/
each(arr, fn) {
let ret
for(let i = 0, l = arr.length; i < l; i++) {
let n = arr[i]
ret = fn.call(n, i, n)
}
return ret
}
2. 消息订阅
/**
* 消息订阅
* @param key 消息标识
* @param fn 消息订阅时的回调
* @param cache 消息列表
*/
_listen(key, fn, cache) {
if(!cache[key]) {
cache[key] = []
}
cache[key].push(fn)
}
3. 移除消息订阅
/**
* 移除消息订阅
* @param key 消息标识
* @param cache 消息列表
* @param fn 消息订阅时的回调
*/
_remove(key, cache, fn) {
let fns = cache[key]
if(!fns) return false
if(!fn) {
fns = []
} else {
for(let i = fns.length - 1; i >= 0; i--) {
if(fns[i] === fn) {
fns.splice(i, 1)
}
}
}
}
4 消息发布
/**
* 消息发布
* @param cache 方法名
* @param key 标识
*/
_trigger() {
let cache = this._shift.call(arguments),
key = this._shift.call(arguments),
stack = cache[key],
args = arguments,
_self = this
if(!stack || !stack.length) return
return _self.each(stack, function() {
return this.apply(_self, args)
})
}
5 创建命名空间
/**
* 创建命名空间
* @param namespace 命名空间
*/
_create(namespace) {
namespace = namespace || this._default
let _self = this
let cache = {},
offlineStack = [], // 离线事件
ret = {
// 消息订阅
listen(key, fn, last) {
_self._listen(key, fn, cache)
if(offlineStack === null) return
if(last === 'last') {
offlineStack.length && offlineStack.pop()()
} else {
_self.each(offlineStack, function() {
this()
})
}
offlineStack = null
},
// 订阅最后一个消息
one(key, fn, last) {
_self._remove(key, cache)
this.listen(key, fn, last)
},
// 移除消息订阅
remove(key, fn) {
_self.remove(key, cache, fn)
},
// 消息发布
trigger() {
let fn, args = arguments
_self._unshift.call(arguments, cache)
fn = () => _self._trigger.apply(_self, args)
if(offlineStack) {
return offlineStack.push(fn)
}
return fn()
}
}
return namespace ?
(this.namespaceCache[namespace] ?
this.namespaceCache[namespace] : this.namespaceCache[namespace] = ret)
: ret
}
9.2 实现 Event 类
class Event extends EventBase {
constructor() {
super()
this.create = this._create
}
one(key, fn, last) {
let event = this.create()
event.one(key, fn, last)
}
remove(key, fn) {
let event = this.create()
event.remove(key, fn)
}
listen(key, fn, last) {
let event = this.create()
event.listen(key, fn, last)
}
trigger() {
let event = this.create()
event.trigger.apply(this, arguments)
}
}
9.3 测试
let e = new Event()
e.trigger('click', 1)
e.trigger('click', 2)
e.listen('click', a => {
console.log(a)
})
e.create('namespace1').listen('click', a => {
console.log('namespace1', a)
})
e.create('namespace1').trigger('click', 2)
e.create('namespace2').listen('click', a => {
console.log('namespace2', a)
})
e.create('namespace2').trigger('click', 3)