设计模式 —— 发布订阅模式
《工欲善其事,必先利其器》
我在之前有写过一篇关于 《观察者模式》 的文章,大家有兴趣的可以去看看,个人认为那个例子还是挺生动的。(狗头)
不过今天我们要学习的是,发布订阅模式
。那么话不多说,我们开始!
一、什么是发布订阅模式?
发布订阅模式
,听起来好像很陌生?但其实我们在工作之中经常有它的射影,例如:
- Vue 中的 EventBus, $on 以及 $emit 和 $off;
- Nodejs 中的 EventEmitter,其中 on 和 emit;
- MQTT 中的 Topic,也是应用了此设计模式。
可见,虽然设计模式在日常的业务开发中可能用到的地方并不多,但是一门优秀的框架,其根本上是离不开设计模式
和数据结构算法
的,这两者对于程序员的编程思想有着举足轻重的意义,我们依旧还是有学习它的必要。
那么发布订阅模式
和观察者模式
又有什么区别呢?如下图,我们可以直观的观察到,这两种模式之间的区别:
发布订阅模式
,发布者和订阅者是间接的关系。他们之间其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知,前提必须是,订阅者和发布者任何一方触发了同一个主题。本质上,发布者是否发布,订阅者是无感的(完全解耦)。只有发布者发布的内容是订阅者订阅的主题,订阅者才会收到通知。
而 观察者模式
,观察者和被观察者之间是直接的关系,被观察者的变化都会影响或通知到另外一方。从上图看,我们可以认为:
观察者模式包含着发布订阅模式
;两者都拥有通知客户的能力,发布订阅模式由调度中心分配,没有直接关联,相当于观察者模式的升级版
;观察者模式主体在发生事件时与客体是松解耦的,不需要感知到客体具体的行为,进行统一的update,但主体还是需要感知到客体的存在,初始化时要预先attach到观察者。发布订阅模式主体不需要感知到客体的任何行为或存在,主体和客体通过事件关联,解耦性更强
。
二、为什么会有这两种设计模式?
额。。好问题。。。
个人认为,有时候设计模式就是为了解耦
。
例如,Vue 中数据的双向绑定。假如我某个Object
发生了变化,其中,我有很多个子组件都双向绑定
了这个对象的不同字段。那么其实,我的 Object
中只需要 update 其中一个字段,则对应的子组件中的字段就会发生改变,这个过程中观察
其他字段的子组件,是无感的。
反之,如果全部都集中管理这些字段,假如这时候我新增了一个字段或删减了一个字段,那么我就需要重写这部分的代码。而且每触发一次字段,所有的子组件都要被通知一遍,这对性能的损耗无疑是巨大的。这就是高度耦合
的不好的地方。
解耦
的思想,对于程序员来讲也是非常重要的。因此,就有了这两种设计模式
。
三、如何实现发布订阅模式?
备注,以下代码都在 Node 环境下编写的,有需要的小伙伴自行查阅文末的 git。
举例: 实现一个 EventBus
。就是所谓的 事件总线模式
,其实就和发布订阅模式
非常类似,比如我们关注了一个作者,作者发布文章之后我们就能收到信息,这就是一种订阅发布的关系。
export default class EventBus {
constructor() {
this.eventId = 0;
this.eventLine = {};
}
$on(eventName, handler) {
this.eventId++;
if (!this.eventLine[eventName]) this.eventLine[eventName] = {};
this.eventLine[eventName][this.eventId] = handler;
console.log(eventName + "新增了一位粉丝!!");
return this.eventId;
}
$emit(eventName, ...args) {
const eventHandlers = this.eventLine[eventName];
for(const id in eventHandlers) {
eventHandlers[id](...args);
}
}
$off(eventName, key) {
delete this.eventLine[eventName][key];
if (!Object.keys(this.eventLine[eventName]).length) delete this.eventLine[eventName];
console.log("一位粉丝取消了关注");
}
}
然后,实例化事件总线,并模拟一下场景:
import EventBus from "EventBus.js";
const eventBus = new EventBus;
// 关注
const key1 = eventBus.$on("vk哥", (articleName) => {
// 张三关注了你,有发布新文章请通知它
console.log("vk哥发布新文章了!!—— 《" + articleName + "》,通知了张三。");
})
const key2 = eventBus.$on("vk哥", (articleName) => {
// 李四关注了你,有发布新文章请通知它
console.log("vk哥发布新文章了!!—— 《" + articleName + "》,通知了李四。");
})
// 发布
eventBus.$emit("vk哥", "设计模式——发布订阅模式");
// 取消关注
eventBus.$off("vk哥", key2);
// 取消关注后再次发布
eventBus.$emit("vk哥", "Vue2.0源码剖析");
然后,让我们看看效果:
可见,发布者和订阅者通过同一个主题,也就是 “vk哥”,来绑定关系的。换句话说,发布者是否发布与订阅者是否订阅,没有直接的关联,达到了完全解耦的目的。
四、Vue 源码中的发布订阅模式
温馨提示:含有英文的注释都是源码的原注释,中文的注释才是我自己理解的注释。
在 Vue
里面,发布订阅模式就体现在它自带的事件总线的方法:
- Vue.$on
- Vue.$once
- Vue.$off
- Vue.$emit
所以接下来,我们就这四个核心方法进行分析。
在 eventsMixin
里面:
- Vue.prototype.$on
Vue.prototype.$on = function (
event: string | Array<string>,
fn: Function
): Component {
const vm: Component = this
// 判断传入的主题是否为数组,如果是,则遍历数组为每一个主题都添加 fn
if (isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
// 如果不是,就直接为当前主题添加 fn
;(vm._events[event] || (vm._events[event] = [])).push(fn)
// optimize hook:event cost by using a boolean flag marked at registration
// instead of a hash lookup
// 这里是判断是否为子组件注入额外的声明周期钩子, 可以选择不看
if (hookRE.test(event)) {
vm._hasHookEvent = true
}
}
return vm
}
- Vue.prototype.$once
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this
// 把订阅和取消订阅封装成一个新函数对象
function on() {
// 取消订阅当前函数对象 on
vm.$off(event, on)
// 回调执行 fn
fn.apply(vm, arguments)
}
// 为新函数对象的 fn 赋值
on.fn = fn
// 用新函数对象 on 订阅主题
vm.$on(event, on)
return vm
}
- Vue.prototype.$off
Vue.prototype.$off = function (
event?: string | Array<string>,
fn?: Function
): Component {
const vm: Component = this
// all
// 如果没有传入参数,则将所有的主题全部清空为一个空对象
// Object.create(null) 是创建一个空对象
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// array of events
// 判断是否主题为数组,如果是则遍历取消订阅
if (isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
// specific event
// vm._events[event!] 是强解析,判断必有 event 参数的情况,这是 typescript 语法
const cbs = vm._events[event!]
if (!cbs) {
// 有 event 参数,但是已经没有 callback 了
return vm
}
// 如果没有 fn 参数,则设置主题为 null,并不删除主题
if (!fn) {
vm._events[event!] = null
return vm
}
// specific handler
// 如果有 callback 的情况,就遍历,一个个从数组删除取消订阅
// 用 while 则是防止意外的错误
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
- Vue.prototype.$emit
Vue.prototype.$emit = function (event: string): Component {
const vm: Component = this
if (__DEV__) {
const lowerCaseEvent = event.toLowerCase()
if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
tip(
`Event "${lowerCaseEvent}" is emitted in component ` +
`${formatComponentName(
vm
)} but the handler is registered for "${event}". ` +
`Note that HTML attributes are case-insensitive and you cannot use ` +
`v-on to listen to camelCase events when using in-DOM templates. ` +
`You should probably use "${hyphenate(
event
)}" instead of "${event}".`
)
}
}
// 获取 callbacks
let cbs = vm._events[event]
if (cbs) {
// 如果 callback 的长度大于1,就把 vm._events[event] 整理成数组,方便循环
cbs = cbs.length > 1 ? toArray(cbs) : cbs
// 这里是将参数的第一项 eventName 去除
const args = toArray(arguments, 1)
// 定义错误提示
const info = `event handler for "${event}"`
// 遍历把每个 callback 都执行一次
for (let i = 0, l = cbs.length; i < l; i++) {
// 这个是错误捕获函数,一旦报错会把 info 抛出,但并不会让整个js进程奔溃
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
以上仅仅是部分代码,我只是取出了 Event 的核心,也就是本文所讲的 发布订阅模式
而已。相信结合我的注释看应该能理解,如果有兴趣的小伙伴也可以自己看一下源码的伟大!!!(狗头)
五、Vue 事件总线的弊端
针对全局的组件通信,个人建议最好就不用 EventBus
。为什么?
因为一旦跨组件通信,最大的问题就是事件来源不明确,如果不是自己写的代码,其他人并不知道这东西在哪触发的,啥时候触发,会触发多少次?归根结底就是一个管理困难的问题,没有一个直观的调用顺序,维护起来非常之困难。如果在这个模块上出现问题,那么排查起来将会是灾难级别的。
但是,仁者见仁智者见智吧,我的观点就是 存在即合理。尽可能的做到:不弃用、不滥用。
最后,感谢你的阅读,希望我的文章能够帮到你,愿你的未来一片光明。