第八章 发布订阅模式
发布-订阅模式又叫观察者模式,它定义对象之间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 中,我们一般用事件模型来替代传统的发布-订阅模式
8.1 现实中的发布-订阅模式
小明想买房,但是售楼处的房子早已售磬。于是小明将号码留在售楼处,让售楼 MM 有了房子之后给他打电话——订阅。
售楼 MM 的手里有想买房客户的花名册,新楼盘推出后,售楼 MM 会翻开花名册,依次给客户发信息通知他们——发布。
8.2 发布-订阅模式的作用
订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅 ajax 请求的 error、succ 事件。
订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布-订阅模式可以让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响他们之间互相通信。
8.3 DOM 事件
我们在 DOM 节点上面绑定过事件函数,这就是一种发布-订阅模式。
js1document.body.addEventListener( 2 'click', 3 function () { 4 alert(2) 5 }, 6 false, 7) 8 9document.body.addEventListener( 10 'click', 11 function () { 12 alert(3) 13 }, 14 false, 15) 16 17document.body.addEventListener( 18 'click', 19 function () { 20 alert(4) 21 }, 22 false, 23) 24 25document.body.click() // 模拟用户点击
在上面的代码中,我们需要监控用户点击 document.body 的动作,但是我们没有办法预知用户在什么时候点击。所以我们订阅 document.body 的 click 事件,当 body 节点被点击时,body 节点就会向订阅者发布这个消息。
8.4 自定义事件
实现发布订阅模式的步骤
- 指定谁充当发布者(售楼处)
- 给发布者添加一个缓存列表,用来存放回调函数以便通知订阅者(花名册)
- 当时机成熟,遍历缓存列表,触发里面存放的订阅者回调函数(通过花名册发短信通知买房)
下面实现一个简单的发布订阅模式
js1const salesOffieces = {} // 订阅对象 2salesOffieces.cache = [] //缓存列表用来放回调函数 3salesOffieces.listen = function (fn) { 4 //订阅消息 5 this.cache.push(fn) //存入缓存列表 6} 7salesOffieces.trigger = function (...rest) { 8 // 需要的时候触发 9 for (let fn of this.cache) { 10 fn.call(this, ...rest) 11 } 12} 13 14// 测试 15salesOffieces.listen(function (e) { 16 console.log(e) 17}) 18// 触发了 19salesOffieces.trigger()
上面的发布订阅比较简单,可惜功能不够,比如不能指定订阅者来发布消息。
我们有必要增加一个标识 key,根据 key 可以给指定的订阅对象发布消息。
js1const salesOffieces = {} // 订阅对象 2 3salesOffieces.cache = {} //缓存列表用来放回调函数 4//订阅时指定key 5salesOffieces.listen = function (key, fn) { 6 //订阅消息 7 if (key in this.cache === false) { 8 this.cache[key] = [] 9 } 10 this.cache[key].push(fn) //存入对应key的缓存列表 11} 12 13// 需要的时候触发 14salesOffieces.trigger = function (key, ...rest) { 15 let fns = this.cache[key] //取出对应key的缓存列表 16 if (!fns || fns.length === 0) { 17 return false 18 } 19 for (let fn of fns) { 20 fn.call(this, key, ...rest) 21 } 22} 23 24salesOffieces.listen('小明', function (key, args) { 25 console.log('price' + key + args) 26}) 27 28salesOffieces.trigger('小明', 20000) 29// "price小明20000"
现在订阅者可以只订阅自己感兴趣的事件了。
8.5 发布-订阅模式的通用实现
下面是通过类来创建发布者的事件中心,使用类可以创建不同的发布者,让发布者可以拥有发布-订阅功能
js1class eventHub { 2 #cache = {} 3 //订阅事件 4 listen(key, fn) { 5 if (key in this.#cache === false) { 6 this.#cache[key] = [] 7 } 8 this.#cache[key].push(fn) 9 } 10 // 发布事件 11 trigger(key, ...rest) { 12 if (!this.#cache[key] || this.#cache[key].length === 0) { 13 return false 14 } 15 for (let fn of this.#cache[key]) { 16 fn.call(this, ...rest) 17 } 18 } 19}
测试一下
js1const a = new eventhub() 2a.listen('a', function (...rest) { 3 console.log(rest) 4}) 5 6a.trigger('a', 1, 2, 3) // [1,2,3]
上述代码是我的改写。《JavaScript 设计模式与开发实践》中并不是采用这种方式,而是直接用一个 event 对象,通过遍历 event 对象,给发布者添加 event 对象身上的 listen、trigger 等属性。
js1var installEvent = function (obj) { 2 for (var i in event) { 3 obj[i] = event[i] 4 } 5}
8.6 取消订阅事件
下面来实现取消订阅事件
js1class eventHub { 2 #cache = {} 3 //订阅事件 4 listen(key, fn) { 5 if (key in this.#cache === false) { 6 this.#cache[key] = [] 7 } 8 this.#cache[key].push(fn) 9 } 10 // 发布事件 11 trigger(key, ...rest) { 12 if (!this.#cache[key] || this.#cache[key].length === 0) { 13 return false 14 } 15 for (let fn of this.#cache[key]) { 16 fn.call(this, ...rest) 17 } 18 } 19 //删除订阅事件 20 remove(key, fn) { 21 if (!key) { 22 return false 23 } 24 //如果没传递指定的函数,则删除全部订阅 25 if (!fn) { 26 this.#cache[key] = [] 27 } 28 const len = this.#cache[key].length 29 // 遍历cache,删除指定的函数 30 for (let i = 0; i < len; i++) { 31 let _fn = this.#cache[key][i] 32 if (fn === _fn) { 33 this.#cache[key].splice(i, 1) 34 break 35 } 36 } 37 } 38}
测试一下
js1const a = new eventHub() 2 3const fn1 = function () { 4 console.log('fn1') 5} 6const fn2 = function () { 7 console.log('fn2') 8} 9a.listen('a', fn1) 10a.listen('a', fn2) 11a.listen('b', fn1) 12a.listen('b', fn2) 13 14a.trigger('a') // fn1 fn2 15a.trigger('b') // fn1 fn2 16a.remove('a') //把a的所有订阅都取消 17a.trigger('a') //取消了 无打印 18a.remove('b', fn2) //给b取消掉fn2函数的订阅 19a.trigger('b') // fn1
8.7 在 React 应用内使用发布-订阅模式传递一个 id
由于书中的例子不是很好理解,我在 React 中试写了一个这个模式,并成功传递了数据,下面是我的代码,都很简单。
我们经常遇到这样一个场景:进入列表页面,可以得到一个 ID,然后通过这个 ID 进行详情页面。
列表页面跟详情页面是兄弟页面,这里我们有几种方式传递 ID
- 我们自然可以用状态提升的方式将 ID 传递给两者的父组件,然后 props 或者 Context 传递(太麻烦)
- Redux 全局管理(太重了)
- 通过 url 的 queryString 传递(常用方法)
- 通过浏览器 storage
- 通过发布-订阅模式传递(不常用但很高级)
下面是发布订阅的代码,采用 TS 编写,方法基本一样,但也略有不同
js1// EventHub.ts 2type Cache = { 3 [key: string]: Array<(args?: unknown) => any>; 4}; 5class _EventHub { 6 private cache: Cache = {}; 7 //订阅事件 8 listen(key, fn) { 9 if (key in this.cache === false) { 10 this.cache[key] = []; 11 } 12 this.cache[key].push(fn); 13 } 14 // 发布事件 15 trigger(key, ...rest) { 16 if (!this.cache[key] || this.cache[key].length === 0) { 17 return false; 18 } 19 const result: any[] = []; 20 for (let fn of this.cache[key]) { 21 result.push(fn.call(this, ...rest)); 22 } 23 //触发后马上清空以免有缓存 24 this.remove(key); 25 return result; 26 } 27 //删除订阅事件 28 remove(key, fn?) { 29 if (!key) { 30 return false; 31 } 32 //如果没传递指定的函数,则删除全部订阅 33 if (!fn) { 34 this.cache[key] = []; 35 } 36 const len = this.cache[key].length; 37 // 遍历cache,删除指定的函数 38 for (let i = 0; i < len; i++) { 39 let _fn = this.cache[key][i]; 40 if (fn === _fn) { 41 this.cache[key].splice(i, 1); 42 break; 43 } 44 } 45 } 46} 47//一个代理类,使用单例模式,可以返回同一个EventHub的实例 48const EventHub = (function () { 49 var cache; 50 return function () { 51 if (cache) { 52 return cache; 53 } 54 return (cache = new _EventHub()); 55 }; 56})(); 57 58export default EventHub;
js1// App.tsx 2import './styles.css' 3import Page1 from './page1' 4import Page2 from './page2' 5import { Routes, Route } from 'react-router-dom' 6export default function App() { 7 return ( 8 <div className="App"> 9 <Routes> 10 <Route path="/page1" element={<Page1 />} /> 11 <Route path="/page2" element={<Page2 />} /> 12 </Routes> 13 </div> 14 ) 15}
js1// Page1.tsx 2import { useEffect } from 'react' 3import { Link } from 'react-router-dom' 4import Eventhub from './eventHub' 5export default function Page1() { 6 const id = 2 7 useEffect(() => { 8 Eventhub().listen('k', function () { 9 return id 10 }) 11 }, []) 12 return ( 13 <div className="page1"> 14 <h1>页面1</h1> 15 <Link to="/page2">to页面2</Link> 16 </div> 17 ) 18}
js1// Page2.tsx 2import './styles.css' 3import Eventhub from './eventHub' 4import { Link } from 'react-router-dom' 5import { useEffect } from 'react' 6 7export default function Page2() { 8 useEffect(() => { 9 const id = Eventhub().trigger('k') 10 console.log('id=' + id) 11 }, []) 12 return ( 13 <div className="page2"> 14 <h2>页面2</h2> 15 <Link to="/page1">to页面1</Link> 16 </div> 17 ) 18}
- 当触发后,需要及时清空 cache 以免有缓存
- 这里使用单例模式,让每次使用 Eventhub 时都返回同一个对象,做到全局使用同一个发布者对象的效果

8.8 全局的发布-订阅对象
在实际开发中,我们不可能使用一次发布-订阅模式就新创建一个发布者对象,比较好的做法是只采用一个全局的 Event 对象来实现。
订阅者不需要知道发布者是谁,而是直接订阅即可。发布者也不需要知道订阅者是谁,只需要按照 key 发布就行。
所以上一节代码中,我使用了一个单例模式,每次执行EventHub()时,都会返回_EventHub的实例。EventHub是一个代理类,它有只返回一个_EventHub实例的职责。
_EventHub是真正的全局的 Event 对象,它充当中介的作用,所有订阅都会通过它,所有发布也都必须经过它。我们不再关心用哪个发布者对象来订阅-发布了,_EventHub可以把订阅者和发布者都联系起来。
8.9 模块间通信
我们要留意另一个问题,模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。
8.10 必须先订阅再发布吗
我们所了解到的发布—订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,这条消息无疑将消失在宇宙中。
在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就如同 QQ 中的离线消息一样,离线消息被保存在服务器中,接收人下次登录上线之后,可以重新收到这条消息。
为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像 QQ 的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。
8.13 小结
发布-订阅模式,也就是我们常说的观察者模式。
优点:
- 在时间上解耦
- 为对象之间解耦
缺点:
- 创建订阅内容需要消耗一定时间和内存,且如果最后都未发生,消息会一直存在。
- 发布-订阅模式可以弱化对象之间的关系,但如果过度使用,对象和对象之间的联系也被深埋在背后,导致程序难以跟踪维护和理解。