前端应该掌握的设计模式—策略模式

lxf2023-03-12 17:51:01

前言

谈到设计模式,有人觉得很难,学不会,有人觉得离前端很远,不需要学习,有人可能没听说过,谈不上学习,总有许多的原因,你和设计模式擦肩而过,确实,不需要设计模式,一样能完成相应功能,它不是前端的必须品,但我们经常遇到一些情况,比如继承了前同事的屎山级项目,所有逻辑混在一起,阅读起来很难受,想要扩展一个新功能,要把整个流程重新自测一遍,可维护性很差。另一方面,你觉得自己的代码不够健壮,你要想要实现更加优雅,扩展性更强的代码,但是没有理论支持,没有套路可用,有心无力,而设计模式就是为了解决这些问题。

前几天经历了一场面试,聊到了关于设计模式,事后做了些思考和总结,建了个专栏,准备写几篇关于前端比较常用的设计模式,以及在日常开发中的具体应用,欢迎点赞,收藏。

策略模式

定义

定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换;

仅仅去看策略模式的定义,有些深奥难懂,不知所云,没有画面感,后续会通过多个日常开发中的示例让其生动形象且易于理解。它的作用除了解决设计模式的基础特性,高扩展和高复用性外,主要用于解决冗长的 if-else 或 switch 分支判断,拆分胖逻辑,让我们的代码有了更高的可读性和可维护性。

结论: 包含较多的if-else 或 switch,并且代码的复杂性较高,需要较强的复用性和扩展性可以考虑使用策略模式来优化代码,但设计模式都是需要灵活使用的,比如判断只有一两个,代码的逻辑也很简单,没有什么复用关联性的,那就不需要多此一举,搞得代码更加繁琐,画蛇添足,降低代码的可维护性,那就得不偿失了。

年终奖计算示例

公司计算年终奖时,不同的部门有不同的计算方式,初定有以下三类:

  • 类型A,月薪的0.8加上销售额的0.5
  • 类型B,月薪的1.2加上销售额的0.3
  • 类型C,月薪的2倍

这个需求一看就很简单,我用脚想都能实现,所以马不停蹄的实现了如下代码,顺手拿起旁边的肥宅快乐水,美滋滋的喝了一口,对于自己的开发效率赞不绝口,却不知这就是屎山级项目的源头。

// 计算年终奖
function getAnnualBonus(type, salary, sales) {
    // 计算typeA年终奖
    if (type === 'typeA') {
      return salary * 0.8 + sales * 0.5
    }
    // 计算typeB年终奖
    if (type === 'typeB') {
      return salary * 1.2 + sales * 0.3
    }
    // 计算typeC年终奖
    if (type === 'typeC') {
      return salary * 2
    }
}

上面这段代码的实现,看起来没什么问题,也能实现需求,但这是因为举例的计算方式比较简单,如果需要根据销售额梯度进行计算,那整个逻辑就会变得庞大起来,许多问题也更能体现。

对于getAnnualBonus函数本身来讲,我们在一个函数里面实现三块比较重的逻辑,可读性和bug排错都是比较繁琐的,而对于不同类型的计算方式,也谈不上复用性,当我们在某个地方需要使用typeA的算法时,除了ctrl c ctrl v别无办法,但是你使用了复制黏贴,同一块逻辑在不同位置实现,会提高很大的维护成本和出错的可能性,当以后需要修改typeA的计算逻辑时,你需要在多个位置修改,有可能出现改的不一致或者漏改的情况,提高了我们出错的比例,造成这个问题的原因是违背单一职责原则

另外一个问题是违背了开放封闭原则,这个原则可能很多人都听过,但是没有太深的体会,假设我现在需要新增一种类型,计算方式为月薪的3倍加上销售额的0.2,之前的代码只能像以下这么改造,这样子的影响是修改了原函数,那就需要对于整个流程重新进行一次测试,避免由于其他原因改到其他计算方式出错,同时,越来越多的逻辑集成到一个函数下,造就了维护性更加低。

// 计算年终奖
function getAnnualBonus(type, salary, sales) {
    // 计算typeA年终奖
    if (type === 'typeA') {
      return salary * 0.8 + sales * 0.5
    }
    // 计算typeB年终奖
    if (type === 'typeB') {
      return salary * 1.2 + sales * 0.3
    }
    // 计算typeC年终奖
    if (type === 'typeC') {
      return salary * 2
    }
    // 计算typeD年终奖
    if (type === 'typeD') {
      return salary * 3 + sales * 0.2
    }
}

单一职责改造

这样子改造后,实现了每个函数功能独立,提高了代码的可读性和可复用性,由于函数的粒度变小了,bug的调试也会方便许多。

// 计算typeA年终奖
const handleTypeA = (salary, sales) => {
    return salary * 0.8 + sales * 0.5
}
// 计算typeB年终奖
const handleTypeB = (salary, sales) => {
    return salary * 1.2 + sales * 0.3
}
// 计算typeC年终奖
const handleTypeC = (salary, sales) => {
    return salary * 2
}

// 计算年终奖
function getAnnualBonus(type, salary, sales) {
    if (type === 'typeA') {
      return handleTypeA(salary, sales)
    }
    if (type === 'typeB') {
      return handleTypeB(salary, sales)
    }
    if (type === 'typeC') {
      return handleTypeC(salary, sales)
    }
}

开放封闭改造

上面虽然实现了每个函数的的功能独立,但是对于功能的扩展依旧束手无策,还是需要在源函数添加if xxx,这样子并没有实现我们对于扩展开放,对于修改封闭的原则,所以还需要进一步思考,怎么才能实现新类型的扩展,但是不需要修改getAnnualBonus函数,从getAnnualBonus函数可以看出一些规律,一种类型对应一个函数,聪明的你一定能想到这不就是key-value格式吗,那答案也呼之欲出,通过对象的key和函数做映射,就能实现我们想要的良好的扩展性,不信我们往下看。

声明一个对象,实现类型和对应计算方式函数的映射,getAnnualBonus函数也简洁许多,只需要根据不同类型,读取对象的计算逻辑函数,并进行执行即可。

const typeMap = {
    // 计算typeA年终奖
    typeA(salary, sales) {
      return salary * 0.8 + sales * 0.5
    },
    // 计算typeB年终奖
    typeB(salary, sales) {
      return salary * 1.2 + sales * 0.3
    },
    // 计算typeC年终奖
    typeC(salary, sales) {
      return salary * 2
    }
}

// 计算年终奖
function getAnnualBonus(type, salary, sales) {
    return typeMap[type](salary, sales)
}

这时我们新增一种类型typeD,计算方式为月薪的3倍加上销售额的0.2,我们只需要对于typeMap重新扩展一个类型即可,而不需要修改原函数getAnnualBonus,这样子我们的年终奖计算逻辑不仅消除了大量的if-else判断逻辑,还实现了很强的代码复用性,可扩展性,可维护性。

typeMap.typeD = (salary, sales) => {
    return salary * 3 + sales * 0.2
}

经历了这个示例的实现及持续优化过程,相信此时的你对于策略模式的定义:定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换;有了更深的体会,一系列算法指的是不同类型年终奖的计算函数,我们将其独立抽离,使之满足单一职责原则,这就是对于算法的封装过程,然后通过对象的key-value映射的巧妙方式,使得这些计算函数(算法)可以根据类型进行替换,同时也拥有很强的扩展性。

多端登录方式示例

上面这个示例算是单个函数的粒度,我们再举个关于文件粒度的示例。

最近开发了基于uni-app的移动端框架,因为支持跨端,所以需要不同端的登录相关逻辑,初期先实现APP微信小程序两种方式,包含校验是否已经登录的checkToken,用于登录的Login和退出登录的Logout,以及一些各端特有的逻辑,他们的函数处理逻辑都不一致,也不相关,谈不上复用,从目前来看if-else是不错的选择,当我们进一步观察,会发现,同样是由类型做区分,不同的类型包含不一样的算法逻辑,同时需要较强的可扩展性和可维护性,这不是巧了么,和我们刚学的策略模式不谋而合,直接排上用场,具体的逻辑实现如下,不过忽略各个函数的具体实现。

目录结构

前端应该掌握的设计模式—策略模式

/permission/app/index.js

// 用于实例化对象
import { create } from "../permission.js";
// 核心代码
function guards(config) {
  return {
    mode: "app",
    checkToken() {
      // 验证token
    },
    getSession() {
      // 获取session
    },
    login() {
      // 登录
    },
    logout() {
      // 退出登录
    },
  };
}
// 实例化app相关逻辑对象
export default create({
  name: "app",
  guards,
});

/permission/mp-weixin/index.js

// 用于实例化对象
import { create } from "../permission.js";
// 核心代码
function guards(config) {
  return {
    mode: "mp-weixin",
    checkToken() {
      // 验证token
    },
    getSession() {
      // 获取session
    },
    login() {
      // 登录
    },
    logout() {
      // 退出登录
    },
  };
}

// 实例化微信小程序相关逻辑对象
export default create({
  name: "mp-weixin",
  guards,
});

/permission/permission.js

// 用于做参数校验,起到对象构造器的作用
class Permission {
  constructor({ name, guards }) {
    if (typeof name !== "string") {
      throw new Error("Permission: name 必须为字符串,不能为空");
    }
    if (typeof guards !== "function") {
      throw new Error(`Permission: guards 必须为函数,不能为空`);
    }
    this.guards = guards;
    this.name = name;
  }
}

// 用于对象实例化
const create = (options) => {
  return new Permission(options);
};

export { create, Permission };

/permission/index.js

import { Permission } from "./permission.js";
import appLogin from "./app/index.js";
import mpWeixinLogin from "./mp-weixin/index.js";
const Core = {
  permissions: {},
  instance: undefined,
  register(instance) {
    if (instance instanceof Permission) {
      this.permissions[instance.name] = instance;
    } else {
      throw new Error(`${instance} 非 Permission 实例`);
    }
  },
  // 根据对应的模式,执行对应实例的guards方法,生成对应实例
  guards(config) {
    const { mode } = config;
    // 获取对应模式实例
    this.instance = this.permissions[mode];
    if (this.instance) {
      return this.instance.guards(config);
    }
    return {};
  },
};

// 注册对应实例,这里只是起到注册的作用,并没有实际的执行
Core.register(appLogin);
Core.register(mpWeixinLogin);

export default Core;

/main.js

import Vue from "vue";
import Permission from "./permission/index.js";

// 公共配置
const config = {
  mode: "app",
};

// 根据配置生成对应类型的实例,并挂载到全局上
Vue.prototype.$permission = Permission.guards(config);

扩展

未来需要再扩展h5相关的登录逻辑,也是比较简单的,创建一个h5文件夹,实现h5独立的登录逻辑

前端应该掌握的设计模式—策略模式

然后在/permission/index.js文件引入h5的入口文件,在执行Core.register(h5Login)注册h5的登录逻辑

前端应该掌握的设计模式—策略模式

这样子算是实现各自登录逻辑的单一性和较好的可扩展性。

小结

正如开头所说,不需要设计模式一样可以很棒的完成需求,但有时候需要的不仅仅是完成需求,如果项目需求都是从最开始确定后就不再变化,那怎么实现代码都无关紧要,但项目都是需要持续变化和迭代的,而在这持续迭代的过程中,怎么保证代码具有良好的扩展性、复用性、可维护性就格外重要了,而设计模式就是保证我们健壮代码的格式或者套路吧。

相信经过一步一步的分析,你对于策略模式有个更深的理解,它也算是学习成本比较低的设计模式,但是带来的成效还是很客观的,有了理论基础,在开发的时候就有了更多的选择,更多的方式去实现健壮的代码。