发布-订阅模式又称观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象状态发生改变时,所有依赖它的对象都将得到通知。
生活中的发布-订阅模式
小明最近看上一套房子,到了售楼处后才被告知,该楼盘的房子已经售罄了,但是售楼处的 MM 告诉小明,不久后还会有尾盘推出,但是具体是什么时候还不确定。
于是小明记下了售楼处的电话,以后每天都会打电话询问是否已经到了购买时间,除了小明,还有小红、小虎、小强也会向售楼处咨询这个问题,一个星期过后,售楼 MM 决定辞职,因为厌倦了每天回答这么多重复的问题。
但是生活中肯定不会这样,而是小明把手机号留在售楼处,售楼 MM 答应他,当楼盘推出的时候就会发短信告知小明,小红、小虎、小强也是如此,他们的电话号码都被记在售楼处的花名册上,当新楼盘推出时,售楼MM 便会打开花名册,遍历上面的电话号码,一次发送短信来通知。
发布-订阅的作用
从上面的例子中,小明、小红、小虎就是订阅者,他们订阅了房子开售的消息,售楼处就是发布者,他们会在合适的时机去通知购房者。
在上面例子中使用发布-订阅有一下有点:
购房者不用每天都打电话询问售楼处是否有房,而是售楼处在合适的时机通知购房者。这一点可以说明发布-订阅模式可以广泛用于异步编程中,用于代替传统的回调函数。
购房者和售楼处不再强耦合在一起,当有新的购房者出现时,他只需要把手机号留在售楼处,售楼处不用关心购房者的任何情况,售楼处的变动也不会影响购房者,比如销售 MM 离职,这些情况都跟购房者无关,只要售楼处记得发短信这件事即可。这一点可以说明发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式的调用另一个对象的某个接口。
实现一个发布-订阅
class SalesOffices {
clientList = {};
listen(key, fn) {
if (this.clientList[key]) {
this.clientList[key].push(fn);
} else {
this.clientList[key] = [fn];
}
}
notify(key, ...args) {
const fns = this.clientList[key];
if (!fns || fns.length === 0) {
return false;
}
fns.forEach((fn) => {
fn(...args);
});
}
remove(key, fn) {
const fns = this.clientList[key];
if (!fns || fns.length === 0) return false;
if (!fn) {
// 移除所有订阅
fns.length = 0;
} else {
// 移除指定订阅
for (let i = 0, len = fns.length; i < len; i++) {
if (fns[i] === fn) {
fns.splice(i, 1);
break;
}
}
}
}
}
// 售楼处
const salesOffices = new SalesOffices();
// 小明订阅
const mingFn = (type, price) => {
console.log('小明-价格:' + type + price);
};
salesOffices.listen('TypeA', mingFn);
// 小强订阅
salesOffices.listen('TypeB', (type, price) => {
console.log('小强-价格:' + type + price);
});
// 售楼处通知
salesOffices.notify('TypeA', '户型A ', 2000);
salesOffices.notify('TypeB', '户型B ', 2000);
// 小明取消订阅
salesOffices.remove('TypeA', mingFn);
// 售楼处通知
salesOffices.notify('TypeA', '户型A ', 4000);
上面代码中我们通过售楼处例子实现了一个发布-订阅模式,且购房者可以订阅自己想要的户型,当售楼处有房时,也只会通知给订阅了对应户型的购房者,同时购房者还可以取消订阅。
全局的发布-订阅
在上面的例子中存在一个问题,购房者和售楼处还存在一定的耦合性,购房者至少要知道售楼处对象的名称叫 salesOffices
, 才能顺利订阅到事件,如果有其他售楼处的时候,购房者还需要知道它对象的名称。
其实在现实中,买房子未必可以去售楼处,也可以找中介公司,而各大房产公司只需要通知中介公司来发布房子信息,购房者也只需要接受中介公司的消息,不必再关系消息是来自那个房产公司了。
在程序中,发布-订阅可以用一个全局的 Event
对象来实现,订阅者不需要了解消息来自那个发布者,发布者也不知道消息会推送给那些订阅者,Event
作为一个类似 “中介者” 的角色,将其联系在一起:
class Event {
clientList = {};
listen(key, fn) {
if (this.clientList[key]) {
this.clientList[key].push(fn);
} else {
this.clientList[key] = [fn];
}
}
notify(key, ...args) {
const fns = this.clientList[key];
if (!fns || fns.length === 0) {
return false;
}
fns.forEach((fn) => {
fn(...args);
});
}
remove(key, fn) {
const fns = this.clientList[key];
if (!fns || fns.length === 0) return false;
if (!fn) {
// 移除所有订阅
fns.length = 0;
} else {
// 移除指定订阅
for (let i = 0, len = fns.length; i < len; i++) {
if (fns[i] === fn) {
fns.splice(i, 1);
break;
}
}
}
}
}
const event = new Event();
// 小强订阅
event.listen('TypeB', (type, price) => {
console.log('小强-价格:' + type + price);
});
// 售楼处通知
event.notify('TypeB', '户型B ', 6000);
Vue 中的发布-订阅
在 Vue2.x 中我们有时会使用 Event Bus 来实现组件之间的通信,如下面的代码:
// main.js
Vue.prototype.EventBus = new Vue();
// 组件A
this.EventBus.$on('eventName', (param1) => {});
// 组件B
this.$EventBus.$emit('eventName', 'data');
Vue 的 Event Bus 就是发布-订阅的实现,现在我们来看下它的源码:
// src/core/instance/events.js
export function eventsMixin (Vue: Class<Component>) {
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
const vm: Component = this
if (Array.isArray(event)) {
// 多个事件绑定一个回调
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
(vm._events[event] || (vm._events[event] = [])).push(fn)
}
return vm
}
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this
function on () {
// 事件触发后,先用 $off 将事件关闭掉
vm.$off(event, on)
// 然后再触发回调函数
fn.apply(vm, arguments)
}
// 将 fn 缓存到 on 上,这会在 off 的时候用到,因为在 off 中需要使用 fn 进行判断,而这里将 fn 替换成了 on
// 所以需要使用这个缓存 fn 来进行判断
on.fn = fn
// 使用 $on 来注册事件
vm.$on(event, on)
return vm
}
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
const vm: Component = this
// 如果没有传递任何参数的话,则关闭所有注册的事件
if (!arguments.length) {
// 重置 _events
vm._events = Object.create(null)
return vm
}
// 如果 event 是一个数组的话,就遍历关闭对应事件,(不同事件名对应相同的回调)
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
// 关闭
vm.$off(event[i], fn)
}
return vm
}
// 到这里 event 就是一个字符串了
// 在 _events 中找到当前当前 event 对应的回调函数数组
const cbs = vm._events[event]
// 没有的话就结束执行
if (!cbs) {
return vm
}
// 判断是否传递了 fn,没有传递则清空当前 event 下注册的所有事件回调
if (!fn) {
vm._events[event] = null
return vm
}
// 传递了 fn,则在 events 中找到这个对应的回调事件,然后再 events 中删除
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
// 判断寻找对应的 fn
// cb.fn === fn 这个判断使用查找 $once 注册的事件,因为再 $once 中重写了传递的 fn
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
Vue.prototype.$emit = function (event: string): Component {
const vm: Component = this
// 找到 emit 事件的回调函数数组
let cbs = vm._events[event]
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs
// 获取 emit 传递的参数
const args = toArray(arguments, 1)
const info = `event handler for "${event}"`
// 循环触发每一个回调,并传递参数
for (let i = 0, l = cbs.length; i < l; i++) {
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
}
发布-订阅的缺点
发布-订阅的优点非常明显,可以在时间上解耦(异步编程),可以为对象之间解耦(一个对象不用显式的调用另一个对象的接口),但它同样存在缺点,比如当我们订阅了一个消息,这个消息也许到最后都没有发生,但这个订阅者会始终存在内存中,另外发布-订阅虽然可以弱化对象之间的关系,但也将对象于对象之间的必要联系,深埋在了背后,导致程序难以跟踪维护和理解。