第八章 发布订阅模式

发布-订阅模式又叫观察者模式,它定义对象之间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 中,我们一般用事件模型来替代传统的发布-订阅模式

8.1 现实中的发布-订阅模式

小明想买房,但是售楼处的房子早已售磬。于是小明将号码留在售楼处,让售楼 MM 有了房子之后给他打电话——订阅。

售楼 MM 的手里有想买房客户的花名册,新楼盘推出后,售楼 MM 会翻开花名册,依次给客户发信息通知他们——发布。

8.2 发布-订阅模式的作用

订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅 ajax 请求的 error、succ 事件。

订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布-订阅模式可以让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响他们之间互相通信。

8.3 DOM 事件

我们在 DOM 节点上面绑定过事件函数,这就是一种发布-订阅模式。

js
1document.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 自定义事件

实现发布订阅模式的步骤

  • 指定谁充当发布者(售楼处)
  • 给发布者添加一个缓存列表,用来存放回调函数以便通知订阅者(花名册)
  • 当时机成熟,遍历缓存列表,触发里面存放的订阅者回调函数(通过花名册发短信通知买房)

下面实现一个简单的发布订阅模式

js
1const 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 可以给指定的订阅对象发布消息。

js
1const 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 发布-订阅模式的通用实现

下面是通过类来创建发布者的事件中心,使用类可以创建不同的发布者,让发布者可以拥有发布-订阅功能

js
1class 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}

测试一下

js
1const 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 等属性。

js
1var installEvent = function (obj) {
2  for (var i in event) {
3    obj[i] = event[i]
4  }
5}

8.6 取消订阅事件

下面来实现取消订阅事件

js
1class 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}

测试一下

js
1const 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 编写,方法基本一样,但也略有不同

js
1// 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;
js
1// 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}
js
1// 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}
js
1// 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 时都返回同一个对象,做到全局使用同一个发布者对象的效果

React-eventhub

8.8 全局的发布-订阅对象

在实际开发中,我们不可能使用一次发布-订阅模式就新创建一个发布者对象,比较好的做法是只采用一个全局的 Event 对象来实现。

订阅者不需要知道发布者是谁,而是直接订阅即可。发布者也不需要知道订阅者是谁,只需要按照 key 发布就行。

所以上一节代码中,我使用了一个单例模式,每次执行EventHub()时,都会返回_EventHub的实例。EventHub是一个代理类,它有只返回一个_EventHub实例的职责。

_EventHub是真正的全局的 Event 对象,它充当中介的作用,所有订阅都会通过它,所有发布也都必须经过它。我们不再关心用哪个发布者对象来订阅-发布了,_EventHub可以把订阅者和发布者都联系起来。

8.9 模块间通信

我们要留意另一个问题,模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。

8.10 必须先订阅再发布吗

我们所了解到的发布—订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,这条消息无疑将消失在宇宙中。

在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就如同 QQ 中的离线消息一样,离线消息被保存在服务器中,接收人下次登录上线之后,可以重新收到这条消息。

为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像 QQ 的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。

8.13 小结

发布-订阅模式,也就是我们常说的观察者模式。

优点:

  • 在时间上解耦
  • 为对象之间解耦

缺点:

  • 创建订阅内容需要消耗一定时间和内存,且如果最后都未发生,消息会一直存在。
  • 发布-订阅模式可以弱化对象之间的关系,但如果过度使用,对象和对象之间的联系也被深埋在背后,导致程序难以跟踪维护和理解。