浅谈JS中的发布订阅模式

508 阅读8分钟

什么是设计模式

在菜鸟教程上,是这样定义的:

设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。

设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。 毫无疑问,设计模式于己于他人于系统都是多赢的,设计模式使代码编制真正工程化,设计模式是软件工程的基石,如同大厦的一块块砖石一样。项目中合理地运用设计模式可以完美地解决很多问题,每种模式在现实中都有相应的原理来与之对应,每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,这也是设计模式能被广泛应用的原因。

而我只想用一个词来形容设计模式:套路

套路

个人理解:设计模式就是解决软件工程中经常遇到的问题的基本套路。那么使用这些套路有什么好处呢?请读者思考两分钟。

为什么使用设计模式

学过软件工程的同学肯定知道,软件工程中有一个概念叫高内聚低耦合,这是判断软件设计好坏的标准。其目的是提高程序模块的可重用性移植性。通常,模块间内聚程度越高,模块间的耦合程度就越低。这些听上去都很抽象,我来举个具体的例子,让你体会下高内聚低耦合的重要性。

客户让我做一个后台管理系统,但是要分权限,其主要功能是增删改查,然后我第一时间想到用表格来展示数据。

element表格

用户大致分六种角色,每种角色都有80%的内容是相同的,20%的内容是根据角色权限确定,我首先把等级最低的用户的管理页面做了出来,然后一顿CV做出了六个页面,然后在具体的页面该具体的字段和功能,一个月没到就做完了,心满意足的把自己的系统给客户看,然后客户此时说我表格哪里少了个什么字段,哪里少了搜索功能,哪里少了个排序功能,哪里修改功能不太友好,然后也没有多选删除。没办法,谁叫甲方是爸爸,为了拿到钱,我只能又回去改。

改代码的时候,我发现好cd(优美的中国话),一个改完了还要再改五个,当时就感觉自己的代码跟“屎山一样”,一个个的找在哪里CV,而且当时我们这个项目是两个人协作的,是按照页面分的,然后改完了发现有的改了,有的没改,改完的效果还不一样,最离谱的是添加的这个页面的功能添加到另一个页面去了,当时就在想,还好是6个页面,不是60个页面,否则我要当场去世。当时我就在想,如果在项目的设计之初,我们把这个表格组件抽离出来,暴露一些借口,引用的时候通过修改表格组件的属性来改变实际内容该多好!

这是作者的亲身经历,而且实际上我记得应该不止6个页面,都是泪的教训,从那以后,我觉得能复用的东西一定要封装起来,要不然后期维护就是一堆“屎山”等着你。

事实上,使用设计模式最主要的一个目的就是解耦,我们来看几个使用设计模式的具体的例子。

策略模式

定义:定义一系列的算法,把他们一个个封装起来,并且使他们可以相互替换。

策略模式的目的就是将算法的使用算法的实现解耦。

一个基于策略模式的程序至少有两部分,第一部分是一组策略(可变),策略封装了具体的算法,即上面说的的算法的实现。第二部分是环境类Context(不变),Context接收客户的请求,随后将请求委托给某个策略类。要做到这一点,说明Context中要维持对某个策略对象的引用。

下面看个实际的例子,根据用户的等级和工资发放年终奖。

不使用策略模式:

const calculateBonus = (level,salary) => {
  switch(level){
    case 's':{
      return salary*4
    }
    case 'a':{
      return salary*3
    }
    case 'b':{
      return salary*2
    }
    default:{
      return 0
    }
  }
  
}

calculateBonus('s',20000)
calculateBonus('a',10000)

可以看到算法的实现和算法的调用都耦合在一起了,这样有两个坏处,一是不利于阅读理解,而是不利于维护。下面我们看看使用策略模式后的代码:

//策略类
const strategies = {
  s: (salary) => salary * 4,
  a: (salary) => salary * 3,
  b: (salary) => salary * 2,
};

//环境类
const calculateBonus = (level, salary) => strategies[level](salary);

calculateBonus("s", 20000);		
calculateBonus("a", 10000);

可以看到我们把策略类抽离了出来,这样当以后有新的年终奖发放算法我们直接在策略类里添加属性就行了,不用更改我们的环境类。

对比一下,下面的代码是不是更容易理解,维护起来是不是更方便?

发布订阅模式

发布订阅模式

应用场景

这个设计模式我觉得是目前应用的最多的模式,没有之一。

这时候肯定有读者产生疑问,此话怎讲?别急,且听我娓娓道来。

新浪微博你用过吗?B站你用过吗?QQ空间你用过吗?如果上述三者你都没用过微信朋友圈你该用过吧。

目前几乎99%的社交平台几乎都使用的是这种模式。

主流社交平台

我们以新浪微博为例,作为普通用户,我们会关注一些大V或者有意思的博主,这个过程叫subscribe;然后有天你关注的明星发了篇微博:”官宣“,这个过程叫publish,然后你接到通知,你关注的XXX发了一篇微博,这个过程叫notify;然后你点开一开,发现官宣对象是你讨厌的人,然后你很气,脱粉了,取消关注,这个过程叫unsubscribe。

观察者模式

这里已经将发布订阅模式的四个核心动作解释了一遍,其实其中还参与了两个对象,一个是观察者(observer),在这个例子中就是作为粉丝的我们,另一个一个是被观察对象(subject),在例子中指的是你关注的明星。其实,有很多人认为,发布订阅模式是观察者模式的一个升级版本。观察者模式是一个对象维持一系列依赖于它的对象(观察者),当对象状态发生变化时主动通知这些观察者。

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

  //收集依赖
  add(observer) {
    this.observers.push(observer);
  }

  //删除依赖
  remove(observer) {
    const index = this.observers.indexOf(observer);
    if (index !== -1) {
      this.observers.splice(index, 1);
    }
  }

  //通知观察者更新
  notify() {
    this.observers.forEach((observer) => observer.update());
  }

  //返回观察者列表的拷贝
  getObservers() {
    return [...this.observers];
  }
}

class Observer {
  //更新操作
  update() {}
}

大名鼎鼎的Promise就采用了这种设计模式,其在原型方法then中收集回调函数(可以理解为只有一个update函数的观察者)加入微任务队列,当其执行resolve或者reject时,状态发生改变,通知相应的回调执行。

代码

Talk is cheap, show me the code. 接下来我们来写一个简单的PubSub类。

class Publisher {
  constructor(id, name) {
    this.id = id;
    this.name = name;
  }
}

class Subscriber {
  constructor(id, name) {
    this.id = id;
    this.name = name;
  }
  //更新操作
  update(publisher, data) {
    console.log(publisher.name + "发布了新内容:" + data);
  }
}

class PubSub {
  constructor() {
    //事件调度中心
    this.pubsub = {};
  }

  /**
   * @description 发布动作
   * @param {object} publisher 发布者
   * @param {object} data 发布内容
   */
  publish(publisher, data) {
    const subscribers = this.pubsub[publisher.id];
    if (!subscribers || !subscribers.length) return;
    subscribers.forEach((subscriber) => subscriber.update(publisher, data));
  }

  /**
   * @description 订阅动作
   * @param {obejct} publisher 发布者
   * @param {object} subscriber 订阅者
   */
  subscribe(publisher, subscriber) {
    //如果该发布者没有订阅者,则初始化数组
    !this.pubsub[publisher.id] && (this.pubsub[publisher.id] = []);

    //将订阅者对象放进事件调度中心
    this.pubsub[publisher.id].push(subscriber);
  }

  /**
   * @description 取消订阅
   * @param {object} subscriber 订阅者
   * @param {object} publisher 发布者
   */
  unsubscribe(publisher, subscriber) {
    const index = this.pubsub[publisher.id].findIndex(
      (sub) => sub.id === subscriber.id
    );
    if (index !== -1) this.pubsub[publisher.id].splice(index, 1);
  }
}

//简单测试
const wzq = new Subscriber(666, "wzq");
const yxy = new Publisher(99, "Evan You");
const pubSub = new PubSub(); //这里建议使用单例模式创建对象
pubSub.subscribe(yxy, wzq);
pubSub.publish(yxy, "Vue 3.2正式发布");
pubSub.unsubscribe(yxy, wzq);
pubSub.publish(yxy, "Vue 4.0将于2022年1月发布"); //没有输出

对比上面两个代码,不难看出,观察者模式的缺点是被观察对象必须自己维护一个观察者列表,当对象状态有更新时,直接调用观察者的update方法;而发布订阅模式有个事件调度中心(也有人称之为管道),其维护了一个哈希表,其key是每个发布者的id,其value是订阅该发布者的订阅者集合(真实场景中可能是其id的集合),这使得发布者和订阅者不直接交互,避免了订阅者和发布者产生依赖关系。发布者通过事件调度中心发布自己的内容,然后触发相应的订阅者执行更新操作。

未完待续...