设计模式在前端开发中的实践(十一)——发布订阅模式

前言

本来我是不想写关于发布订阅模式的文章的,因为很多掘友都是Vue专家,早就对发布订阅模式烂熟于心了,我要是再写这样的文章,感觉有点儿在大家面前班门弄斧了,哈哈哈。

但是,可能有些同学掌握的发布订阅模式或其使用场景是比较局限的,因此,我决定给大家分享一下我的一些心得。

1、基本概念

发布-订阅模式(又叫观察者模式)定义了一种一对多的依赖关系,让多个观察对象同时监听某一个主题对象,这个主题对象的状态发生变化时,会通知所有观察者对象,使他们能够主动更新自己。

上述是比较官方的话术解释,举个实际生活中的例子:

比如在实际开发中,前端开发的工作需要依赖其他同事,在其他同事完成工作这个期间,我们是无法进行开发的,但是我们又想在这个期间做一些准备工作,但是又不想一会儿又去打扰同事问:好了没有呀?此刻我们就会跟同事交代,你如果开发完成了的话,给我说一下吧。然后同事就会给你说,知道了,我的工作做完我就告诉你,你现在就放心的去干别的吧~。

这个过程中,给同事交代他完成之后要通知我们,这其实就是订阅,当同事的工作开发完成之后,他就会告诉我们他的工作已经完成了,这其实就是发布

发布订阅模式的UML图如下:

订阅者需要持有观察者,并且把自己加到(也支持移除)它订阅者中,正是因为这样,发布订阅模式有一个缺点就是如果订阅者还没有持有观察者,那么观察者就会错过订阅者之前的通知,所以,在有些场景下就对业务代码的执行顺序有要求,后面的篇幅我们尝试用一些手段来修正这个问题,就可以消除使用的心智负担。

2、代码示例

以下是一个可以支持频道的发布订阅模式的代码范式,因为JS的语言灵活性,我们可以不用设计UML图中描述的State属性和Update方法。

ts 复制代码
abstract class Observer {
  // some logical
}

abstract class Subject {
  private observers: Map<string, Set<Observer>> = new Map();

  attach(channel: string, observer: Observer) {
    // 在订阅的时候增加频道
    let obs = this.observers.get(channel);
    if (!obs) {
      obs = new Set();
    }
    obs.add(observer);
    this.observers.set(channel, obs);
  }

  detach(channel: string, observer: Observer) {
    let obs = this.observers.get(channel);
    if (!obs) {
      return;
    }
    obs.delete(observer);
  }

  notify(channel: string, msg: string) {
    // 只对订阅了这个频道的人进行通知
    const obSet = this.observers.get(channel);
    if (obSet) {
      obSet.forEach((ob) => {
        ob.update(msg);
      });
    }
  }
}

class ConcreteObserver extends Observer {
// some logical
}

class ConcreteSubject extends Subject {
 // some logical
}

const sub = new ConcreteSubject();
const obTom = new ConcreteObserver();
const obJohn = new ConcreteObserver();
sub.attach("villa", obTom);
sub.attach("department", obJohn);
sub.notify("villa", "别墅降价了");
sub.notify("villa", "别墅又降价了");
sub.notify("department", "公寓涨价了,再不买,买不到了");

3、前端开发中的实践

对于发布订阅模式可以说没有一个前端不熟悉它的身影了,因为Vue的双向绑定正是应用了这个设计模式,所以大家迫于面试就会去了解它。

3.1 EventEmitter

EventEmitter就是典型的发布订阅模式的实现,可以用它进行组件"远距离"通信(如Vue的事件管道,可以实现任何两个组件之间的通信),理解了发布订阅模式的设计思想,其实这个EventEmitter实现起来也就相当容易了,以下是我实现的一个EventEmitter

ts 复制代码
class EventEmitter {
  private _map: Map<string, ((...args: any[]) => void)[]> = new Map();

  /**
   * 触发一个事件
   */
  $emit(channel: string, ...args: any[]) {
    const eventSets = this._map.get(channel);
    if (!Array.isArray(eventSets)) {
      return;
    }
    eventSets.forEach((f) => {
      f.apply(this, args);
    });
  }

  /**
   * 单次监听事件
   * @param {String} channel 监听事件的频道
   * @param {Function} handler 监听事件的处理器
   */
  $once(channel: string, handler: (...args: any[]) => void) {
    this.$on(channel, handler, true);
  }

  /**
   * 监听事件
   * @param {String} channel 监听事件的频道
   * @param {Function} handler 监听事件的处理器
   * @param {boolean} once 是否仅监听一次
   */
  $on(channel: string, handler: (...args: any[]) => void, once?: boolean) {
    let eventSets = this._map.get(channel);
    if (!Array.isArray(eventSets)) {
      eventSets = [];
    }
    if (!once) {
      eventSets.push(handler);
    } else {
      const wrapperFunc = function (...args: any[]) {
        handler.apply(this, args);
        this.$off(channel, wrapperFunc);
      };
      eventSets.push(wrapperFunc);
    }
    this._map.set(channel, eventSets);
  }

  /**
   * 移除事件监听
   * @param {String} channel 移除监听事件的频道
   * @param {Function} handler 移除监听事件的处理器
   */
  $off(channel: string, handler: (...args: any[]) => void) {
    const eventSets = this._map.get(channel);
    if (!Array.isArray(eventSets)) {
      console.warn("移除的事件频道不存在");
      return;
    }
    // 如果不传递handler则移除该管道的所有监听
    if (typeof handler !== "function") {
      this._map.delete(channel);
    } else {
      // 否则只删除一个事件监听器
      const delIdx = eventSets.findIndex((f) => f === handler);
      if (delIdx < 0) {
        console.warn("当前尚未设置此handler的监听");
        return;
      } else {
        eventSets.splice(delIdx, 1);
        this._map.set(channel, eventSets);
      }
    }
  }
}

3.2 复杂业务逻辑下实现Promise状态变更

另外,在有些场景,发布订阅模式可以用来控制异步操作,它和Promise结合起来能够达到一个化腐朽为神奇的效果。

在以前的团队使用的Ajax请求库是自己在HTML5提供的fetch API的一层封装(后文简称sdk),其中包裹了许多业务参数,直接调用这个sdk可以省时省力避免因其他因素而产生不确定的bug。但是sdk有个很不方便的特点就是只能当它调用了业务初始化接口获得响应内容结果之后才能正常工作。假设我们在页面中有个接口必须要等到sdk初始化执行,sdk初始化接口一定可以请求成功。

以下是利用发布订阅模式在SDK上的改造:

js 复制代码
class Request {
  request = null;
  constructor() {
    this.timeout = 3000;
    this.requestQueues = [];
  }
  initialize() {
    this.request = axios.create({
      baseURL: "/",
      timeout: this.timeout,
    });
    console.log("this request lib has been initialized!");
    // timeout error
    while (this.requestQueues.length > 0) {
      let execute = this.requestQueues.pop();
      let call = execute.dowork;
      // 如果执行到当前时刻的时候,已经超时,将不在执行了。
      if (execute.timeout) {
        return;
      }
      try {
        const result = typeof call === "function" && call();
        if (result && typeof result.then === "function") {
          result
            .then((response) => {
              execute.trigger("success", response);
            })
            .catch((err) => {
              execute.trigger("error", err);
            });
        } else {
          execute.trigger("success", result);
        }
      } catch (exp) {
        execute.trigger("error", exp);
      }
    }
  }

  post() {
    var args = arguments;

    if (!this.request) {
      const callback = {
        dowork: () => {
          return this.request.post.apply(this, args);
        },
        timeout: false,
      };

      callback.channels = {};

      callback.on = function (channel, func, once = false) {
        callback.channels[channel] = { func, once };
      };

      callback.off = function (channel) {
        delete callback.channels[channel];
      };

      callback.once = function (channel, callback) {
        callback.on(channel, callback, true);
      };

      callback.trigger = function (channel, args) {
        var action = callback.channels[channel];
        if (!action) {
          console.warn("this channel has been off");
          return;
        }
        const { func, once } = action;
        if (typeof func === "function") {
          func(args);
          once && delete callback.channels[channel];
        }
      };
      // 5S内SDK没有初始化成功,则认为SDK初始化超时
      setTimeout(() => {
        callback.off("success");
        callback.off("error");
        callback.timeout = true;
        callback.trigger("timeout");
        console.log("the request lib initialization timeout");
      }, 5000);

      this.requestQueues.push(callback);

      return new Promise((resolve, reject) => {
        callback.on("timeout", function () {
          resolve({ errno: 1, errmsg: "接口请求超时" });
        });

        callback.on("success", function (response) {
          clearInterval(timer);
          resolve(response);
        });

        callback.on("error", function (response) {
          clearInterval(timer);
          reject(response);
        });
      });
    }
    return this.request.post.apply(this, arguments);
  }
}

经过这番改造后,我们就可以无痛编写业务代码了,不需要关心SDK什么时候初始化完成。

以上思路在很多异步初始化的场景下均可以使用,比如我们团队现在使用神策埋点,必须要等神策SDK加载完成之后才能发送埋点请求,但是对于业务开发者来说他不应该关心神策SDK什么时候初始化完成,也就可以利用这种方式改善埋点的方法。

4、改善发布订阅模式

这儿的改善,我们主要体现在2个方面。

第一个点是可以给发布订阅模式增加一个namespace的概念。就拿我们之前实现的EventEmitter来说,它可能是封装成util调用的,但是团队大家都调用的话,就会出现混乱的情况,而假设我们基于业务进行namespace的管理的话,就不用再担心这个问题。

第二个点是改善发布订阅模式必须要先订阅才能发布的不足,这个是最关键的改善,因为在实际的开发中,这个是最容易影响我们的代码质量的,因为组件渲染的时机的问题,可能导致错过信息的bug。

4.1 增加命名空间

这个取决于您的API怎么设计了,我就以用提供方法进行上下文切换的形式来做命名空间了,您也可以根据自己的团队习惯来设计。

接下来我就以上面的EventEmitter实现向大家阐述如何增加命名空间。

ts 复制代码
export class EventEmitter {
  private _anonymousNs = Symbol("anonymous");
  // 二维Map,一维是namespace,二维就是channel
  private _map: Map<
    PropertyKey,
    Map<PropertyKey, ((...args: any[]) => void)[]>
  > = new Map();

  private _namespace: PropertyKey = this._anonymousNs;

  /**
   * 切换到指定的上下文
   * @param namespace 命名空间
   */
  switchNs(namespace: string) {
    this._namespace = namespace;
    return this;
  }

  /**
   * 切换到默认命名空间
   */
  switchToDefaultNs() {
    this._namespace = this._anonymousNs;
  }

  /**
   * 触发一个事件
   */
  $emit(channel: PropertyKey, ...args: any[]) {
    const ns = this._map.get(this._namespace);
    if (!ns) {
      return;
    }
    const eventSets = ns.get(channel);
    if (!Array.isArray(eventSets)) {
      return;
    }
    eventSets.forEach((f) => {
      f.apply(this, args);
    });
    ns.set(channel, eventSets);
  }

  /**
   * 单次监听事件
   * @param channel 监听事件的频道
   * @param handler 监听事件的处理器
   */
  $once(channel: PropertyKey, handler: (...args: any[]) => void) {
    this.$on(channel, handler, true);
  }

  /**
   * 监听事件
   * @param channel 监听事件的频道
   * @param handler 监听事件的处理器
   * @param once 是否仅监听一次
   */
  $on(channel: PropertyKey, handler: (...args: any[]) => void, once?: boolean) {
    let ns = this._map.get(this._namespace);
    if (!ns) {
      ns = new Map();
      this._map.set(this._namespace, ns);
    }
    let eventSets = ns!.get(channel);
    if (!Array.isArray(eventSets)) {
      eventSets = [];
    }
    if (!once) {
      eventSets.push((...params: any[]) => handler(params));
    } else {
      const wrapperFunc = function (...args: any[]) {
        handler.apply(this, args);
        this.$off(channel, wrapperFunc);
      };
      eventSets.push(wrapperFunc);
    }
    ns!.set(channel, eventSets);
  }

  /**
   * 移除事件监听
   * @param channel 移除监听事件的频道
   * @param handler 移除监听事件的处理器
   */
  $off(channel: PropertyKey, handler: (...args: any[]) => void) {
    const ns = this._map.get(this._namespace);
    if (!ns) {
      return;
    }
    const eventSets = ns.get(channel);
    if (!Array.isArray(eventSets)) {
      console.warn("移除的事件频道不存在");
      return;
    }
    // 如果不传递handler则移除该管道的所有监听
    if (typeof handler !== "function") {
      ns.delete(channel);
    } else {
      // 否则只删除一个事件监听器
      const delIdx = eventSets.findIndex((f) => f === handler);
      if (delIdx < 0) {
        console.warn("当前尚未设置此handler的监听");
        return;
      } else {
        eventSets.splice(delIdx, 1);
        ns.set(channel, eventSets);
      }
    }
  }
}

使用:

ts 复制代码
const bus = new EventEmitter();
bus.switchNs("demo").$on("hello", (args) => {
  console.log(args);
});
// 在最近一个namespace上触发事件
bus.$emit("hello", "demo");
// 可以调用switchNs方法切回默认的命名空间

4.2 增加历史消息的订阅

接下来是增加历史消息的订阅,对这个能力的支持思路很简单,就是将之前触发过的事件和参数记录下来。我们可以使用一个数组来进行存储,为了防止内存占用太多,我们限定一下这个数组的长度。

以下是我的一个简单实现:

ts 复制代码
class EventEmitter {
  private _map: Map<string, ((...args: any[]) => void)[]> = new Map();
  // 历史消息的记录
  private _history: Array<{
    channel: string;
    args: any[];
  }> = [];

  private maxCacheSize = 0;

  constructor(maxSize = 10) {
    this.maxCacheSize = maxSize;
  }

  /**
   * 将某次触发的事件记录到历史消息中
   * @param channel 频道
   * @param args 参数列表
   */
  private setHistory(channel: string, args: any[]) {
    const historyArgsRecord = this._history.find((v) => v.channel === channel);
    if (historyArgsRecord) {
      // 用新的消息覆盖旧的消息
      historyArgsRecord.args = args;
    } else {
      this._history.push({
        channel,
        args,
      });
      // 清除最远的记录,保证记录的消息个数永远不超过最大的长度
      while (this._history.length > this.maxCacheSize) {
        this._history.shift();
      }
    }
  }

  /**
   * 内部触发事件的方法
   * @param channel 频道
   * @param record 是否记录到历史消息记录
   * @param args 触发事件的参数列表
   * @returns
   */
  private selfEmit(channel: string, record: boolean, args: any[]) {
    const eventSets = this._map.get(channel);
    // 将消息记录到历史记录中
    record && this.setHistory(channel, args);
    if (!Array.isArray(eventSets)) {
      return;
    }
    eventSets.forEach((f) => {
      f.apply(this, args);
    });
  }

  /**
   * 触发一个事件
   */
  $emit(channel: string, ...args: any[]) {
    this.selfEmit(channel, true, args);
  }

  /**
   * 单次监听事件
   * @param {String} channel 监听事件的频道
   * @param {Function} handler 监听事件的处理器
   */
  $once(channel: string, handler: (...args: any[]) => void) {
    this.$on(channel, handler, true);
  }

  /**
   * 监听事件
   * @param {String} channel 监听事件的频道
   * @param {Function} handler 监听事件的处理器
   * @param {boolean} once 是否仅监听一次
   */
  $on(channel: string, handler: (...args: any[]) => void, once?: boolean) {
    let eventSets = this._map.get(channel);
    if (!Array.isArray(eventSets)) {
      eventSets = [];
    }
    if (!once) {
      eventSets.push((...args) => handler(args));
    } else {
      const wrapperFunc = function (...args: any[]) {
        handler.apply(this, args);
        this.$off(channel, wrapperFunc);
      };
      eventSets.push(wrapperFunc);
    }
    this._map.set(channel, eventSets);
    // 立即触发事件,找到对应的channel
    const historyArgNode = this._history.find((v) => v.channel === channel);
    if (!historyArgNode) {
      return;
    }
    this.selfEmit(historyArgNode.channel, false, historyArgNode.args);
  }

  /**
   * 移除事件监听
   * @param {String} channel 移除监听事件的频道
   * @param {Function} handler 移除监听事件的处理器
   */
  $off(channel: string, handler: (...args: any[]) => void) {
    const eventSets = this._map.get(channel);
    if (!Array.isArray(eventSets)) {
      console.warn("移除的事件频道不存在");
      return;
    }
    // 如果不传递handler则移除该管道的所有监听
    if (typeof handler !== "function") {
      this._map.delete(channel);
    } else {
      // 否则只删除一个事件监听器
      const delIdx = eventSets.findIndex((f) => f === handler);
      if (delIdx < 0) {
        console.warn("当前尚未设置此handler的监听");
        return;
      } else {
        eventSets.splice(delIdx, 1);
        this._map.set(channel, eventSets);
      }
    }
  }
}

在上面的实现中,我支持了同一个频道可以多次订阅,使用时,如果多次订阅大家看起来可能觉得行为有点儿怪异不要奇怪,这是我预期的,你可以根据项目需求进行修改。

ts 复制代码
const bus = new EventEmitter();
bus.$on("hello", (args) => {
  console.log(args);
});
bus.$emit("hello", 1, 2, 3, 4, 5);
bus.$emit("hello", 1, 2, 5);
bus.$on("hello", (args) => {
  done();
});
// 1 2 3 4 5,这是第一次的订阅结果
// 1 2 5,这是第二次触发
// 因为hello这个频道已经触发过两次,所以当再次订阅,会把之前记录的2次历史消息都同步过来,所以执行两次输出
// 1 2 5
// 1 2 5

总结

我个人的理解是把观察者模式和发布订阅模式划等号的,如果你们面试中被问到发布订阅模式和观察者模式的区别的话,我觉得区别就是所谓的观察者模式没有频道的概念,而发布订阅模式有频道的概念,但是他们的本质都是一样的。

在实际的开发中,发布订阅模式非常常见。使用发布订阅模式来改进某些API的设计,能够将一些异步操作封装的看起来像是同步,这种思路能让我们在编写一些基础库避免返回Promise,进而避免了异步的传染的问题(异步的传染,比如一个AsyncFucntion,别人在调用的时候,就会要用await关键字,加上了await关键字,就到这调用的这个异步方法也的函数也要添加async关键字,所以异步就像病菌一样传染了,异步的传染会给我们带来一些使用上的不便)。

在实际的开发中,可以给发布订阅模式增加可以接受历史消息的能力,因为在前端的组件通信的时候,经常会遇到渲染时序的问题,能够接收历史消息可以大大的减少出bug的概率。

最后,根据我多年的开发经验,在Vue开发中我的实际感受是组件间的通信,在万不得已的时候才考虑使用发布订阅模式,使用发布订阅模式进行组件通信最大的问题是数据的流向不清晰,很容易给后面的维护者埋雷。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

相关推荐
玩电脑的辣条哥2 小时前
Python如何播放本地音乐并在web页面播放
开发语言·前端·python
ew452182 小时前
ElementUI表格表头自定义添加checkbox,点击选中样式不生效
前端·javascript·elementui
suibian52352 小时前
AI时代:前端开发的职业发展路径拓宽
前端·人工智能
画月的亮2 小时前
element-ui 使用过程中遇到的一些问题及解决方法
javascript·vue.js·ui
Moon.92 小时前
el-table的hasChildren不生效?子级没数据还显示箭头号?树形数据无法展开和收缩
前端·vue.js·html
m0_526119402 小时前
点击el-dialog弹框跳到其他页面浏览器的滚动条消失了多了 el-popup-parent--hidden
javascript·vue.js·elementui
垚垚 Securify 前沿站2 小时前
深入了解 AppScan 工具的使用:筑牢 Web 应用安全防线
运维·前端·网络·安全·web安全·系统安全
工业甲酰苯胺5 小时前
Vue3 基础概念与环境搭建
前端·javascript·vue.js
lyj1689975 小时前
el-tree选中数据重组成树
javascript·vue.js·elementui
mosquito_lover16 小时前
怎么把pyqt界面做的像web一样漂亮
前端·python·pyqt