前端中的通信模式:观察者与发布 / 订阅模式

45 阅读5分钟

前端有两种常用的通信模式:观察者和发布 / 订阅模式。两者最主要的区别是一对多单向通信还是多对多双向通信的问题。

以微前端为例,如果只需要主应用向各个子应用单向广播通信,并且多个子应用之间互相不需要通信,那么只需要使用观察者模式即可,而如果主应用需要和子应用双向通信,或者子应用之间需要实现去中心化的双向通信,那么需要使用发布 / 订阅模式。

在浏览器中会使用观察者模式来实现内置 API 的单向通信,例如 IntersectionObserverMutationObserverResizeObserver 以及 PerformanceObserver 等,而发布 / 订阅模式则通常是框架提供的一种供外部开发者自定义通信的能力,例如浏览器中的 EventTarget、Node.js 中的 EventEmitter、Vue.js 中的 $emit 等。

观察者模式

观察者模式需要包含 Subject 和 Observer 两个概念,其中 Subject 是需要被观察的目标对象,一旦状态发生变化,可以通过广播的方式通知所有订阅变化的 Observer,而 Observer 则是通过向 Subject 进行消息订阅从而实现接收 Subject 的变化通知,具体如下所示:

image.png

我们以浏览器的 MutationObserver 为例,来看下观察者模式如何运作:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="subject"></div>
  </body>

  <script>
    # 当观察到变动时执行的回调函数
    const callback = function (mutationsList, observer) {
      for (let mutation of mutationsList) {
        if (mutation.type === 'childList') {
          console.log('A child node has been added or removed.')
        } else if (mutation.type === 'attributes') {
          console.log(
            'The ' + mutation.attributeName + ' attribute was modified.'
          )
        }
      }
    }

    # 创建第一个 Observer
    const observer1 = new MutationObserver(callback)

    # Subject 目标对象
    const subject = document.getElementById('subject')

    # Observer 的配置(需要观察什么变动)
    const config = { attributes: true, childList: true, subtree: true }

    # Observer 订阅 Subject 的变化
    observer1.observe(subject, config)

    # 创建第二个 Observer
    const observer2 = new MutationObserver(callback)

    # Observer 订阅 Subject 的变化
    observer2.observe(subject, config)

    # Subject 的属性变化,会触发 Observer 的 callback 监听
    subject.className = 'change class'

    # Subject 的子节点变化,会触发 Observer 的 callback 监听
    subject.appendChild(document.createElement('span'))

    # 这里为什么需要 setTimeout 呢?如果去除会有什么影响吗?
    setTimeout(() => {
      // 取消订阅
      observer1.disconnect()
      observer2.disconnect()
    })
  </script>
</html>

当 DOM 元素(Subject 目标对象)改变自身的属性或者添加子元素时,都会将自身的状态变化单向通知给所有订阅该变化的观察者。

当然上述 Web API 内部包装了很多功能,例如观察者配置。我们可以设计一个更加便于理解的观察者通信方式:

   class Subject {
    constructor() {
      this.observers = [];
    }

    // 添加订阅
    subscribe(observer) {
      this.observers.push(observer);
    }

    // 取消订阅
    unsubscribe() {}

    // 广播信息
    broadcast() {
      this.observers.forEach((observer) => observer.update());
    }
  }

  class Observer {
    constructor() {}

    // 实现一个 update 的接口,供 subject 耦合调用
    update() {
      console.log("observer update...");
    }
  }

  const subject = new Subject();

  subject.subscribe(new Observer());

  subject.broadcast();

  subject.subscribe(new Observer());

  subject.broadcast();

上述观察者模式没有一个实体的 Subject 对象,我们可以结合 DOM 做一些小小的改动,例如:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <!-- 目标对象 -->
    <input type="checkbox" id="checkbox" />

    <!-- 观察者 -->
    <div id="div"></div>
    <h1 id="h1"></h1>
    <span id="span"></span>

    <script>
      class Subject {
        constructor() {
          this.observers = [];
        }

        // 添加订阅
        subscribe(observer) {
          this.observers.push(observer);
        }

        // 取消订阅
        unsubscribe() {}

        // 广播信息
        broadcast(value) {
          this.observers.forEach((observer) => observer.update(value));
        }
      }


      # 观察的目标对象
      const checkbox = document.getElementById("checkbox");

      # 将 subject 实例挂载到 DOM 对象上(也可以单独使用)
      checkbox.subject = new Subject();

      checkbox.onclick = function (event) {
        # 通知观察者 checkbox 的变化
        checkbox.subject.broadcast(event.target.checked);
      };

      # 观察者
      const span = document.getElementById("span");
      const div = document.getElementById("div");
      const h1 = document.getElementById("h1");

      # 观察者实现各自 update 接口
      span.update = function (value) {
        span.innerHTML = value;
      };
      div.update = function (value) {
        div.innerHTML = value;
      };
      h1.update = function (value) {
        h1.innerHTML = value;
      };

      # 添加订阅
      checkbox.subject.subscribe(span);
      checkbox.subject.subscribe(div);
      checkbox.subject.subscribe(h1);
    </script>
  </body>
</html>

发布 / 订阅模式

发布 / 订阅模式需要包含 Publisher、Channels 和 Subscriber 三个概念,其中 Publisher 是信息的发送者,Subscriber 是信息的订阅者,而 Channels 是信息传输的通道,如下所示:

image.png

发布者可以向某个通道传输信息,而订阅者则可以订阅该通道的信息变化。

通过新增通道,可以将发布者和订阅者解耦出来,从而形成一种去中心化的通信模式。

如上图所示,订阅者本身也可以是发布者,从而实现事件的双向通信。

我们以浏览器的 EventTarget 为例,来看下发布 / 订阅模式如何运作:

const event = new EventTarget();
// event 是订阅者
event.addEventListener("channel1", (e) => console.log(e.detail));
// event 是发布者
event.dispatchEvent(
  new CustomEvent("channel1", { detail: { hello: true } })
);

需要注意的是先订阅,后发布,如果先发布后订阅则不行:

event.dispatchEvent(
  new CustomEvent("channel2", { detail: { hello: true } })
);
// 由于先发布后订阅,导致订阅失败,但是发布者不感知订阅者的失败状态
event.addEventListener("channel2", (e) => console.log(e.detail));

我们可以通过简单的几行代码实现上述功能,如下所示:

class Event {
    constructor() {
      this.channels = {};
      // 这里的 token 也可以是随机生成的 uuid
      this.token = 0;
    }

    // 实现订阅
    subscribe(channel, callback) {
      if (!this.channels[channel]) this.channels[channel] = [];
      this.channels[channel].push({
        channel,
        token: ++this.token,
        callback,
      });
      return this.token;
    }

    // 实现发布
    publish(channel, data) {
      const subscribers = this.channels[channel];
      if (!subscribers) return;
      let len = subscribers.length;
      while (len--) {
        subscribers[len]?.callback(data, subscribers[len].token);
      }
    }

    // 取消订阅
    unsubscribe(token) {
      for (let channel in this.channels) {
        const index = this.channels[channel].findIndex(
          (subscriber) => subscriber.token === token
        );
        if (index !== -1) {
          this.channels[channel].splice(index, 1);
          if (!this.channels[channel].length) {
            delete this.channels[channel];
          }
          return token;
        }
      }
    }
  }

  const event = new Event();
  const token = event.subscribe("channel1", (data) => console.log('token: ', data));
  const token1 = event.subscribe("channel1", (data) => console.log('token1: ', data));
  // 打印 token 和 token1
  event.publish("channel1", { hello: true });
  event.unsubscribe(token);
  // 打印 token1,因为 token 取消了订阅
  event.publish("channel1", { hello: true });

发布 / 订阅模式和观察者模式存在明显差异:

  • 首先在功能上观察者模式是一对多的单向通信模式,而发布 / 订阅模式是多对多的双向通信模式。

  • 其次观察者模式需要一个中心化的 Subject 广播消息,并且需要感知 Observer(例如上述的 observers 列表) 实现通知,是一种紧耦合的通信方式。而发布 / 订阅模式中的发布者只需要向特定的通道发送信息,并不感知订阅者的订阅状态,是一种松散解耦的通信方式。