1 背景
你是不是遇到过这种场景,需要写多个if去判断相关条件执行代码,如:求以下的VIP费用。
enum ERole = {
/** 普通角色 */
Common = 1,
/** Vip角色 */
Vip = 2,
/** 超级Vip角色 */
SuperVip = 3
};
function getFare(userRole: ERole, month: number) {
if (userRole === ERole.Common) {
return 300 * month;
} else if (userRole === ERole.Vip) {
return 30 * month;
} else if (userRole === ERole.SuperVip) {
return 1 * month;
}
return 0;
}
此时的你,看着这么多if可能有点不爽,灵机一动👀,就用switch改写了一下。
function getFare(userRole: ERole, month: number) {
switch(userRole) {
case ERole.Common:
return 300 * month;
case ERole.Vip:
return 30 * month;
case ERole.SuperVip:
return 1 * month;
default:
return 0;
}
}
然后看了看,用swich写和用if...else写,没啥区别鸭,一样是写这么多的代码 TOT~ 想哭 ~
网上有个推文,教你如何消除多if判断语句,它是使用key - value的形式,来消除if...else语句。
function getFare(userRole: ERole, month: number) {
// 1.算法实现
const FARE_STRATEGY = {
[ERole.Common]: 300 * month,
[ERole.Vip]: 30 * month,
[ERole.SuperVip]: 1 * month
};
// 2.算法使用
return fareStrategy[userRole] ?? 0;
}
getFare(ERole.Common, 12);
哇🤩,一看代码量瞬间减少,简洁优雅,嗨森✧*。٩(ˊᗜˋ*)و✧*。
2 定义
其实这种做法,是策略模式的实现,我们先来看看其定义和目的。
定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
目的:将算法的使用与算法的实现分离开来。
用法:从上面的例子🌰来看,我们将求取费用的算法,封装在了一个FareStrategy的策略对象中,使用时只需要引用对象,用相关的key值,命中相关的策略value值即可。
好处:策略对象中的策略,一个改动,不会影响到其它的策略,耦合性低,简单省心。
3 扩展
3.1 性能的扩展
假如,你计算费用的过程相当之耗时,又有n个策略(无限多...),在策略对象中直接赋值计算好的结果,是不太高效的。此时可以将策略的计算改为惰性函数,使用到了相关策略,才去求值计算,这样子就能避免大量不必要的计算。
// 1.策略实现(策略方法为惰性函数)
const FARE_STRATEGY = {
[ERole.Common]: (month: number) => 300 * month,
[ERole.Vip]: (month: number) => 30 * month,
[ERole.SuperVip]: (month: number) => 1 * month
};
// 2.策略使用
function getFare(
userRole: ERole, fareStrategy: {[key: number}: () => number, month: number
) {
return fareStrategy[userRole]?.(month) ?? 0;
}
getFare(ERole.Common, FARE_STRATEGY, 12);
3.2 理想是完美的,现实是残酷的
产品经理有一天,告诉你,费用的计算,不仅区分用户角色类型,还要使用男女性别计算,还有。。。我是不是要把写好的代码,推倒,重新用回if...else? TOT~ 想哭 ~
别慌,简单多条件联合策略可以帮到你,上码(顺带用上ES6模板字符串的新特性) ~
// 1.简单多条件联合策略的实现
enum ESex = {
Male,
Female
};
const FARE_STRATEGY = {
[`${ERole.Common}${ESex.Male}`]: (month: number) => 300 * month,
[`${ERole.Common}${ESex.Female}`]: (month: number) => 300 * month * 0.8,
[`${ERole.Vip}${ESex.Male}`]: (month: number) => 30 * month,
[`${ERole.Vip}${ESex.Female}`]: (month: number) => 30 * month * 0.5,
[`${ERole.SuperVip}${ESex.Male}`]: (month: number) => 1 * month,
[`${ERole.SuperVip}${ESex.FeMale}`]: (month: number) => 1 * month * 0.7,
};
// 2.策略的使用
function getFare(
userRole: ERole,
sex: ESex,
fareStrategy: {[key: number}: (month: number) => number,
month: number
) {
return fareStrategy[`${userRole}${sex}`]?.(month) ?? 0;
}
get(ERole.Common, ESex.Male, FARE_STRATEGY, 12);
3.3 理想是完美的,现实是狠狠残酷的
产品经理过了一天又告诉你,费用的计算根据单双月不一样,根据节日不一样,根据各种营销活动不一样,根据。。。
(就是让你知道代码执行,哪会只使用简单的值,就可以判断,会有多个复杂条件联合判断)。
!!!天哪,想这么久的简单多条件联合策略用不了,产品想要这样:
function getFare(params: object, month: number) {
const { paramA, paramB, paramC, paramD, paramE, paramF } = params;
if (paramA > paramB) {
return 300 * month;
} else if (paramB > paramC) {
return 100 * month;
} else if (paramC > paramD && paramD > paramE) {
return 88 * month;
} else if (paramE > paramF) {
return 1 * month;
}
return 0;
}
每一个判断的条件都不是简单的常量,都是复杂的联合条件判断,用不了之前的对象策略形式。而且后一个else if依赖前一个if的判断条件。例如:
return 100 * month,是通过(paramA <= paramB && paramB > paramC)来判断的。
假如有一天,产品让你去掉if (paramA > paramB)这个前置判断条件,后面的else if 和 else全部都要改动,判断分支这么多,改完出bug,还是你背锅,TOT~ 想哭 ~
有木有,更好的解决方法呢?别慌,我们首先将判断的过程,抽象一下:
type TStrategy<T> = {
condition: boolean;
value: T;
};
/**
* 获取策略模式中某个策略
* @param strategyList 策略列表
* @param defaultStrategy 默认的策略,当没命中时返回(默认返回策略列表中的第一个)
*/
function getStrategy<T>(
strategyList: TStrategy<T>[], defaultStrategy?: T
): T {
const firstStrategy = strategyList?.[0]?.value;
let strategy = defaultStrategy ?? firstStrategy;
for (const item of strategyList) {
if (item?.condition) {
strategy = item.value;
break;
}
}
return strategy;
}
在上面我们编写一个获取策略模式的抽象过程,通过遍历一个策略列表,判断列表元素中的condition值如果为true,就返回元素的value值。如果遍历完成后,没有condition值为true,返回默认的策略(defaultStrategy)。如果没有传默认策略,就返回策略列表中的第一个value。
有了这个getStrategy获取策略的抽象方法,我们就可以解决产品经常丢给我们的多复杂条件判断的难题,当随意改动一个判断分支,不会影响到其它分支,没有了牵一发而动全身的担忧。
function getFare(params: object, month: number) {
const { paramA, paramB, paramC, paramD, paramE, paramF } = params;
// 1.策略的实现
const strategyList = [{
condition: paramA > paramB,
value: (month: number) => 300 * month
}, {
condition: paramA <= paramB && paramB > paramC,
value: (month: number) => 100 * month
}, {
condition: paramC > paramD && paramD > paramE,
value: (month: number) => 88 * month;
}, {
condition: paramE > paramF,
value: (month: number) => 1 * month;
};
// 2.策略的使用
const strategy = getStrategy(strategyList, () => 0);
return strategy(month);
}
3.4 还有大招吗?由面向过程,走向面向对象
当你以为面向过程的策略模式能够很优雅的解决各种if...else判断时,其实还有更强大的写法。自从有了ES6+TypeScript,我们可以书写✍️面向对象的策略模式了,上图。
从图中,我们先来了解以下几个定义。
策略(Strategy): 策略是一个接口,该接口定义若干个算法标识,即定义了若干个抽象方法。
具体策略(ConcreteStrategy): 具体策略是实现策略接口的类。具体策略实现策略接口所定义的抽象方法,即给出算法标识的具体算法。
上下文(Context): 上下文是依赖于策略接口的类,即上下文包含有策略声明的变量。上下文中提供了一个方法,该方法委托策略变量调用具体策略所实现的策略接口中的方法。
说了这么多,我们利用上文的费用计算,来举个实际的🌰。
首先,策略和具体策略的定义和实现:
interface IStrategy {
getFare(month: number): number;
}
class CommonStrategy implements IStrategy {
getFare(month: number) {
return 300 * month;
}
}
class VipStrategy implements IStrategy {
getFare(month: number) {
return 30 * month;
}
}
class SuperVipStrategy implements IStrategy {
getFare(month: number) {
return 1 * month;
}
}
接着,在上下文中进行策略的依赖注入:
class Context {
private strategy: IStrategy;
constructor(strategy: IStrategy) {
this.strategy = strategy;
}
getFare(month: number) {
if (!this.strategy) {
throw new Error('初始化失败,当前的strategy为空');
}
this.strategy?.getFare(month);
// 这里抽取策略类方法的公共部分组合,执行
doSomeCommonMethodInGetFare();
}
}
最后,调用执行。
const FARE_STRATEGY = {
[ERole.Common]: () => new CommonStrategy(),
[ERole.Vip]: () => new VipStrategy(),
[ERole.SuperVip]: () => new SuperVipStrategy()
};
function getFare(
userRole: ERole, month: number
) {
const strategy = FARA_STRATEGY[userRole];
// 1.将相关的策略实例,依赖注入上下文中
const context = new Context(stratey());
// 2.调用上下中的方法,既可执行相关策略的方法
return context.getFare(month);
}
getFare(ERole.Common, 12);
此时此刻的你,可能要开始吐槽了,明明能用一个策略对象完成的事情,为啥要搞这么复杂?
3.5 面向对象的大招要怎么放
结合上一小节策略模式面向对象的🌰,我们来慢慢品一下,为什么要这么编写,它要应对的场景是怎样的,我们往往是结合实际场景,因景制宜的。
场景:产品说,我们的费用策略,根据用户角色类型,要计算相关的费用外,还需要能打印特制的费用账单,进行退费的操作,等等。
实例:
先来一个面向过程的书写🌰:
// 1.策略的实现
/** 费用计算策略 */
const FARE_STRATEGY = {
[ERole.Common]: (month: number) => 300 * month,
[ERole.Vip]: (month: number) => 30 * month,
[ERole.SuperVip]: (month: number) => 1 * month
};
/** 打印特制的费用账单策略 */
const FARE_CONSOLE_STRATEGY = {
[ERole.Common]: () => console.log('Common Fare'),
[ERole.Vip]: () => console.log('Vip Fare'),
[ERole.SuperVip]: () => console.log('SuperVip Fare')
};
// 2.策略的使用
function getFare(
userRole: ERole, fareStrategy: {[key: number}: () => number, month: number
) {
return fareStrategy[userRole]?.(month) ?? 0;
}
function consoleFare(
userRole: ERole, fareConsoleStrategy: {[key: number}: () => void
) {
fareConsoleStrategy[userRole]?.();
}
getFare(ERole.Common, FARE_STRATEGY, 12);
consoleFare(ERole.Common);
分析:如果按上面使用面向过程来编写,如果产品再加多几个策略的方法,我们是不是要演化出很多策略对象,来满足相同判断条件,不同执行方法的操作。导致最终代码不够抽象,还很冗余。
此时我们换成用面向对象的写法(基于接口),来实现一波需求。
// 1.策略的实现
interface IStrategy {
/** 费用计算 */
getFare(month: number): number;
/** 打印特制的费用账单 */
consoleFare(): void;
}
class CommonStrategy implements IStrategy {
getFare(month: number) {
return 300 * month;
}
consoleFare() {
console.log('Common Fare')
}
}
class VipStrategy implements IStrategy {
getFare(month: number) {
return 30 * month;
}
consoleFare() {
console.log('Vip Fare')
}}
class SuperVipStrategy implements IStrategy {
getFare(month: number) {
return 1 * month;
}
consoleFare() {
console.log('Vip Fare')
}
}
从面向对象的角度实现策略来看,每个策略都是一个基于接口实现的类,根据约定实现一些公共的具体的方法,已经抹平了各个策略实现的差异。使用时,调用对应策略实例方法即可,无需关心其内部的实现,大大降低耦合性。
// 2.策略的使用
const FARE_STRATEGY = {
[ERole.Common]: () => new CommonStrategy(),
[ERole.Vip]: () => new VipStrategy(),
[ERole.SuperVip]: () => new SuperVipStrategy()
};
// 2.1 获取策略实例
const strategy = FARA_STRATEGY[userRole]();
// 2.2 费用计算
strategy.getFare(ERole.Common, 12);
// 2.3 打印特制的费用账单
strategy.consoleFare();
同时,每个策略是一个类,遇到复杂的场景时,可以在各自的类中实现各种变量和方法来满足,同时不会影响到外部对策略实例的使用,增强了策略的扩展性。如:假如有一天,产品的费用计算时需要通过历史的费用来推导计算的,那么此时可以这么做:
class CommonStrategy implements IStrategy {
private fareHistoryList: number[];
constructor() {
this.fareHistoryList = [];
}
getFare(month: number) {
const historyTotalFare = fareHistory.reduce((fare1, fare2) => fare1 + fare2));
const fare = (historTotalFare + 300 * month) / (this.fareHistoryList.length + 1);
return fare;
}
setFare(fare: number) {
this.fareHistoryList.push(fare);
}
consoleFare() {
console.log('Common Fare')
}
}
是不是觉得面向对象来实现策略模式,能够解决更复杂的场景。但这还不全部,还记得前文还有一个context的上下文中间层吗?是不是对那个也感到疑惑。
class Context {
private strategy: IStrategy;
constructor(strategy: IStrategy) {
this.strategy = strategy;
}
getFare(month: number) {
if (!this.strategy) {
throw new Error('初始化失败,当前的strategy为空');
}
this.strategy?.getFare(month);
// 这里抽取策略类方法的公共部分组合,执行
doSomeCommonMethodInGetFare();
}
}
其实,Context上下文,这里实现了一个代理,将策略的实例依赖注入后,可以抽取策略类方法的公共部分组合并执行,进一步提高了策略类的复用性,减少重复的冗余代码。同时新增一层中间层,能够处理一些部署于策略范围内的事情,实现单一职责原则。
4 实例
说了这么多费用计算的🌰,我们最后再来举一个其它的🌰。
最近,笔者有个H5页面录音上传的需求,这个录音上传可以通过app上传,也可以通过微信上传,以后还可以在各种应用场景上传,只要那个场景能够打开H5网页。需要在设计中,抹平录音上传在各个应用平台的差异,并且能够抽象出录音上传执行的公共功能部分。此时,笔者就想到使用策略模式去实现这个录音的上传。先上个几个设计图:
在此使用策略模式,实现录音上传工具模块的录音上传功能。其好处:
a. 上下文和具体策略是松耦合关系。因此上下文只知道它要使用某一个实现Strategy接口类的实例,但不需要知道具体是哪一个类。
b. 策略模式满足开放封闭原则。当增加新的具体策略时,不需要修改上下文类的代码,上下文就可以引用新的具体策略的实例。
综上,录音该录音模块具有不错的拓展性,在不同的场景下只要根据设计好的接口约束,重新实现相关的上传策略就可以。
5 什么时候可以使用策略模式
a. 一个类定义了多种行为,并且这些行为在这个类的方法中以多个条件语句的形式出现,那么可以使用策略模式,避免使用大量的条件语句(if … else …)。
b. 程序不希望暴露复杂的、与算法有关的数据结构,那么可以使用策略模式来封装算法。
c. 需要使用一个算法的不同变体。
讲了这么多,那什么时候应该使用面向过程的策略模式,什么时候应该使用面向对象的策略模式呢?
这个应该结合实际的使用场景,因景制宜,如果一开始场景只是简单的判断,那么面向过程的策略模式是合适使用的。如果随后产品觉得业务应该开始高大尚,逻辑越来越复杂,并有抽象的共通点,此时的你可以考虑一下面向对象的策略模式。
使用最终的目的,还是让你代码整洁,可维护,0 bug,早点下班,爱你😘
其它
本文在编写代码示例,使用了大量可选链?.和双问号??,以及一些ts的语法特性,如果不太熟悉,可以参考以下链接学习一番:
可选链和双问号:www.jianshu.com/p/bca4ce835…
ts语法:www.tslang.cn/docs/home.h…