前言
本节为大家带来的是策略模式,这种设计模式的难度不大,在面试中也几乎没有什么权重,但是却对大家培养良好的编码习惯和重构意识却大有脾益。
举个简单的例子
策略模式不太适合一上来就怼概念,容易懵。咱们先从一个非常贴近业务的需求讲起,跟我敲完这段代码,自然会知道策略模式是怎么回事。
先来看一个真实场景
相信大家都经历过淘宝大促,这天李雷想在自己电商网站实现这样一个功能,也就是差异化询,啥是差异化询价呢?就是说同一个商品,我通过在后台给它设置不同的价格类型,可以让他展示不同的价格。具体的逻辑如下:
- 当价格类型为“预售价”时,满 100 - 20,不满 100 打 9 折
- 当价格类型为“大促价”时,满 100 - 30,不满 100 打 8 折
- 当价格类型为“返场价”时,满 200 - 50,不叠加
- 当价格类型为“尝鲜价”时,直接打 5 折
李雷想了想,立刻来了注意。他首先将四种价格做了标签化:
预售价 - pre
大促价 - onSale
返场价 - back
尝鲜价 - fresh
接下来李雷仔细研究了其中的内容,作为一个资深的if-else侠,他三下五除二就写出了一套功能完备的代码:
// 询价方法,接受价格标签和原价为入参
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
if(tag === 'onSale') {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
if(tag === 'back') {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
if(tag === 'fresh') {
return originPrice * 0.5
}
}
此段代码运行起来确实没什么毛病,相信很多小伙伴也写过这样的代码。但这么多if-else看起来确实不够优雅。 这时李雷的好朋友韩梅梅走了过来,看到了李雷的这段代码,对李雷说:原来你就是人人喊打的if-else侠。
李雷委屈的很:这代码有什么问题吗。韩梅梅说:我们一起看看有什么问题。
- 首先,它违背了“单一功能”原则。一个 function 里面,它竟然处理了四坨逻辑——这个函数的逻辑太胖了!这样会带来什么样的糟糕后果,在前面的小节中已经 BB 过很多次了:比如说万一其中一行代码出了 Bug,那么整个询价逻辑都会崩坏;与此同时出了 Bug 你很难定位到底是哪个代码块坏了事;再比如说单个能力很难被抽离复用等等等等。相信跟着我一路学下来的各位,也已经在重重实战中对胖逻辑的恶劣影响有了切身的体会。总之,见到胖逻辑,我们的第一反应,就是一个字——拆!
- 不仅如此,它还违背了“开放封闭”原则。假如有一天要他加一个满 100 - 50 的“新人价”怎么办?他只能继续 if-else。
李雷听了恍然大悟!!!
功能逻辑重构
单一功能改造 首先,我们赶紧把四种询价逻辑提出来,让它们各自为政:
// 处理预热价
function prePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
function onSalePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
function backPrice(originPrice) {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
function freshPrice(originPrice) {
return originPrice * 0.5
}
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
return prePrice(originPrice)
}
// 处理大促价
if(tag === 'onSale') {
return onSalePrice(originPrice)
}
// 处理返场价
if(tag === 'back') {
return backPrice(originPrice)
}
// 处理尝鲜价
if(tag === 'fresh') {
return freshPrice(originPrice)
}
}
OK,我们现在至少做到了一个函数只做一件事。现在每个函数都有了自己明确的、单一的分工:
prePrice - 处理预热价
onSalePrice - 处理大促价
backPrice - 处理返场价
freshPrice - 处理尝鲜价
askPrice - 分发询价逻辑
如此一来,我们在遇到 Bug 时,就可以做到“头痛医头,脚痛医脚”,而不必在庞大的逻辑海洋里费力去定位到底是哪块不对。
同时,如果我在另一个函数里也想使用某个询价能力,比如说我想询预热价,那我直接把 prePrice 这个函数拿去调用就是了,而不必在 askPrice 肥胖的身躯里苦苦寻觅、然后掏出这块逻辑、最后再复制粘贴到另一个函数去——更何况万一哪天 askPrice 里的预热价逻辑改了,你还得再复制粘贴一次,扎心啊老铁!
到这里,在单一功能原则的指引下,我们已经解决了一半的问题。
我们现在来捋一下,其实这个询价逻辑整体上来看只有两个关键动作:
询价逻辑的分发 ——> 询价逻辑的执行
在改造的第一步,我们已经把“询价逻辑的执行”给摘了出去,并且实现了不同询价逻辑之间的解耦。接下来,我们就要拿“分发”这个动作开刀。
开放封闭改造 剩下一半的问题是啥呢?就是咱们上面说的那个新人价的问题——这会儿我要想给 askPrice 增加新人询价逻辑,我该咋整?我只能这么来:
// 处理预热价
function prePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 20
}
return originPrice * 0.9
}
// 处理大促价
function onSalePrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 30
}
return originPrice * 0.8
}
// 处理返场价
function backPrice(originPrice) {
if(originPrice >= 200) {
return originPrice - 50
}
return originPrice
}
// 处理尝鲜价
function freshPrice(originPrice) {
return originPrice * 0.5
}
// 处理新人价
function newUserPrice(originPrice) {
if(originPrice >= 100) {
return originPrice - 50
}
return originPrice
}
function askPrice(tag, originPrice) {
// 处理预热价
if(tag === 'pre') {
return prePrice(originPrice)
}
// 处理大促价
if(tag === 'onSale') {
return onSalePrice(originPrice)
}
// 处理返场价
if(tag === 'back') {
return backPrice(originPrice)
}
// 处理尝鲜价
if(tag === 'fresh') {
return freshPrice(originPrice)
}
// 处理新人价
if(tag === 'newUser') {
return newUserPrice(originPrice)
}
}
在外层,我们编写一个 newUser 函数用于处理新人价逻辑;在 askPrice 里面,我们新增了一个 if-else 判断。可以看出,这样其实还是在修改 askPrice 的函数体,没有实现“对扩展开放,对修改封闭”的效果。
那么我们应该怎么做?我们仔细想想,楼上用了这么多 if-else,我们的目的到底是什么?是不是就是为了把 询价标签-询价函数 这个映射关系给明确下来?那么在 JS 中,有没有什么既能够既帮我们明确映射关系,同时不破坏代码的灵活性的方法呢?答案就是对象映射!
咱们完全可以把询价算法全都收敛到一个对象里去嘛:
// 定义一个询价处理器对象
const priceProcessor = {
pre(originPrice) {
if (originPrice >= 100) {
return originPrice - 20;
}
return originPrice * 0.9;
},
onSale(originPrice) {
if (originPrice >= 100) {
return originPrice - 30;
}
return originPrice * 0.8;
},
back(originPrice) {
if (originPrice >= 200) {
return originPrice - 50;
}
return originPrice;
},
fresh(originPrice) {
return originPrice * 0.5;
},
};
当我们想使用其中某个询价算法的时候:通过标签名去定位就好了:
// 询价函数
function askPrice(tag, originPrice) {
return priceProcessor[tag](originPrice)
}
如此一来,askPrice 函数里的 if-else 大军彻底被咱们消灭了。这时候如果你需要一个新人价,只需要给 priceProcessor 新增一个映射关系:
priceProcessor.newUser = function (originPrice) {
if (originPrice >= 100) {
return originPrice - 50;
}
return originPrice;
}
这样一来,询价逻辑的分发也变成了一个清清爽爽的过程。当李雷以这种方式新增一个新人价的询价逻辑的时候,就可以底气十足地对测试同学说:老哥,我改了询价逻辑,但是改动范围仅仅涉及到新人价,是一个单纯的功能增加。所以你只测这个新功能点就 OK,老逻辑不用管!
从此,李雷就从人人喊打的 if-else 侠,摇身一变成为了测试之友、中国好开发。业务代码里的询价逻辑,也因为李雷坚守设计原则100年不动摇,而变得易读、易维护。
这就是策略模式
上面这一段重构的过程,就是对策略模式的应用
现在大家来品品策略模式的定义:
定义一系列的算法,把它们一个个的封装起来,并且使它们可以相互替换
回头看看,咱们忙活到现在,是不是就干了这事儿?
但你要直接读这句话,可能确实会懵圈——啥是算法?如何封装?可替换又是咋做到的?
如今你你已经自己动手实现了算法提取、算法封装、分发优化的整个一条龙的操作流,相信面对这条定义,你可以会心一笑——算法,就是我们这个场景中的询价逻辑,它也可以是你任何一个功能函数的逻辑;“封装”就是把某一功能点对应的逻辑给提出来;“可替换”建立在封装的基础上,只是说这个“替换”的判断过程,咱们不能直接怼 if-else,而要考虑更优的映射方案。
再举个更常用的例子
相信大家看了上边的代码,应该初步了解了策略模式的意义以及好处,接下来再来一个更常用的例子。
相信前端开发的小伙伴都知道什么是表单校验,也应该有些小伙伴自己写过表单校验:
现在编写表单校验的第一个版本,可以提前透露的是,目前我们还没有引入策略模式。代码 如下:
<html>
<body>
<form action="http:// xxx.com/register" id="registerForm" method="post">
请输入用户名:<input type="text" name="userName"/ >
请输入密码:<input type="text" name="password"/ >
请输入手机号码:<input type="text" name="phoneNumber"/ >
<button>提交</button>
</form>
<script>
var registerForm = document.getElementById( 'registerForm' );
registerForm.onsubmit = function(){
if ( registerForm.userName.value === '' ){
alert ( '用户名不能为空' );
return false;
}
if ( registerForm.password.value.length < 6 ){
alert ( '密码长度不能少于6 位' );
return false;
}
if ( !/(^1[3|5|8][0-9]{9}$)/.test( registerForm.phoneNumber.value ) ){
alert ( '手机号码格式不正确' );
return false;
}
}
</script>
</body>
</html>
这是一种很常见的代码编写方式,它的缺点跟大促的最初版本一模一样。
- registerForm.onsubmit 函数比较庞大,包含了很多if-else 语句,这些语句需要覆盖所有的校验规则。
- registerForm.onsubmit 函数缺乏弹性,如果增加了一种新的校验规则,或者想把密码的长度校验从6 改成8,我们都必须深入registerForm.onsubmit 函数的内部实现,这是违反开放—封闭原则的。
- 算法的复用性差,如果在程序中增加了另外一个表单,这个表单也需要进行一些类似的校验,那我们很可能将这些校验逻辑复制得漫天遍野。
下面我们将用策略模式来重构表单校验的代码,很显然第一步我们要把这些校验逻辑都封装 成策略对象:
var strategies = {
isNonEmpty: function(value, errorMsg) { // 不为空
if (value === '') {
return errorMsg;
}
},
minLength: function(value, length, errorMsg) { // 限制最小长度
if (value.length < length) {
return errorMsg;
}
},
isMobile: function(value, errorMsg) { // 手机号码格式
if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
return errorMsg;
}
}
};
接下来我们准备实现Validator 类。Validator 类在这里作为Context,负责接收用户的请求 并委托给strategy 对象。在给出Validator 类的代码之前,有必要提前了解用户是如何向Validator 类发送请求的,这有助于我们知道如何去编写Validator 类的代码。代码如下:
var validataFunc = function() {
var validator = new Validator(); // 创建一个validator 对象
/***************添加一些校验规则****************/
validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空');
validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6 位');
validator.add(registerForm.phoneNumber, 'isMobile', '手机号码格式不正确');
var errorMsg = validator.start(); // 获得校验结果
return errorMsg; // 返回校验结果
}
var registerForm = document.getElementById('registerForm');
registerForm.onsubmit = function() {
var errorMsg = validataFunc(); // 如果errorMsg 有确切的返回值,说明未通过校验
if (errorMsg) {
alert(errorMsg);
return false; // 阻止表单提交
}
};
从这段代码中可以看到,我们先创建了一个validator 对象,然后通过validator.add 方法,往validator 对象中添加一些校验规则。validator.add 方法接受3 个参数,以下面这句代码说明:validator.add( registerForm.password, 'minLength:6', '密码长度不能少于6 位' );
- registerForm.password 为参与校验的input 输入框。
- 'minLength:6'是一个以冒号隔开的字符串。冒号前面的minLength 代表客户挑选的strategy对象,冒号后面的数字6 表示在校验过程中所必需的一些参数。'minLength:6'的意思就是校验registerForm.password 这个文本输入框的value 最小长度为6。如果这个字符串中不包含冒号,说明校验过程中不需要额外的参数信息,比如'isNonEmpty'。
- 第3 个参数是当校验未通过时返回的错误信息。 当我们往validator 对象里添加完一系列的校验规则之后,会调用validator.start()方法来启动校验。如果validator.start()返回了一个确切的errorMsg 字符串当作返回值,说明该次校验没有通过,此时需让registerForm.onsubmit 方法返回false 来阻止表单的提交。
最后是Validator 类的实现:
var Validator = function() {
this.cache = []; // 保存校验规则
};
Validator.prototype.add = function(dom, rule, errorMsg) {
var ary = rule.split(':'); // 把strategy 和参数分开
this.cache.push(function() { // 把校验的步骤用空函数包装起来,并且放入cache
var strategy = ary.shift(); // 用户挑选的strategy
ary.unshift(dom.value); // 把input 的value 添加进参数列表
ary.push(errorMsg); // 把errorMsg 添加进参数列表
return strategies[strategy].apply(dom, ary);
});
};
Validator.prototype.start = function() {
for (var i = 0, validatorFunc; validatorFunc = this.cache[i++];) {
var msg = validatorFunc(); // 开始校验,并取得校验后的返回信息
if (msg) { // 如果有确切的返回值,说明校验没有通过
return msg;
}
}
};
使用策略模式重构代码之后,我们仅仅通过“配置”的方式就可以完成一个表单的校验, 这些校验规则也可以复用在程序的任何地方,还能作为插件的形式,方便地被移植到其他项 目中。 在修改某个校验规则的时候,只需要编写或者改写少量的代码。比如我们想将用户名输入框 的校验规则改成用户名不能少于4 个字符。可以看到,这时候的修改是毫不费力的。代码如下:
validator.add( registerForm.userName, 'isNonEmpty', '用户名不能为空' );
// 改成:
validator.add( registerForm.userName, 'minLength:10', '用户名长度不能小于10 位' );
在提高一点难度
为了让读者把注意力放在策略模式的使用上,目前我们的表单校验实现留有一点小遗憾:一 个文本输入框只能对应一种校验规则,比如,用户名输入框只能校验输入是否为空:
validator.add( registerForm.userName, 'isNonEmpty', '用户名不能为空' );
如果我们既想校验它是否为空,又想校验它输入文本的长度不小于10 呢?我们期望以这样 的形式进行校验:
validator.add( registerForm.userName, [
{
strategy: 'isNonEmpty',
errorMsg: '用户名不能为空'
}, {
strategy: 'minLength:6',
errorMsg: '用户名长度不能小于10 位'
}
] );
下面提供的代码可用于一个文本输入框对应多种校验规则:
<html>
<body>
<form action="http:// xxx.com/register" id="registerForm" method="post">
请输入用户名:<input type="text" name="userName" />
请输入密码:<input type="text" name="password" />
请输入手机号码:<input type="text" name="phoneNumber" />
<button>提交</button>
</form>
<script>
/***********************策略对象**************************/
var strategies = {
isNonEmpty: function(value, errorMsg) {
if (value === '') {
return errorMsg;
}
},
minLength: function(value, length, errorMsg) {
if (value.length < length) {
return errorMsg;
}
},
isMobile: function(value, errorMsg) {
if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
return errorMsg;
}
}
};
/***********************Validator 类**************************/
var Validator = function() {
this.cache = [];
};
Validator.prototype.add = function(dom, rules) {
var self = this;
for (var i = 0, rule; rule = rules[i++];) {
(function(rule) {
var strategyAry = rule.strategy.split(':');
var errorMsg = rule.errorMsg;
self.cache.push(function() {
var strategy = strategyAry.shift();
strategyAry.unshift(dom.value);
strategyAry.push(errorMsg);
return strategies[strategy].apply(dom, strategyAry);
});
})(rule)
}
};
Validator.prototype.start = function() {
for (var i = 0, validatorFunc; validatorFunc = this.cache[i++];) {
var errorMsg = validatorFunc();
if (errorMsg) {
return errorMsg;
}
}
};
/***********************客户调用代码**************************/
var registerForm = document.getElementById('registerForm');
var validataFunc = function() {
var validator = new Validator();
validator.add(registerForm.userName, [{
strategy: 'isNonEmpty',
errorMsg: '用户名不能为空'
}, {
strategy: 'minLength:6',
errorMsg: '用户名长度不能小于10 位'
}]);
validator.add(registerForm.password, [{
strategy: 'minLength:6',
errorMsg: '密码长度不能小于6 位'
}]);
validator.add(registerForm.phoneNumber, [{
strategy: 'isMobile',
errorMsg: '手机号码格式不正确'
}]);
var errorMsg = validator.start();
return errorMsg;
}
registerForm.onsubmit = function() {
var errorMsg = validataFunc();
if (errorMsg) {
alert(errorMsg);
return false;
}
};
</script>
</body>
</html>
策略模式的优缺点
策略模式是一种常用且有效的设计模式,本章提供了计算大促、表单校验两个例子来加深大家对策略模式的理解。从这两个例子中,我们可以总结出策略模式的一些优点。
- 策略模式利用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句。
- 策略模式提供了对开放—封闭原则的完美支持,将算法封装在独立的strategy 中,使得它们易于切换,易于理解,易于扩展。
- 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
- 在策略模式中利用组合和委托来让Context 拥有执行算法的能力,这也是继承的一种更轻便的替代方案。
当然,策略模式也有一些缺点,但这些缺点并不严重。 首先,使用策略模式会在程序中增加许多策略类或者策略对象,但实际上这比把它们负责的逻辑堆砌在Context 中要好。 其次,要使用策略模式,必须了解所有的strategy,必须了解各个strategy 之间的不同点,这样才能选择一个合适的strategy。比如,我们要选择一种合适的旅游出行路线,必须先了解选择飞机、火车、自行车等方案的细节。此时strategy 要向客户暴露它的所有实现,这是违反最少知识原则的。
小结
本节的代码有点多,意在为各位小伙伴加深理解此设计模式,各位老铁,抱拳了。
本节我们既提供了接近传统面向对象语言的策略模式实现,也提供了更适合JavaScript 语言 的策略模式版本。在JavaScript 语言的策略模式中,策略类往往被函数所代替,这时策略模式就 成为一种“隐形”的模式。尽管这样,从头到尾地了解策略模式,不仅可以让我们对该模式有更 加透彻的了解,也可以使我们明白使用函数的好处。
感谢
谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。
我是Nicnic,如果觉得写得可以的话,请点个赞吧❤。
写作不易,「点赞」+「在看」+「转发」 谢谢支持❤