第五章 策略模式

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

5.1 使用策略模式计算奖金

以年终奖计算为例:

假设绩效 S 的年终奖有 4 倍工资,绩效 A 的年终奖有 3 倍工资,绩效 B 的则为 2 倍工资。

我们可以写这样一段代码

js
1var calculateBonus = function (performanceLevel, salary) {
2  switch (performanceLevel) {
3    case 'S':
4      return salary * 4
5    case 'A':
6      return salary * 3
7    case 'B':
8      return salary * 2
9  }
10}
11calculateBonus('B', 20000) // 输出:40000
12calculateBonus('S', 6000) // 输出:24000

calculateBonus 函数接受两个参数,分别是绩效等级和工资水平。

这段代码非常简单,但是存在缺点:

  • 存在太多条件判断分支
  • 缺乏弹性,如果增加一种新的绩效等级,那么我们就需要来改代码,这违反了开放-封闭原则
  • 复用性差

下面是使用组合函数来重构代码。组合函数就是将业务逻辑拆分成很多小函数,将其进行组合。这里是将计算的业务逻辑与判断等级的业务逻辑分开

js
1var performanceS = function (salary) {
2  return salary * 4
3}
4var performanceA = function (salary) {
5  return salary * 3
6}
7var performanceB = function (salary) {
8  return salary * 2
9}
10var calculateBonus = function (performanceLevel, salary) {
11  if (performanceLevel === 'S') {
12    return performanceS(salary)
13  }
14  if (performanceLevel === 'A') {
15    return performanceA(salary)
16  }
17  if (performanceLevel === 'B') {
18    return performanceB(salary)
19  }
20}
21calculateBonus('A', 10000) // 输出:30000

虽然目前来看逻辑是分开了,但是依然很臃肿,系统变化时也缺乏弹性。

使用策略模式来修改代码。

策略模式指的是定义一系列的算法,把它们一个个封装起来。将不变的部分和变化的部分隔开时每个设计模式的主题,策略模式的目的就是将算法的使用和算法的实现隔离开来。

这个例子中,算法的使用方式是不变的,都是根据某个算法来得出金额。算法的实现是多种多样和可变化的,每种绩效对应不同的规则。

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

我们先定义一组策略类,将每种绩效的计算规则都封装在对应的策略类中

js
1var performanceS = function () {}
2performanceS.prototype.calculate = function (salary) {
3  return salary * 4
4}
5var performanceA = function () {}
6performanceA.prototype.calculate = function (salary) {
7  return salary * 3
8}
9var performanceB = function () {}
10performanceB.prototype.calculate = function (salary) {
11  return salary * 2
12}

然后创建一个 Context 环境类,它需要保存策略对象的引用。

js
1// Bontus就是环境类,它用来保存策略对象的引用
2var Bontus = function () {
3  this.salary = null //保存金额 这里是额外属性
4  this.strategy = null //这个属性用来保存策略对象的引用
5}
6Bontus.prototype.setSalary = function (salary) {
7  this.salary = salary
8}
9Bontus.prototype.setStrategy = function (strategy) {
10  //设置策略对象
11  this.strategy = strategy
12}
13Bontus.prototype.getBonus = function () {
14  return this.strategy.calculate(this.strategy)
15}

使用时,先设置金额,再设置策略对象,最后获取结果

js
1var bon = new Bontus()
2bon.setSalary(2000) // 设置金额
3bon.setStrategy(new performanceS()) // 设置策略对象
4bon.getBonus() // 8000
5bon.setSalary(10000)
6bon.setStrategy(new performanceB())
7bon.getBonus() // 20000

上面的代码中,我们先创建一个 bon 对象,并且给他设置一些原始的数据,这里是设置了工资。接下来给他设置一个策略对象,让他内部保存着这个策略对象。当需要计算时,bon 对象本身没有计算的能力,而是将计算委托给保存好的策略对象。

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

详细一点就是:定义一系列的算法,把它们各自封装成策略类,算法被封装在策略类内部的方法里。在客户对 Context 发起请求时,Context 总是把请求委托给这些策略对象中间的某一个进行计算。

5.2 JavaScript 版本的策略模式

上面的代码是模拟传统面向对象语言的实现,我们先创建了一组策略类,然后使用 Context 类来保存策略对象(strategy)的引用,策略对象是通过策略类创建的。最后把请求委托给策略对象来计算结果。

JavaScript 中,策略对象并不需要从各个策略类里面创建,我们直接将其定义成一个对象

js
1const strategy = {
2  S: function (salary) {
3    return salary * 4
4  },
5  A: function (salary) {
6    return salary * 3
7  },
8  B: function (salary) {
9    return salary * 2
10  },
11}

Context 类也并不需要通过 new Bontus 来创建,直接用函数就可以了

js
1var calculateBontus = function (performanceLevel, salary) {
2  return strategy[performanceLevel](salary)
3}
4calculateBontus('S', 2000) // 8000

这种方式比传统类型语言更好理解,也更加简洁。

5.3 多态在策略模式中的体现

通过使用策略模式重构代码,我们消除了原来大片的条件分支语句。所有跟奖金有关的计算我们都封装到各个策略对象中,Context 没有直接计算奖金的能力,而是把职责交给某个策略对象。每个策略对象负责的算法都被封装在对象内部。

当我们对这些策略对象发出计算奖金的请求时,它们会返回各自不同的计算结果,这是对象多态性的体现。

替换 Context 中当前保存的策略对象,便能执行不同的算法来得到我们想要的结果。

5.5 更广义的算法

策略模式指的是定义一系列的算法,并且把它们封装起来。

从定义上看,策略模式就是用来封装算法的。但如果仅仅把策略模式用来封装算法,未免有点大材小用。实际开发中,我们通常会把算法的含义扩展开来,使策略模式也可以封装一系列的业务规则。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。

下面是一个用策略模式完成表单校验用户是否输入合法数据的例子。

5.6 表单验证

以下是表单验证的校验逻辑:

  • 用户名不能为空
  • 密码长度不能少于 6 位
  • 手机号码必须符合格式

5.6.1 表单校验的第一个版本

html
1<form action="" id="registerForm" method="post">
2  请输入用户名:
3  <input type="text" name="userName" />
4  请输入密码:
5  <input type="text" name="password" />
6  请输入手机号码:
7  <input type="text" name="phoneNumber" />
8  <button>提交</button>
9</form>
js
1var registerForm = document.getElementById('registerForm')
2registerForm.onsubmit = function () {
3  if (registerForm.userName.value === '') {
4    alert('用户名不能为空')
5    return false
6  }
7  if (registerForm.password.value.length < 6) {
8    alert('密码长度不能少于6位')
9    return false
10  }
11  if (!/(^1[3|5|8][0-9]{9}$)/.test(registerForm.phoneNumber.value)) {
12    alert('手机号码格式不正确')
13    return false
14  }
15}
  • registerForm.onsubmit 函数包含了很多 if-else 的语句,这些语句需要覆盖所有校验规则
  • 这个函数缺乏弹性,如果想增加一种新的校验规则,或者想要将密码长度的校验从 6 位修改为 8 位。我们都需要进入到函数内部去修改内部,这违反了开发-封闭原则
  • 这个函数复用性差,无法给其他表单复用

5.6.2 用策略模式重构表单校验

  • 第一步:将所有策略规则都封装进入策略对象

    js
    1var strategies = {
    2  isNonEmpty: function (value, errorMsg) {
    3    // 不为空
    4    if (value === '') {
    5      return errorMsg
    6    }
    7  },
    8  minLength: function (value, length, errorMsg) {
    9    // 限制最小长度
    10    if (value.length < length) {
    11      return errorMsg
    12    }
    13  },
    14  isMobile: function (value, errorMsg) {
    15    // 手机号码格式
    16    if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
    17      return errorMsg
    18    }
    19  },
    20}
  • 第二步:新建一个 Context 类,这里名叫 Validator 类。它负责接受用户的请求并委托给 strategy 对象。

    要写 Context 类实现代码,最好先设定好用户如何向它发起请求,也就是这个类如何使用,这有助于我们编写 Validator 类,假定它是这样使用的:

    js
    1var validataFunc = function () {
    2  var validator = new Validator() // 创建一个validator对象
    3  /***************添加一些校验规则****************/
    4  validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空')
    5  validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6位')
    6  validator.add(registerForm.phoneNumber, 'isMobile', '手机号码格式不正确')
    7  var errorMsg = validator.start() // 获得校验结果
    8  return errorMsg // 返回校验结果
    9}
    10var registerForm = document.getElementById('registerForm')
    11registerForm.onsubmit = function () {
    12  var errorMsg = validataFunc() // 如果errorMsg有确切的返回值,说明未通过校验
    13  if (errorMsg) {
    14    alert(errorMsg)
    15    return false // 阻止表单提交
    16  }
    17}

    我们通过 Validator 类来创建一个 validator 对象,用 validator.add 来添加校验规则

    validator.add 接受三个参数:

    validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6位');

    1. 第一个参数为需要校验的内容
    2. 第二个参数表示校验规则,minLength:6是一个以冒号隔开的字符串。冒号前面的 minLength 代表客户挑选的 strategy 对象,冒号后面的数字 6 表示在校验过程中所必需的一些参数。'minLength:6’的意思就是校验 registerForm.password 这个文本输入框的 value 最小长度为 6。如果这个字符串中不包含冒号,说明校验过程中不需要额外的参数信息,比如’isNonEmpty'。
    3. 第三个参数是当校验失败后返回的错误信息

    当添加完校验规则后,我们通过 validator.start 方法启动校验,如果不成功则返回不成功的信息。

    下面是 Validator 类的实现

    js
    1class Validator {
    2  #cache = []
    3  add(dom, rule, errorMessage) {
    4    // 把校验的步骤用空函数包装起来,并且放入cache
    5    this.#cache.push(function () {
    6      const [strategyProperty, ...args] = rule.split(':') //分割出需要传递给验证函数的参数
    7      return strategies[strategyProperty].apply(dom, [dom.value, ...args, errorMessage]) //将验证逻辑委托给策略对象中的验证函数
    8    })
    9  }
    10  start() {
    11    for (let validatorFunc of this.#cache) {
    12      let message = validatorFunc() //调用保存在cache属性中的校验规则函数
    13      if (message) {
    14        return message // 如果有message,则表示验证错误,直接返回
    15      }
    16    }
    17  }
    18}

    在使用策略模式重构代码之后,我们可以通过配置的方式完成一个表单的验证,这些校验规则可以复用在程序的任何地方。

    在修改某个校验规则时,只需要编写或者改写少量的代码。比如我希望将用户名的输入框校验规则改成用户名不少于 4 个字符,修改起来是毫不费力的。

    js
    1validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空') // 改成: validator.add(registerForm.userName, 'minLength:4', '用户名最少4个字');

5.6.3 给某个文本输入框添加多个校验规则

目前上面的代码中一个输入框一次只能验证一种规则,如果我们希望一个输入框能够验证多个规则呢?比如像这样

js
1validator.add(registerForm.userName, [
2  ['isNonEmpty', '用户名不能为空'],
3  ['minLength:10', '用户名长度不能小于10位'],
4])

只需要稍微改写一下 add 并添加一个新的 addRules 方法就可以了

js
1   add(dom, rule, errorMessage) {
2    if (rule instanceof Array) {
3      return this.addRules(dom, rule);
4    }...
5  }
6  addRules(dom, rules) {
7    for (let [rule, errorMessage] of rules) {
8      this.add(dom, rule, errorMessage);
9    }
10  }

这段代码并非 Javascript 设计模式与开发实践中的原代码,由于原代码的实现略麻烦,所以这里做一些修改。

5.7 策略模式的优缺点

优点:

  • 策略模式利用组合、委托和多态等思想,可以有效避免多重选择语句
  • 策略模式提供开放-封闭原则的完美支持,将算法独立在 strategy 中,使它们易于切换,易于扩展
  • 策略模式的算法也可以复用在系统的其他地方
  • 策略模式利用组合和委托来让 Context 拥有执行算法的能力,这也是继承的一种更轻便的替代方案

缺点:

  • 需要增加策略类或者策略对象
  • 使用策略模式,必须了解各个 strategy 之间的不同点,才能选择一个合适的 strategy

5.8 一等函数对象和策略模式

在以类为中心的传统面向对象语言中,不同的算法和行为被封装在各个策略类中,Context 将请求委托给这些策略对象,这些策略对象会根据请求返回不同的执行结果,体现了对象的多态性。

在函数作为一等对象的语言中,策略模式是隐形的。strategy 就是值为函数的变量。

在 JavaScript 中,除了使用类来封装算法和行为,使用函数也是一种选择。

这些“算法”可以被封装到函数中并且四处传递,也就是我们常说的“高阶函数”。实际上在 JavaScript 这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当我们对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。在 JavaScript 中,“函数对象的多态性”来得更加简单。

5.9 小结

JavaScript 版本的策略模式往往被函数所取代,这时策略模式就成为一种隐形的模式。

  • 传统策略模式

    需要一个 Context 环境类和一组 Strategy 类,其中 Strategy 策略类封装了具体的算法,并负责具体的计算过程。在 Context 类中可以保存某一个 Strategy 类的引用,通过这个引用来使用算法。

  • JavaScript 策略模式

    JavaScript 下 Strategy 类也可以是一些函数。Context 环境类也可以是一个函数,通过这个函数来将计算过程委托给 Strategy。

策略模式的目的就是将算法的使用和算法的实现隔离开来。这里的算法是一种广义的算法,可以替代其他业务逻辑,比如表单验证等。