【青训营】- JavaScript中常见设计模式(下篇)

行为设计模式

行为型模式封装的是对象的行为变化,用于描述“类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,以及怎样分配职责”。常见的模式包括策略模式(Strategy)迭代器模式(Iterator)发布-订阅模式(Obsever)命令模式(Command)模板方法模式(Template Method)职责链模式(Chain of Responsibility)中间者模式(Mediator)状态模式(State)

1.策略模式(Strategy)

策略模式将定义一系列的算法封装起来,并且使它们可以相互替换。

以年终奖的计算为例,很多公司的年终奖是根据员工的工资基数和年底绩效情况来发放的。例如,绩效为S的人年终奖有4倍工资,绩效为A的人年终奖有3倍工资,而绩效为B的人年终奖是2倍工资。假设财务部要求我们提供一段代码,来方便他们计算员工的年终奖。

我们可以编写一个名为calculateBonus的函数来计算每个人的奖金数额。很显然,calculateBonus函数需要接收两个参数:员工的工资数额和他的绩效考核等级。代码如下:

    var calculateBonus = function (performanceLevel, salary) {
      if (performanceLevel === 'S') {
        return salary * 4;
      }
      if (performanceLevel === 'A') {
        return salary * 3;
      }
      if (performanceLevel === 'B') {
        return salary * 2;
      }
    };
    calculateBonus('B', 20000); // 输出:40000
    calculateBonus('S', 6000); // 输出:24000
复制代码

这段代码十分简单,但是存在着显而易见的缺点。calculateBonus函数比较庞大,包含了很多if-else语句,这些语句需要覆盖所有的逻辑分支。且calculateBonus函数缺乏弹性,如果增加了一种新的绩效等级C,或者想把绩效S的奖金系数改为5,那我们必须深入calculateBonus函数的内部实现,这是违反开放-封闭原则的。

我们利用策略模式来改写这段代码。利用策略模式将定义的一系列的算法封装起来

    var strategies = {
      "S": function (salary) {
        return salary * 4;
      },
      "A": function (salary) {
        return salary * 3;
      },
      "B": function (salary) {
        return salary * 2;
      }
    };
    var calculateBonus = function (level, salary) {
      return strategies[level](salary);
    };
    console.log(calculateBonus('S', 20000)); // 输出:80000
    console.log(calculateBonus('A', 10000)); // 输出:30000
复制代码

将不变的部分和变化的部分隔开是每个设计模式的主题,策略模式也不例外,策略模式的目的就是将算法的使用与算法的实现分离开来。在这个例子里,算法的使用方式是不变的,都是根据某个算法取得计算后的奖金数额。而算法的实现是各异和变化的,每种绩效对应着不同的计算规则。

一个基于策略模式的程序至少由两部分组成。第一个部分是策略组,策略组封装了具体的算法,并负责具体的计算过程。 第二个部分是环境ContextContext接受客户的请求,随后把请求委托给某一个策略。要做到这点,说明Context中要维持对某个策略对象的引用。

2.迭代器模式(Iterator)

迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。

JavaScript内置了迭代器实现,即Array.prototype.forEach。当然,我们也可以自己实现一个迭代器。

    var each = function (ary, callback) {
      for (var i = 0, l = ary.length; i < l; i++) {
        callback.call(ary[i], i, ary[i]); // 把下标和元素当作参数传给callback 函数
      }
    };
    each([1, 2, 3], function (i, n) {
      alert([i, n]);
    });
复制代码

迭代器可以分为内部迭代器外部迭代器,它们有各自的适用场景。我们刚刚编写的each函数属于内部迭代器,each函数的内部已经定义好了迭代规则,它完全接手整个迭代过程,外部只需要一次初始调用。由于内部迭代器的迭代规则已经被提前规定,上面的each函数就无法同时迭代2个数组了。比如现在有个需求,要判断2个数组里元素的值是否完全相等, 如果不改写each函数本身的代码,我们能够入手的地方似乎只剩下each的回调函数了。

    var compare = function (ary1, ary2) {
      if (ary1.length !== ary2.length) {
        throw new Error('ary1 和ary2 不相等');
      }
      each(ary1, function (i, n) {
        if (n !== ary2[i]) {
          throw new Error('ary1 和ary2 不相等');
        }
      });
      alert('ary1 和ary2 相等');
    };
    compare([1, 2, 3], [1, 2, 4]); // throw new Error ( 'ary1 和ary2 不相等' );
复制代码

外部迭代器必须显式地请求迭代下一个元素。外部迭代器增加了一些调用的复杂度,但相对也增强了迭代器的灵活性,我们可以手工控制迭代的过程或者顺序。接下来看看如何用外部迭代器改写compare函数。

    var Iterator = function (obj) {
      var current = 0;
      var next = function () {
        current += 1;
      };
      var isDone = function () {
        return current >= obj.length;
      };
      var getCurrItem = function () {
        return obj[current];
      };
      return {
        next: next,
        isDone: isDone,
        getCurrItem: getCurrItem
      }
    };

    var compare = function (iterator1, iterator2) {
      while (!iterator1.isDone() && !iterator2.isDone()) {
        if (iterator1.getCurrItem() !== iterator2.getCurrItem()) {
          throw new Error('iterator1 和iterator2 不相等');
        }
        iterator1.next();
        iterator2.next();
      }
      alert('iterator1 和iterator2 相等');
    }
    var iterator1 = Iterator([1, 2, 3]);
    var iterator2 = Iterator([1, 2, 3]);
    compare(iterator1, iterator2); // 输出:iterator1 和iterator2 相等
复制代码

3.发布—订阅模式(Obsever)

发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。不论是在程序世界里还是现实生活中,发布-订阅模式的应用都非常之广泛。我们先看一个现实中的例子。

小明、小红、小刚都看上了某个楼盘,但售房处告知该房子已经售罄,但后续会有一些尾盘,于是他们交换了联系方式。在不使用发布-订阅模式的时候,三个人每天都会打电话给售楼处,如果有更多的客户,每天的电话甚至更多;而在使用发布-订阅模式时,售楼处会在有房源时,通知三人。

可以发现,在这个例子中使用发布-订阅模式有着显而易见的优点。

  • 购房者不用再天天给售楼处打电话咨询开售时间,在合适的时间点,售楼处作为发布者会通知这些消息订阅者。
  • 购房者和售楼处之间不再强耦合在一起,当有新的购房者出现时,他只需把手机号码留在售楼处,售楼处不关心购房者的任何情况,而售楼处的其他变动也不会影响购买者。

第一点说明发布-订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。而第二点说明发布-阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。

实际上,只要我们曾经在DOM节点上面绑定过事件函数,那我们就曾经使用过发布-订阅模式。除了DOM 事件,我们还会经常实现一些自定义的事件,这种依靠自定义事件完成的发布-订阅模式可以用于任何JavaScript代码中。

    var salesOffices = {}; // 定义售楼处
    salesOffices.clientList = []; // 缓存列表,存放订阅者的回调函数
    salesOffices.listen = function (fn) { // 增加订阅者
      this.clientList.push(fn); // 订阅的消息添加进缓存列表
    };
    salesOffices.trigger = function () { // 发布消息
      for (var i = 0, fn; fn = this.clientList[i++];) {
        fn.apply(this, arguments); // (2) // arguments 是发布消息时带上的参数
      }
    };

    salesOffices.listen(function (price, squareMeter) { // 小明订阅消息
      console.log('价格= ' + price);
      console.log('squareMeter= ' + squareMeter);
    });
    salesOffices.listen(function (price, squareMeter) { // 小红订阅消息
      console.log('价格= ' + price);
      console.log('squareMeter= ' + squareMeter);
    });
    salesOffices.trigger(2000000, 88); // 输出:200 万,88 平方米
    salesOffices.trigger(3000000, 110); // 输出:300 万,110 平方米
复制代码

4.命令模式(Command)

命令模式(Command)中的命令指的是一个执行某些特定事情的指令。最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系

设想我们正在编写一个用户界面程序,该用户界面上至少有数十个Button按钮。因为项目比较复杂,所以我们决定让某个程序员负责绘制这些按钮,而另外一些程序员则负责编写点击按钮后的具体行为,这些行为都将被封装在对象里。对于绘制按钮的程序员来说,他完全不知道某个按钮未来将用来做什么,可能用来刷新菜单界面,也可能用来增加一些子菜单,他只知道点击这个按钮会发生某些事情。

我们很快可以找到在这里运用命令模式的理由:点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。但是目前并不知道接收者是什么对象,也不知道接收者究竟会做什么。此时我们需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。

  <button id="button1">点击按钮1</button>
  <button id="button2">点击按钮2</button>
  <button id="button3">点击按钮3</button>
复制代码
    var button1 = document.getElementById('button1');
    var button2 = document.getElementById('button2');
    var button3 = document.getElementById('button3');
    var MenuBar = {
      refresh: function () {
        console.log('刷新菜单目录');
      }
    };
    var SubMenu = {
      add: function () {
        console.log('增加子菜单');
      },
      del: function () {
        console.log('删除子菜单');
      }
    };

    var RefreshMenuBarCommand = function (receiver) {
      return {
        execute: function () {
          receiver.refresh()
        }
      }
    };
    var AddSubMenuCommand = function (receiver) {
      return {
        execute: function () {
          receiver.add()
        }
      }
    };
    var DelSubMenuCommand = function (receiver) {
      return {
        execute: function () {
          receiver.del()
        }
      }
    };
    var setCommand = function (button, command) {
      button.onclick = function () {
        command.execute();
      }
    };
    var refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
    var addSubMenuCommand = AddSubMenuCommand(SubMenu);
    var delSubMenuCommand = DelSubMenuCommand(SubMenu);
    setCommand(button1, refreshMenuBarCommand);
    setCommand(button2, addSubMenuCommand);
    setCommand(button3, delSubMenuCommand);
复制代码

点击按钮会执行某个命令,执行命令的动作被约定为调用command对象的execute()方法。虽然还不知道这些命令究竟代表什么操作,但负责绘制按钮的程序员不关心这些事情,他只需要预留好安装命令的接口,command对象自然知道如何和正确的对象沟通

5.模板方法模式(Template Method)

模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

假如我们有一些平行的子类,各个子类之间有一些相同的行为,也有一些不同的行为。如果相同和不同的行为都混合在各个子类的实现中,说明这些相同的行为会在各个子类中重复出现。但实际上,相同的行为可以被搬移到另外一个单一的地方,模板方法模式就是为解决这个问题而生的。在模板方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留待子类来实现

咖啡与茶是一个经典的例子,经常用来讲解模板方法模式,这个例子的原型来自《Head First设计模式》。

首先,我们先来泡一杯咖啡,如果没有什么太个性化的需求,泡咖啡的步骤通常如下:

  1. 把水煮沸
  2. 用沸水冲泡咖啡
  3. 把咖啡倒进杯子
  4. 加糖和牛奶
    var Coffee = function () { };
    Coffee.prototype.boilWater = function () {
      console.log('把水煮沸');
    };
    Coffee.prototype.brewCoffeeGriends = function () {
      console.log('用沸水冲泡咖啡');
    };
    Coffee.prototype.pourInCup = function () {
      console.log('把咖啡倒进杯子');
    };
    Coffee.prototype.addSugarAndMilk = function () {
      console.log('加糖和牛奶');
    };
    Coffee.prototype.init = function () {
      this.boilWater();
      this.brewCoffeeGriends();
      this.pourInCup();
      this.addSugarAndMilk();
    };
    var coffee = new Coffee();
    coffee.init();

复制代码

接下来,开始准备我们的茶,泡茶的步骤跟泡咖啡的步骤相差并不大:

  1. 把水煮沸
  2. 用沸水浸泡茶叶
  3. 把茶水倒进杯子
  4. 加柠檬
  var Tea = function () { };
  Tea.prototype.boilWater = function () {
    console.log('把水煮沸');
  };
  Tea.prototype.steepTeaBag = function () {
    console.log('用沸水浸泡茶叶');
  };
  Tea.prototype.pourInCup = function () {
    console.log('把茶水倒进杯子');
  };
  Tea.prototype.addLemon = function () {
    console.log('加柠檬');
  };
  Tea.prototype.init = function () {
    this.boilWater();
    this.steepTeaBag();
    this.pourInCup();
    this.addLemon();
  };
  var tea = new Tea();
  tea.init();
复制代码

对比泡咖啡和泡茶的过程

Snipaste_2021-09-17_14-25-54.png

我们找到泡咖啡和泡茶主要有以下不同点。

  • 原料不同。一个是咖啡,一个是茶,但我们可以把它们都抽象为“饮料”。
  • 泡的方式不同。咖啡是冲泡,而茶叶是浸泡,我们可以把它们都抽象为“泡”。
  • 加入的调料不同。一个是糖和牛奶,一个是柠檬,但我们可以把它们都抽象为“调料”。

经过抽象之后,不管是泡咖啡还是泡茶,我们都能整理为下面四步:

  1. 把水煮沸
  2. 用沸水冲泡饮料
  3. 把饮料倒进杯子
  4. 加调料

现在可以创建一个抽象父类来表示泡一杯饮料的整个过程。

    var Beverage = function () { };
    Beverage.prototype.boilWater = function () {
      console.log('把水煮沸');
    };
    Beverage.prototype.brew = function () { }; // 空方法,应该由子类重写
    Beverage.prototype.pourInCup = function () { }; // 空方法,应该由子类重写
    Beverage.prototype.addCondiments = function () { }; // 空方法,应该由子类重写
    Beverage.prototype.init = function () {
      this.boilWater();
      this.brew();
      this.pourInCup();
      this.addCondiments();
    };
复制代码

接下来我们要创建咖啡类,并让它继承饮料类:

    var Coffee = function () { };
    Coffee.prototype = new Beverage();
    Coffee.prototype.brew = function () {
      console.log('用沸水冲泡咖啡');
    };
    Coffee.prototype.pourInCup = function () {
      console.log('把咖啡倒进杯子');
    };
    Coffee.prototype.addCondiments = function () {
      console.log('加糖和牛奶');
    };
    var Coffee = new Coffee();
    Coffee.init();
复制代码

至此我们的Coffee类已经完成了,当调用coffee对象init方法时,由于coffee对象Coffee构造器的原型prototype上都没有对应的init方法,所以该请求会顺着原型链,被委托给Coffee的“父类”Beverage原型上的init方法。

Beverage.prototype.init方法中已经规定好了泡饮料的顺序,所以我们能成功地泡出一杯咖啡。Beverage.prototype.init就是模板方法。

6.职责链模式(Chain of Responsibility)

职责链模式的名字非常形象,一系列可能会处理请求的对象被连接成一条链,请求在这些对象之间依次传递,直到遇到一个可以处理它的对象,我们把这些对象称为链中的节点.

图片2.png

职责链模式的例子在现实中并不难找到。当你在高峰时挤上公交,常常因为公交车上人太多而无法投币,所以只好把两块钱硬币往前面递。除非你运气够好,否则,你的硬币通常要在N个人手上传递,才能最终到达投币箱里。

职责链模式的最大的优点是请求发送者只需要知道链中的第一个节点,从而弱化了发送者和一组接收者之间的强联系。如果不使用职责链模式,那么在公交车上,我就得先走到投币箱前,才能投币。

假设我们负责一个售卖手机的电商网站,经过分别交纳500元定金和200元定金的两轮预定后,现在已经到了正式购买的阶段。公司针对支付过定金的用户有一定的优惠政策。在正式购买后,已经支付过500元定金的用户会收到100元的商城优惠券,200元定金的用户可以收到50元的优惠券,而之前没有支付定金的用户只能进入普通购买模式,也就是没有优惠券,且在库存有限的情况下不一定保证能买到。

我们把这个流程写成代码:

    var order = function (orderType, pay, stock) {
      if (orderType === 1) { // 500 元定金购买模式
        if (pay === true) { // 已支付定金
          console.log('500 元定金预购, 得到100 优惠券');
        } else { // 未支付定金,降级到普通购买模式
          if (stock > 0) { // 用于普通购买的手机还有库存
            console.log('普通购买, 无优惠券');
          } else {
            console.log('手机库存不足');
          }
        }
      }
      else if (orderType === 2) { // 200 元定金购买模式
        if (pay === true) {
          console.log('200 元定金预购, 得到50 优惠券');
        } else {
          if (stock > 0) {
            console.log('普通购买, 无优惠券');
          } else {
            console.log('手机库存不足');
          }
        }
      }
      else if (orderType === 3) {
        if (stock > 0) {
          console.log('普通购买, 无优惠券');
        } else {
          console.log('手机库存不足');
        }
      }
    };
复制代码

虽然我们得到了意料中的运行结果,但order函数不仅难以阅读,而且需要经常进行修改。现在我们采用职责链模式重构这段代码。

    var order500yuan = function (orderType, pay, stock) {
      if (orderType === 1 && pay === true) {
        console.log('500 元定金预购,得到100 优惠券');
      } else {
        return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
      }
    };
    var order200yuan = function (orderType, pay, stock) {
      if (orderType === 2 && pay === true) {
        console.log('200 元定金预购,得到50 优惠券');
      } else {
        return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
      }
    };
    var orderNormal = function (orderType, pay, stock) {
      if (stock > 0) {
        console.log('普通购买,无优惠券');
      } else {
        console.log('手机库存不足');
      }
    };

    Function.prototype.after = function (fn) {
      var self = this;
      return function () {
        var ret = self.apply(this, arguments);
        if (ret === 'nextSuccessor') {
          return fn.apply(this, arguments);
        }
        return ret;
      }
    };
    
    var order = order500yuan.after(order200yuan).after(orderNormal);
    order(1, true, 500); // 输出:500 元定金预购,得到100 优惠券
    order(2, true, 500); // 输出:200 元定金预购,得到50 优惠券
    order(1, false, 500); // 输出:普通购买,无优惠券
复制代码

职责链模式可以很好地帮助我们组织代码,但这种模式也并非没有弊端,首先我们不能保证某个请求一定会被链中的节点处理,在这种情况下,我们可以在链尾增加一个保底的接受者节点来处理这种即将离开链尾的请求。另外,职责链模式使得程序中多了一些节点对象,可能在某一次的请求传递过程中,大部分节点并没有起到实质性的作用,它们的作用仅仅是让请求传递下去,从性能方面考虑,我们要避免过长的职责链带来的性能损耗

7.中间者模式(Mediator)

在程序里,也许一个对象会和多个对象打交道,保持对其引用。当程序的规模增大,对象会越来越多,它们之间的关系也越来越复杂,难免会形成网状的交叉引用。当我们改变或删除其中一个对象的时候,很可能需要通知所有引用到它的对象。这样一来,就像在心脏旁边拆掉一根毛细血管一般,即使一点很小的修改也必须小心翼翼。

图片1.png

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享