设计模式 —— 发布订阅模式
《工欲善其事,必先利其器》

我在之前有写过一篇关于 《观察者模式》 的文章,大家有兴趣的可以去看看,个人认为那个例子还是挺生动的。(狗头)
不过今天我们要学习的是,发布订阅模式。那么话不多说,我们开始!
一、什么是发布订阅模式?
发布订阅模式,听起来好像很陌生?但其实我们在工作之中经常有它的射影,例如:
- 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// 判断传入的主题是否为数组,如果是,则遍历数组为每一个主题都添加 fnif (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() {// 取消订阅当前函数对象 onvm.$off(event, on)// 回调执行 fnfn.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!] = nullreturn vm}// specific handler// 如果有 callback 的情况,就遍历,一个个从数组删除取消订阅// 用 while 则是防止意外的错误let cblet i = cbs.lengthwhile (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 = thisif (__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}".`)}}// 获取 callbackslet 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 。为什么?
因为一旦跨组件通信,最大的问题就是事件来源不明确,如果不是自己写的代码,其他人并不知道这东西在哪触发的,啥时候触发,会触发多少次?归根结底就是一个管理困难的问题,没有一个直观的调用顺序,维护起来非常之困难。如果在这个模块上出现问题,那么排查起来将会是灾难级别的。
但是,仁者见仁智者见智吧,我的观点就是 存在即合理。尽可能的做到:不弃用、不滥用。
最后,感谢你的阅读,希望我的文章能够帮到你,愿你的未来一片光明。


















