前端JS高频面试题---4.策略模式

819 阅读11分钟

前言

本节为大家带来的是策略模式,这种设计模式的难度不大,在面试中也几乎没有什么权重,但是却对大家培养良好的编码习惯和重构意识却大有脾益。

image.png

举个简单的例子

策略模式不太适合一上来就怼概念,容易懵。咱们先从一个非常贴近业务的需求讲起,跟我敲完这段代码,自然会知道策略模式是怎么回事。

先来看一个真实场景

相信大家都经历过淘宝大促,这天李雷想在自己电商网站实现这样一个功能,也就是差异化询,啥是差异化询价呢?就是说同一个商品,我通过在后台给它设置不同的价格类型,可以让他展示不同的价格。具体的逻辑如下:

  • 当价格类型为“预售价”时,满 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 里的预热价逻辑改了,你还得再复制粘贴一次,扎心啊老铁!

image.png

到这里,在单一功能原则的指引下,我们已经解决了一半的问题。

我们现在来捋一下,其实这个询价逻辑整体上来看只有两个关键动作:

询价逻辑的分发 ——> 询价逻辑的执行

在改造的第一步,我们已经把“询价逻辑的执行”给摘了出去,并且实现了不同询价逻辑之间的解耦。接下来,我们就要拿“分发”这个动作开刀。

开放封闭改造 剩下一半的问题是啥呢?就是咱们上面说的那个新人价的问题——这会儿我要想给 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,而要考虑更优的映射方案。

再举个更常用的例子

相信大家看了上边的代码,应该初步了解了策略模式的意义以及好处,接下来再来一个更常用的例子。

image.png

相信前端开发的小伙伴都知道什么是表单校验,也应该有些小伙伴自己写过表单校验:

现在编写表单校验的第一个版本,可以提前透露的是,目前我们还没有引入策略模式。代码 如下:

<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 要向客户暴露它的所有实现,这是违反最少知识原则的。

小结

本节的代码有点多,意在为各位小伙伴加深理解此设计模式,各位老铁,抱拳了。

image.png

本节我们既提供了接近传统面向对象语言的策略模式实现,也提供了更适合JavaScript 语言 的策略模式版本。在JavaScript 语言的策略模式中,策略类往往被函数所代替,这时策略模式就 成为一种“隐形”的模式。尽管这样,从头到尾地了解策略模式,不仅可以让我们对该模式有更 加透彻的了解,也可以使我们明白使用函数的好处。

感谢

谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。

我是Nicnic,如果觉得写得可以的话,请点个赞吧❤。

写作不易,「点赞」+「在看」+「转发」 谢谢支持❤

往期好文

《前端JS高频面试题---1.发布-订阅模式》

《前端JS高频面试题---2.单例模式》

《前端JS高频面试题---3.代理模式》

《前端CSS高频面试题---1.CSS选择器、优先级、以及继承属性》

《前端CSS高频面试题---2.em/px/rem/vh/vw的区别》