第五章 策略模式
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以互相替换。
5.1 使用策略模式计算奖金
以年终奖计算为例:
假设绩效 S 的年终奖有 4 倍工资,绩效 A 的年终奖有 3 倍工资,绩效 B 的则为 2 倍工资。
我们可以写这样一段代码
js1var 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 函数接受两个参数,分别是绩效等级和工资水平。
这段代码非常简单,但是存在缺点:
- 存在太多条件判断分支
- 缺乏弹性,如果增加一种新的绩效等级,那么我们就需要来改代码,这违反了开放-封闭原则
- 复用性差
下面是使用组合函数来重构代码。组合函数就是将业务逻辑拆分成很多小函数,将其进行组合。这里是将计算的业务逻辑与判断等级的业务逻辑分开
js1var 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 中需要保存对某个策略对象的引用。
我们先定义一组策略类,将每种绩效的计算规则都封装在对应的策略类中
js1var 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 环境类,它需要保存策略对象的引用。
js1// 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}
使用时,先设置金额,再设置策略对象,最后获取结果
js1var 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 中,策略对象并不需要从各个策略类里面创建,我们直接将其定义成一个对象
js1const 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 来创建,直接用函数就可以了
js1var 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 表单校验的第一个版本
html1<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>
js1var 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 用策略模式重构表单校验
-
第一步:将所有策略规则都封装进入策略对象
js1var 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 类,假定它是这样使用的:
js1var 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位');- 第一个参数为需要校验的内容
- 第二个参数表示校验规则,
minLength:6是一个以冒号隔开的字符串。冒号前面的 minLength 代表客户挑选的 strategy 对象,冒号后面的数字 6 表示在校验过程中所必需的一些参数。'minLength:6’的意思就是校验 registerForm.password 这个文本输入框的 value 最小长度为 6。如果这个字符串中不包含冒号,说明校验过程中不需要额外的参数信息,比如’isNonEmpty'。 - 第三个参数是当校验失败后返回的错误信息
当添加完校验规则后,我们通过 validator.start 方法启动校验,如果不成功则返回不成功的信息。
下面是 Validator 类的实现
js1class 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 个字符,修改起来是毫不费力的。
js1validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空') // 改成: validator.add(registerForm.userName, 'minLength:4', '用户名最少4个字');
5.6.3 给某个文本输入框添加多个校验规则
目前上面的代码中一个输入框一次只能验证一种规则,如果我们希望一个输入框能够验证多个规则呢?比如像这样
js1validator.add(registerForm.userName, [ 2 ['isNonEmpty', '用户名不能为空'], 3 ['minLength:10', '用户名长度不能小于10位'], 4])
只需要稍微改写一下 add 并添加一个新的 addRules 方法就可以了
js1 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。
策略模式的目的就是将算法的使用和算法的实现隔离开来。这里的算法是一种广义的算法,可以替代其他业务逻辑,比如表单验证等。