🔥 每个故事都是一种设计模式

简单说明:该文章是自己对于设计模式的一次系统的重新学习和整理,分享出来以记录为主,也希望能给小伙伴们带去帮助,另外由于我日常使用 Typescript,所以下方示例皆会使用 TS 编码,除此文章当然也可能会有错误的地方,如若发现,烦请提出,感谢!

前言

我们八卦的时候常会聊起谁谁谁的代码写得更好、有设计感,所以什么样的代码才是好的有设计感的呢?

我眼里的"设计感"

在我的眼里,要判定一个程序的代码有没有设计感,还得看它有没有给到我 强烈的学习欲望安全感

当我看到一个程序的代码结构新奇,无繁琐细节,但却逻辑清晰、代码区域职责分明且易于拓展时,它就能给到我强烈的学习欲望(有启发性的),而如果它还能做到当我改动时不用害怕影响它原功能时,那我愿意称之为有满满安全感的代码(可维护的)。

其实有没有"设计感"这个是因人而异的,但会存在共通点,而实际上当我们去思考这个问题的时候,前辈们早已将他们认为有"设计感"且实用的代码共通点抽离了出来并为我们这些后辈提供了模板,也就是我们所说的设计模式。

设计模式是软件设计中常见问题的典型解决方案。每个模式就像一张蓝图,我们可以拿着它对着自己开发中的程序进行定制以解决代码中的特定设计问题。

为什么要学习设计模式

有些人崇尚代码自由主义,他不主张学习设计模式,主张自由发挥,但是实际上即使他从未学习过一个设计模式,他依然会在工作中不自知的情况下使用设计模式,因为设计模式就是各种经过实践验证的解决方案,实际应用中就算你自由发挥最后解决了问题,最后的结果也不过是花了更多的时间踩了更多的坑而实现了一样的东西。

而当你学习了设计模式,你就能在遇到相同的场景时选择直接跳过一些即将出现的坑,一步到位拿出"秘籍"并快速解决当下问题。

我学习参考网站是:设计模式,它更为权威和具体,感兴趣可自行查阅。

设计模式的分类

我们接下来讲的21种模式可以根据其意图或目的来分类:

  • 创建型模式,如何创建对象能增加代码的灵活性和可复用性。
  • 结构型模式,如何保持较大结构的灵活和高效。
  • 行为型模式,如何让对象间的沟通更为高效和职责清晰。

每个故事都是一种设计模式

接下来,我将为你分享我学习设计模式的方法,就是创造故事,代入自己:

以下所有故事都源于我前阵子对游泳的痴迷,所以不要疑惑为啥都和游泳有关😆。

创建型设计模式

单例模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> "泳池爱上我" "泳池爱上我" </math>"泳池爱上我"

(扮演:游泳者)

生活总是值得体验点新玩意,在家人的鼓励下,你报名了游泳课,在上课前教练让你每节课上课前都要准备一套游泳装备(泳衣、泳帽、泳镜),这时你脑海里出现了三个方案:

① 每次上课前都准备一套全新的游泳装备。

② 穿越回出生的那一天,让家人提前买一套游泳装备,直到今天才开始并往后都使用这套装备上课。(假设允许这么做)

③ 从这天开始淘一套游泳装备,并以后上课都使用这套装备。

对此,你会作何种选择呢?

一、书接上文

现实生活中,这个问题的最后选择必然选择方案③,这是毋庸置疑的。

但从程序设计的角度上看,虽然我们很清楚前两个天马行空方案离了大谱,但在实际开发中,却仍会存在部分开发者由于习惯问题或处于快速开发的阶段而选择前两个方案,接下来我们来看看三个方案的代码实现、找出它们的差异并从根源上学习为何需要"惰性单例"。

二、代码解析
方案一

由于每次上课前都准备一套全新的游泳装备,也相当于每次都需要new构建新实例对象。

Typescript 复制代码
class Equipment {};

const equipment1 = new Equipment();
const equipment2 = new Equipment();

console.log(equipment1 === equipment2); // false
方案二(单例)

在未使用到实例对象前提前准备一个实例对象,并在后续使用时重复利用,而不创建新实例对象。

Typescript 复制代码
class Equipment {};
...
// 提前准备一个实例对象
let __equipment = new Equipment();
const getEquipment = () => {
  // 单例模式,后续用到拿出
  return __equipment;
}
...
// 后续获取都只会返回同一个实例对象
const equipment1 = getEquipment();
const equipment2 = getEquipment();

console.log(equipment1 === equipment2); // true
方案三(惰性单例)

在使用到时才创建实例对象,并在后续使用时重复利用,而不创建新实例对象。

typescript 复制代码
class Equipment {};
...
let __equipment: Equipment | null = null;
const getEquipment = () => {
  // 惰性单例模式
  __equipment = __equipment ?? new Equipment();
  return __equipment;
}
...
const equipment1 = getEquipment();
const equipment2 = getEquipment();

console.log(equipment1 === equipment2); // true
三、惰性单例

我们可以看到,单例模式是一种创建型设计模式,它保证一个类同一时间内只会创建和持有一个实例,这也使得我们可以使用单例模式重复利用手头上已有可重复利用的资源,减少不必要的重复对象创建(内存消耗)。

从程序设计的角度看,虽然方案二也是个可选项,但相比方案三而言,它的初始创建可能在某些场景下就显得就不太有必要了,假如后续并没有真正使用到该实例对象,那也就是"白白创建"了一个无用对象。

方案三所强调的 "在需要的时候再去创建" ,也正是"惰性"这个词的真正含义。惰性单例模式能避免不必要的初始对象创建,是最佳的单例模式。

⚠️ JavaScript中的单例可能造成命名空间污染和变量冲突

从上面的代码中我们会发现一个问题,单例模式的实现关键是一个变量的标记,但在JavaScript中这个变量是很容易造成全局变量污染的,我们一般可以选择使用闭包特性将该实例变量私用化。

Typescript 复制代码
class Equipment {};
...
const getEquipment = (() => {
  let __equipment: Equipment | null = null;
  return () => {
    __equipment = __equipment ?? new Equipment();
    return __equipment;
  }
})();
...
const equipment = getEquipment();
四、常见应用场景
  1. 全局缓存:用于存储全局数据,例如页面配置信息、用户信息等。
  2. 模态框:确保只有一个模态框实例,避免多次弹出重复的模态框。
五、更通用的惰性单例

惰性单例的应用场景是广泛的,但它们的"标记过程"却是类似的,因此我们可以将"标记过程"抽离并封装出来:

Typescript 复制代码
/**
 * 更通用的惰性单例
 */
const getSingle = <Fn extends (...args: any) => any>(fn: Fn) => {
  let __instance: any;
  return (...args: Parameters<Fn>) => {
    __instance = __instance ?? fn.apply(fn, args);
    return __instance as ReturnType<Fn>;
  }
};

后续在使用到"惰性单例"的场景下都可以使用,比如:

Typescript 复制代码
class Equipment {};

const getSingleEquipment = getSingle(() => new Equipment());

const equipment1 = getSingleEquipment();
const equipment2 = getSingleEquipment();

console.log(equipment1 === equipment2); // true

工厂方法模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 新课程 新课程 </math>新课程

(扮演:游泳馆老板)

由于游泳馆才开馆不久,所以当前只开设了少儿课程,但最近网络上突然掀起一阵成年人学游泳的热潮,所以你也想紧跟热潮,开设成人课程。

于是你找到馆内的金牌教练,希望他能对新课程的开设提供设计方案。

金牌教练很快就给你提供了以下两套方案:

① 从零开始设计,包括教学方式、课程数、课程内容等通通重新设计

② 保持原有的少儿课程教学框架,在实际课程中进行简单的改动以达到符合成人教学的教学要求。

所以你会做怎样的选择呢?

一、书接上文

在实际生活中,我们肯定是会基于实际情况来做最后的选择,如果我们需要设计一个更加有特色、更加有针对性的课程,重新设计必然是最好的选择,但这毫无疑问是耗时耗力的事情;而基于原有课程框架进行设计,不仅能快速上新课程,而且有旧课程的教学经验下也更能保证教学的质量。

在程序设计中,我们更倾向于复用可重复利用的代码,仔细想想,其实无论是少儿课程还是成人课程、甚至是老年课程,他们的整体的授课流程都不会有太大的差异,在方案二中,我们提取出"课程"的基础框架,后面无论是少儿还是老年课程,都可以基于这套框架去具体实现和细节调整,从这个角度出发,方案二也自然显得更合理。

二、代码解析
方案一

方案一中我们需要对每个课程类进行独立的设计,它们的实例中方法接口将不尽相同,比如:

Typescript 复制代码
class AdultCourse {
  // 教学方法
  teach() {};
  // 分配教练
  assignCoach() {};
}

class ChildrenCourse {
  // 教学方法
  start() {}
  // 分配教练
  assignCoach() {}
}

上面的代码中我们很容易发现如果后续某处需要执行教学方法,我们可能需要专门区分对象的类型来处理正确的方法,而且其内部本可以共用的方法(如assignCoach)也无法实现共用。假设后续出现"老年课程"、"幼儿课程"等课程类,那实现起来就十分麻烦了。

方案二(简单工厂模式)

在方案二中,如果我们要使用原少儿课程的教学框架,需要将它们的共同点抽离出来,这里简单归纳为一个Course抽象类,内有一个通用的方法assignCoach和一个必须要子类具体实现的抽象方法teach

Typescript 复制代码
abstract class Course {
  // 抽象方法:教学方式
  abstract teach(): void;
  // 通用方法:分配教练
  assignCoach(): void {};
}

而具体课程的实现也要继承Course抽象类的方法进行具体化实现,比如少儿课程类的实现:

Typescript 复制代码
class ChildrenCourse extends Course {
  teach(): void {
    console.log("具体少儿课程教学设计");
  }
}

课程类具体实现后,在实际的应用中,我们常见以下的方法去创建对应的实例对象:

Typescript 复制代码
function createCourse(type: 'children' | 'adult'): Course {
  switch(type) {
    case 'children':
    return new ChildrenCourse();
    case 'adult':
      return new AdultCourse();
    default:
      throw new Error('Unknown type');
  }
}

这种创建的方式其实就应用了 "简单工厂模式" ,简单工厂模式是一种创建型设计模式,它通过一个工厂类或工厂方法来创建不同类型的对象,但这个模式中我们也发现另一个问题,通过条件判断创建对应实例的逻辑导致了代码高度耦合,这个是我们需要去避免的,因为后续一旦我们需要拓展新的课程时,将不得不重新回到这里进行修改从而导致代码臃肿和不易维护。

三、工厂方法模式

为了解决上面"简单工厂模式"的代码耦合问题,我们就需要用到"工厂方法模式"了。

在"工厂方法模式"中,每个具体的课程类都需要用到一个专门的工厂类去实现构建,而这些具体的工厂类也都是一个统一抽象工厂类的具体实现:

Typescript 复制代码
abstract class CourseFactory {
  abstract create(): Course;
}

对应的少儿课程工厂类的实现如下:

Typescript 复制代码
class ChildrenCourseFactory extends CourseFactory {
  create() {
    return new ChildrenCourse();
  }
}
const childrenCourseFactory = new ChildrenCourseFactory();
const childrenCourse = childrenCourseFactory.create();
childrenCourse.teach();

但我们需要拓展新的课程时,我们只需要给这个课程实现具体类和具体的工厂类即可实现创建上的解耦,比如当前新增一个老年课程:

Typescript 复制代码
// 具体实现老年课程
class SeniorCourse extends Course {
  teach(): void {
    console.log("具体老年课程教学设计");
  }
}
// 具体实现老年课程工厂
class SeniorCourseFactory extends CourseFactory {
  create() {
    return new SeniorCourse();
  }
}
// 应用
const seniorCourseFactory = new SeniorCourseFactory();
const seniorCourse = SeniorCourseFactory.create();
seniorCourse.teach();
四、这个"工厂方法模式"看着比"直接new对象"还麻烦❓

工厂模式的核心在于将new构建的过程置于内部并返回一个实例对象,但在构建的返回对象前你可以做一系列其他的操作,如果只是跟上面例子一样仅仅是生成一个"产品"并返回------课程实例,那确实是没有必要设计得如此复杂。

在实际应用中,工厂类中还可以包含其他功能,就比如在create方法中应用"单例模式"实现对象的缓存和重用,所以实际应用中还得看是否有使用的必要。

抽象工厂真的过于抽象,抽象到实在不知道怎么讲,感兴趣自己了解啦。

生成器模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 登记表 登记表 </math>登记表

(扮演:客服)

这天上班老板皱着眉头喊你去办公室谈话,你很疑惑,难道最近做错什么了?

进去后老板放了一张登记表在你面前,说:"你设计这张表的时候有自己尝试做一次吗?你觉得这样的设计好吗?"。

你看向那张登记表思考了片刻,感觉没问题便摇了摇头。

老板无奈地说"最近收到反馈这张表的让人看着眼花缭乱,导致我们的专业性受到了客户的质疑,甚至有的看到表就直接转身离开了,你回去再重新调整过吧!"

于是你低着头走出办公室,不得不重新设计一份。

一、书接上文

这里的"眼花缭乱"其实就是没有进行问题的分类,一股脑地堆砌问题,在这个故事中我们作为客服人员如果要重新设计一份条例清晰的登记表,那必须要对登记表的问题进行分类,并根据相关性有顺序地排列问题,如下图所示:

在程序设计中,我们也经常会遇到一种非常类似的情况,就是某个类可选构造参数过多的问题,如果当前你是库或是方法的设计者,你也不会想看到过多的参数配置让使用者眼花缭乱,对此,我们可以使用"生成器模式"改善这个问题。

生成器模式是一种创建型设计模式, 使我们能够分步骤创建复杂对象。

二、代码解析
改善前
Typescript 复制代码
/**
 * 登记表
 */
class Form {
  public name: string;
  public age: number;
  public gender: "男" | "女";
  public hadLearned: boolean = false;
  public hopes: [type: string, content: string][] = [];

  constructor(
    options: Partial<{
      name: string;
      age: number;
      gender: "男" | "女";
      hadLearned: boolean;
      hopes: [type: string, content: string][];
    }>
  ) {
    // ...
  }
}

const form = new Form({
  name: "小刘",
  age: 20,
  gender: "男",
  hopes: [
    ["参与课程", "课程A"],
    ["学习泳姿", "蛙泳"],
    ["达到水平", "能游就好"],
  ]
})

可以看到改善前登记表的构造函数调用十分不简洁,假设哪天我们需要添加一个新的配置,我们需要修改这个庞大的构造函数,也会逐步导致它变得臃肿和难以维护。

改善后(生成器模式)
Typescript 复制代码
/**
 * 登记表
 */
class Form {
  public name: string;
  public age: number;
  public gender: '男' | '女';
  public hadLearned: boolean = false;
  public hopes: [type: string, content: string][] = [];
}

/**
 * 生成器
 */
class FormBuilder {
  private form: Form;

  constructor() {
    this.reset();
  }

  public reset() {
    this.form = new Form();
  }

  public setBaseInfo(name: string, age: number, gender: '男' | '女') {
    this.form.name = name;
    this.form.age = age;
    this.form.gender = gender;
  }

  public setLearnState(hadLearned: boolean) {
    this.form.hadLearned = hadLearned;
  }

  public addHope(type: string, content: string) {
    this.form.hopes.push([type, content]);
  }

  public create(): Form {
    const result = this.form;
    this.reset();
    return result;
  }
}

/**
 * 生成器应用示例
 */
function generate() {
  const builder = new FormBuilder();
  builder.setBaseInfo("小刘", 20, '男');
  builder.addHope("参与课程", "课程A");
  builder.addHope("学习泳姿", "蛙泳");
  builder.addHope("达到水平", "能游就好");
  const form = builder.create();
}

从改善后的代码里我们可以发现,产品的构造不再是基于自身的构造函数,而是将构造流程分类并拆分到一个独立的"生成器"对象中,这个改动不仅让构造的流程变得清晰,也使得后续的拓展更为轻松。

三、生成器模式

生成器模式是一种创建型设计模式, 使你能够分步骤创建复杂对象。 生成器模式建议将对象构造的代码从产品类中抽取出来, 并将其放在一个名为"生成器"的独立对象中。

有时还会构建一个"主管类",用于快速构造不同类别的产品,主管类中会预设执行一系列步骤并生成特定类别的产品,如下方仅接收基础信息就可快速创建初学者的实例:

Typescript 复制代码
class BeginnerManager {
  generate(name: string, age: number, gender: '男' | '女') {
    const builder = new FormBuilder();
    builder.setBaseInfo(name, age, gender);
    builder.addHope("参与课程", "课程A");
    builder.addHope("学习泳姿", "蛙泳");
    builder.addHope("达到水平", "能游就好");
    return builder.create();
  }
}

const manager = new BeginnerManager();
manager.generate("小刘", 20, '男');
四、应用场景
  1. 解决上面所说的"构造函数可选参数过多"的问题。
  2. 应用"主管"可以快速创建不同类别(仅细节差异)的产品。
  3. 在生成器发布产品前,产品都是无法获取到的,这也可用于避免客户端获取不完整的产品对象。

原型模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 你教我我教他 你教我我教他 </math>你教我我教他

(扮演:游泳者)

你终于上完课程了,你开心地跟家人分享了你的喜悦,而家人也因为你的分享也激起了对游泳的学习欲望,对此你给家人提了两个建议:

① 自行报课学习

② 自己将学习的课程毫无保留地传授给他

家人希望你能帮他拿主意,你最后会选择哪个建议呢?

一、书接上文

在现实生活中,要做这个决定往往要考虑多方面的因素,但毫无疑问最后考虑的基本都是开销问题,所以一般我们都会选择建议②以避免二次花销(作为一家人)。

在程序设计中,我们同样需要考虑开销问题,但这里的"开销"并不是指"内存开销"问题,因为新创建对象的内存占用是不可避免的,而是指一系列创建过程的"流程成本"问题。如果我们选择建议①,除了需要缴费的问题,我们还需要完成一系列如"咨询、报名、登记、缴费、约课"的操作,这个操作的成本是更大的。但如果我们选择了建议②,由于作为家人"知根知底",实际要做的也就是将游泳这个技能倾囊传授。

要解决这里的"流程开销"问题,我们可以使用原型模式,原型模式是一种创建型设计模式,它的主要作用就是用于减少创建时的"流程开销"------初始的重复操作

二、代码解析

要在这个例子中理解原型模式,可以先把我和我的家人都看做是游泳者Siwmmer的对象实例。但是不同的是我的家人这个对象并不是基于new构建,而是以我为原型clone(克隆)出来的。

不使用原型模式
Typescript 复制代码
class Swimmer {
  public name: string;
  public skills: string[];

  constructor(name: string) {
    this.name = name;
  }

  public learn(skill: string) {
    this.skills.push(skill);
  }
}

const swimmer = new Swimmer('小刘');
swimmer.learn('蛙泳');
swimmer.learn('自由泳');

const family = new Swimmer('家人');
swimmer.learn('蛙泳');
swimmer.learn('自由泳');

console.log(family.skills); // 蛙泳 自由泳
使用原型模式
Typescript 复制代码
class Swimmer {
  public name: string;
  public skills: string[];

  constructor(name: string) {
    this.name = name;
  }

  public learn(skill: string) {
    this.skills.push(skill);
  }

  // 对象实例中含有克隆 clone 方法
  public clone(name: string): this {
    const clone = Object.create(this);

    clone.name = name;

    return clone;
  }
}

const swimmer = new Swimmer('小刘');
swimmer.learn('蛙泳');
swimmer.learn('自由泳');

const family = swimmer.clone('家人');
console.log(family.skills); // 蛙泳 自由泳

我们可以看到,通过这种方式创建的对象实例不用再重复执行Swimmer.learn方法,一些相同的状态在克隆的时候得以保留,可以减少大量重复且复杂的初始化过程,减少流程上和系统上的开销。

上面的克隆方式是浅克隆,原型模式不强调深浅克隆,仅强调必须有一个"克隆"的方法用于在已有对象实例中克隆出新对象。

三、原型模式

原型模式的核心是对象实例提供一个"复制自身"的接口,使我们可以复制现有对象,通过调整其状态以满足新的需求,而不是通过构造函数创建新的对象。这样可以避免复杂的初始化过程,减少系统的开销。

四、应用场景:通过"克隆"实现复杂对象的创建

在开发中,常会需要创建一些具有复杂结构的对象,比如绘图应用中的路径对象,这些路径对象在创建时会经过非常复杂的路径解析初始化过程,内部存在大量计算,如果不使用原型模式意味着每次实例化都需要一次完整的计算, 而如果使用原型模式,则可以直接复用计算后的路径数据。

结构型设计模式

代理模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 约课风波 约课风波 </math>约课风波

(扮演:游泳馆老板)

没想游泳馆才刚开业不久,就收到了诸多投诉,其中不少的是学员约课困难的问题,对此你约谈了相关的教练,但在教练给出答复后你才发现原来这问题并不在教练身上。

由于教学原因,教练时常是泡在池中进行游泳教学,也无法携带电子设备,所以时常无法对学员约课的消息做出及时的回复,有时堆积消息过多的时候还很容易错过消息。

对此你也意识到学员和教练直接沟通约课这种形式很明显不是一种好的方式,所以你有想到什么好的方法可以解决这个问题了吗?

一、书接上文

在实际生活中,教学机构常常会采用另一种管理约课方式,那就是不直接让教练添加学员的联系账号,而是让一个客服(顶着教练的名号)添加学员账号,这样当学员发送约课信息的时候,客服可以更及时地回复学员信息,并且可以为教练缓存学员约课信息,减少约课冲突。

在程序设计中,这种通过使用一个对象"冒充"另一个对象执行方法的操作非常类似"代理模式",但实际编程还有所区别,下面我们来更详细学习它。

二、代码解析

上面故事中,客服虽然能冒充教练进行约课,但却不能冒充教练进行教课,所以并不是完全符合"代理模式",代理模式通常要求"代理对象"和"服务对象"实现相同的接口(方法)或继承相同的抽象类,这种设计使得客户端代码可以透明地与代理对象交互,而不需要知道它们之间的区别,不同的只是代理对象可以在接口的具体实现中添加额外的处理。

不同对象中的方法,代理对象是否需要拥有与实际对象相同的属性取决于具体的需求和设计。

使用代理模式
Typescript 复制代码
interface ICoach {
  reserve(student: string, date: string): void;
  teach(): void;
}

/**
 * 教练类
 */
class Coach implements ICoach {
  // 约课表
  public schedule: [student: string, date: string][] = [];

  public reserve(student: string, date: string): void {
    this.schedule.push([student, date]);
  }

  public teach(): void {
    console.log('教学');
  }
}

/**
 * 代理教练类,代理教练类的接口应该完全与教练类一致
 */
class ProxyCoach implements ICoach {
  private coach: Coach;

  constructor(coach: Coach) {
    this.coach = coach;
  }

  public reserve(student: string, date: string): void {
    const isOccupied = this.coach.schedule.some(item => item[1] === date);
    if (isOccupied) {
      console.log('因时间占用约课失败');
    } else {
      this.coach.reserve(student, date);
    }
  }

  public teach(): void {
    // 可以调用回原教练对象的方法,也可以自行实现,看实际场景
    this.coach.teach();
  }
}

const coach = new Coach();
const proxy = new ProxyCoach(coach);
proxy.reserve('小刘', '2024-10-10');
proxy.reserve('小李', '2024-10-10'); // 因时间占用约课失败

这里实现的"代理对象"为"服务对象"添加了检查预约时间是否被占用的额外逻辑,如果时间被占用,则输出 "因时间占用约课失败" 并拒绝预约,否则调用实际对象的reserve方法。

三、代理模式

代理模式是一种结构型设计模式,让你能提供真实服务对象的替代品给客户端使用,因为代理模式中要求"代理对象"和"服务对象"实现相同的接口,这让客户端通常无需特意区分是否是代理对象。代理模式会接收客户端的请求并进行一些额外的处理(访问控制和缓存等),然后根据需要跳出或将请求传递回实际的服务对象。

四、应用场景
  1. 拦截和处理 HTTP 请求,例如使用 Axios 拦截器。

严格来说,这里的例子中有些并不完全符合经典的代理模式,比如使用Axios拦截器,在上面讲到代理模式需要做到两点:① "代理对象"和"服务对象"实现相同的接口;② 进行一些额外的处理。虽然使用Axios拦截器中没有实现与实际服务对象相同的接口,但是它的拦截处理实现了一种类似于代理的行为,所以我们也可将其视为代理模式的一种应用。

Typescript 复制代码
// 引入 Axios
const axios = require('axios');

// 创建一个 Axios 实例
const apiClient = axios.create({
    baseURL: 'https://api.example.com',
    timeout: 1000,
});

// 添加请求拦截器
apiClient.interceptors.request.use(
    function (config) {
        // 在发送请求之前做些什么
        console.log('Request Interceptor:', config);
        // 例如,添加认证头
        config.headers.Authorization = 'Bearer token';
        return config;
    },
    function (error) {
        // 对请求错误做些什么
        return Promise.reject(error);
    }
);

// 使用 Axios 实例发送请求
apiClient.get('/data')
    .then(response => {
        console.log('Data:', response.data);
    })
    .catch(error => {
        console.error('Error:', error);
    });
  1. 虚拟代理,比如加载大图前的占位图。
  2. 保护代理,控制权限访问。
  3. 缓存代理,缓存请求结果,减少重复请求。

外观模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 救生员培训 救生员培训 </math>救生员培训

(扮演:主管)

一年一度的救生员安全培训又要开始了,以往每个培训的筹备和环节老板都会直接参与,但是今年他实在是没有时间再去管这件事了,于是将你叫进了办公室。

老板说到:"今年的培训我没有时间去监督每个中间项目了,我也不关心过程中办得怎么样,我只是想知道最后有多少救生员通过了培训,你应该知道怎么做了吧?"

所以你知道该怎么做吧?

一、书接上文

在这个故事里,你毫无疑问是要将事情包下来的。老板将他不关心的中间事项通通都"打包"给到你去处理,而你要做的就是完成他们并返回完成结果。在程序设计中,这种操作非常类似应用了"外观模式"的代码。

在"打包"的事情中我们可以假设这些环节包括:邀请培训专家、执行培训考试、定制并分配培训证书,主类(老板)是不关心他们的执行过程的,如果你还是将这些执行环节直接写在主类代码中,那主类(老板)就不高兴了。因为当后续如果环节有改动,那就不得不重新梳理并修改主类的代码了,所以我们需要一个主管类去替老板把这些事情解决了并返回给老板最后要的结果。

二、代码解析

根据故事的描述,我们需要实现Expert 专家类Exam 考试类Certificate 证书类三个类:

Typescript 复制代码
/**
 * 专家类
 */
class Expert {
  public invite(): void {
    console.log('邀请培训专家')
  }
}
/**
 * 考试类
 */
class Exam {
  public executive(): void {
    console.log('执行培训考试')
  }
}

/**
 * 证书类
 */
class Certificate {
  public distribute(): void {
    console.log('定制并分配培训证书')
  }
}
普通代码
Typescript 复制代码
/**
 * 主代码
 */
const main = () => {
  const expert = new Expert();
  const exam = new Exam();
  const certificate = new Certificate();
  
  expert.invite();
  exam.executive();
  certificate.distribute();
}

在上面的代码示例中,可以看到如果要在主类中执行具体的培训流程,我们需要在主类中控制相关对象的执行,但实际这些操作是"培训"这个操作内的子操作,我们外部是不关心。因此我们可以构建一个主管类,负责全权处理培训这件事,所有相关子操作都在其内部执行,而我们要做的就是让主管"培训"起来并返回结果。

使用外观模式
Typescript 复制代码
/**
 * 主管类
 */
class Manager {
  protected expert: Expert;
  protected exam: Exam;
  protected certificate: Certificate;

  constructor(expert: Expert, exam: Exam, certificate: Certificate) {
    this.expert = expert;
    this.exam = exam;
    this.certificate = certificate;
  }

  public train(): void {
    this.expert.invite();
    this.exam.executive();
    this.certificate.distribute();
  }
}

/**
 * 主代码
 */
const main = () => {
  const expert = new Expert();
  const exam = new Exam();
  const certificate = new Certificate();
  
  const manager = new Manager(expert, exam, certificate);
  manager.train();
}

我们可以看到应用了外观模式的代码,就不用再在外部处理子操作对象,在后续如果有新的环节有调整,也只是调整主管类代码即可,外部也不需要清楚主管内执行了哪些环节或如何去执行的。

可能乍一看感觉其实就是将这些子对象的操作抽离出去了,没什么太多的区别,但假设这个主类的逻辑非常复杂,那合理的抽离就变得必不可少了,不然主类代码过于臃肿就会变得难以维护。

三、外观模式

外观模式是一种结构型设计模式, 能为程序库、框架或其他复杂类提供一个简单的接口。外观类为包含许多活动部件的复杂子系统提供一个简单的接口,与直接调用子系统相比,外观提供的功能虽然可能比较有限, 但它却包含了客户端真正关心的功能。

需要注意的是如果如果外观类(也就是上面的主管类)也在不断地新增逻辑后变得过于臃肿,你可以考虑将其部分行为再抽取为一个新的专用外观类。

实际上,外观模式本身并不复杂,甚至很多开发者在不知道它叫"外观模式"前就已经会应用相关的逻辑了。

装饰器模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 会员机制 会员机制 </math>会员机制

(扮演:乙方员工)

游泳馆方找到你,说之前你帮忙开发用于展示会员服务的程序需要新增了一些服务,如私人教练、桑拿房和按摩服务,但是这些服务需要根据会员等级逐步开放展示,希望你能在程序中展示出来,另外还提醒你这些服务还不确定对应的开放等级,另外后续还可能会有其他的会员服务会新增进去。

上门的生意当然是要做的,但是怎么实现你就犯难了,你现在脑海里有两种实现方式,你打算采用哪一种呢?

① 直接在原会员类中实现这些功能并通过判断会员等级使用

② 不改动原会员类,为每个新的服务添加一个单独的类,但它们会和原会员类实现一样的接口,只不过在特定权益上进行额外的改动。

一、书接上文

如果我们选择第一种方式直接在会员类中添加这些功能,那最后的结果只会让代码变得复杂且难以维护,而第二种方式其实就是"装饰器模式",装饰器模式可以非常优雅地实现为初始功能添加额外拓展的功能,而不修改到原始类。

在装饰器模式中,拓展的功能会单独实现为一个装饰器类,但为了不影响原来功能的使用,它需要和原始类遵循同一接口,也因此我们可以像俄罗斯套娃一样对对象进行无限次的封装和功能拓展。

二、代码解析
方案一:不使用装饰器模式

每次需要添加新的服务时,都需要修改原会员类,这不仅使得代码耦合度高,而且不利于扩展和维护。

Typescript 复制代码
interface Member {
  getServices(): string[];
}

class BasicMember implements Member {
  private level: number;

  constructor(level: number) {
    this.level = level;
  }

  getServices(): string[] {
    const services = ['基础权益'];
    if (this.level >= 1) {
      services.push('私人教练');
    }
    if (this.level >= 2) {
      services.push('桑拿房');
    }
    if (this.level >= 3) {
      services.push('按摩服务');
    }
    return services;
  }
}

// 客户端代码
const basicMember = new BasicMember(0);
console.log(basicMember.getServices()); // ['基础权益']

const memberWithTrainer = new BasicMember(1);
console.log(memberWithTrainer.getServices()); // ['基础权益', '私人教练']

const memberWithTrainerAndSauna = new BasicMember(2);
console.log(memberWithTrainerAndSauna.getServices()); // ['基础权益', '私人教练', '桑拿房']

const memberWithAllServices = new BasicMember(3);
console.log(memberWithAllServices.getServices()); // ['基础权益', '私人教练', '桑拿房', '按摩服务']
方案二:使用装饰器模式

通过装饰器模式,将会员的服务扩展逻辑封装在装饰器类中,使得系统更加灵活和可扩展。

Typescript 复制代码
interface Member {
  getServices(): string[];
}

/**
 * 基本会员类
 */
class BasicMember implements Member {
  getServices(): string[] {
    return ['基础权益'];
  }
}

/**
 * 装饰器抽象类(需要和原始类遵循同一接口)
 */
class MemberDecorator implements Member {
  protected member: Member;

  constructor(member: Member) {
    this.member = member;
  }

  getServices(): string[] {
    return this.member.getServices();
  }
}

/**
 * 私人教练装饰器
 */
class PersonalTrainerDecorator extends MemberDecorator {
  // 添加"装饰",拓展功能
  getServices(): string[] {
    return [...super.getServices(), '私人教练'];
  }
}

/**
 * 桑拿房装饰器
 */
class SaunaDecorator extends MemberDecorator {
  // 添加"装饰",拓展功能
  getServices(): string[] {
    return [...super.getServices(), '桑拿房'];
  }
}

/**
 * 按摩服务装饰器
 */
class MassageDecorator extends MemberDecorator {
  // 添加"装饰",拓展功能
  getServices(): string[] {
    return [...super.getServices(), '按摩服务'];
  }
}

// 客户端代码
const basicMember = new BasicMember();
console.log(basicMember.getServices()); // ['基础权益']

const memberWithTrainer = new PersonalTrainerDecorator(basicMember);
console.log(memberWithTrainer.getServices()); // ['基础权益', '私人教练']

const memberWithTrainerAndSauna = new SaunaDecorator(memberWithTrainer);
console.log(memberWithTrainerAndSauna.getServices()); // ['基础权益', '私人教练', '桑拿房']

const memberWithAllServices = new MassageDecorator(memberWithTrainerAndSauna);
console.log(memberWithAllServices.getServices()); // ['基础权益', '私人教练', '桑拿房', '按摩服务']

从代码示例中看,我们可以发现装饰器模式不会改动原有的接口,而是基于原有接口进行功能增强,这样也就能实现"套娃"增强的效果。

也许你会好奇,就一定要保持接口一致吗?实际上,保持接口一致性是该设计模式的核心原则之一,它可以让我们透明地使用装饰后的对象,而无需感知其内部结构的变化,你新增或是删除某一接口,都会让你不得不在使用装饰后对象时保持小心翼翼。

当你的接口不一致时,其实更接近即将介绍到的适配器而非装饰器。

三、装饰器模式

装饰器模式是一种结构型设计模式,允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。装饰器模式使得系统更灵活、更易于扩展,符合面向对象设计的开闭原则,在动态添加功能和功能组合等场景中有广泛的应用。

四、应用场景
  1. 动态添加功能:在不修改现有代码的情况下,为对象动态添加新的功能。
  2. 功能组合:通过组合不同的装饰器,为对象添加多种功能。

适配器模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 门禁系统升级 门禁系统升级 </math>门禁系统升级

(扮演:游泳馆老板)

你最近给馆内升级了新的电子门禁系统,在会员录入指纹信息后就可以通过指纹识别自动开门,本来想着这样就不用会员们再花钱办 VIP 卡了,同时也能解决卡丢失的问题,但是没想到这个门禁一换倒招来了不一样的批评声音,有大部分会员说是要保护自己的指纹信息,就是不愿意使用指纹机,希望还能支持刷卡开门的方式...

但是新的门禁已经安装好了,你不可能再将它拆了,于是你只能想办法解决这个问题了。

一、书接上文

在故事中,由于新的电子门禁系统已经装好,如果想要同时支持刷卡开门的方式,我们只能咨询厂方看看能否安装什么适配的机器可以同时支持指纹开门和刷卡开门。这里的故事虽然是虚构的,但是"寻找适配器"的想法在程序设计中却是很常见。

二、代码解析
方案一:不使用适配器模式

每种设备都有自己的操作方式,对于上面的故事,这里相当就有两个独立的门禁系统,不使用适配器也就意味着只能二选一,要么只支持刷卡开门,要么就是指纹开门。

Typescript 复制代码
class FingerprintLock {
  openWithFingerprint(): void {
    console.log("通过指纹锁开锁");
  }
}

class CardLock {
  openWithCard(): void {
    console.log("通过卡片开锁");
  }
}

const fingerprintLock = new FingerprintLock();
const cardLock = new CardLock();

fingerprintLock.openWithFingerprint();
cardLock.openWithCard();
方案二:使用适配器模式
Typescript 复制代码
// 目标接口
interface Lock {
  open(): void;
}

// 适配器类(指纹锁)
class FingerprintLockAdapter implements Lock {
  private fingerprintLock: FingerprintLock;

  constructor(fingerprintLock: FingerprintLock) {
    this.fingerprintLock = fingerprintLock;
  }

  open(): void {
    this.fingerprintLock.openWithFingerprint();
  }
}

// 适配器类(卡锁)
class CardLockAdapter implements Lock {
  private cardLock: CardLock;

  constructor(cardLock: CardLock) {
    this.cardLock = cardLock;
  }

  open(): void {
    this.cardLock.openWithCard();
  }
}

// 客户端代码
const fingerprintLock = new FingerprintLock();
const cardLock = new CardLock();

const fingerprintLockAdapter = new FingerprintLockAdapter(fingerprintLock);
const cardLockAdapter = new CardLockAdapter(cardLock);

const locks: Lock[] = [fingerprintLockAdapter, cardLockAdapter];

locks.forEach(lock => lock.open());

这里你可以理解成通过适配器,将不同类型的设备适配到统一的接口,能输出相同的开锁信号,使他们能相互合作。

三、适配器模式

适配器模式是一种结构型设计模式,它可以让我们将一个类的接口转换成客户希望的另一个接口,这使得原本由于接口不兼容而不能一起工作的类可以一起工作。

四、常见应用场景

适配器模式在前端开发中非常常见,尤其是在需要整合不同的库或接口时,它有以下常见场景:

  1. API适配

相信熟悉fabricjs@5的都知道,它内部有很多API都使用回调函数来处理异步操作,比如fabric.Image.fromURL,而我们往往都向Promise靠拢了,所以只能使用适配器模式来构造新的API以适配原API了。

Typescript 复制代码
/**
 * 加载fabric图像
 * @param url 链接
 * @param option fabric.Image配置
 * @returns fabric.Image对象
 */
export const loadFabricImage = (url: string, option: fabric.IImageOptions = {}) =>
  new Promise<fabric.Image>((resolve, reject) => {
    fabric.Image.fromURL(
      url,
      (image: fabric.Image) => {
        if (url && (image.width === 0 || image.height === 0)) reject();
        resolve(image);
      },
      {
        crossOrigin: 'anonymous',
        ...option
      }
    );
  });
  1. 三方库的接口适配:将相同功能三方库的接口进行统一,以支持统一形式调用,如将多种验证码的库的API进行统一。
  2. 为后端返回的接口数据做格式适配。

享元模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 更衣间 更衣间 </math>更衣间

(扮演:游泳馆老板)

在建游泳馆的时候,由于疏忽,你并没有建设更衣间,为了临时凑合,你购入了移动厕所用做更衣间,但坑爹的是这些移动厕所从设计开始就是为了给个人使用的,所以每个只配备一张电子钥匙卡,于是你规定每个刷卡进入游泳馆的客人都会分配一个移动厕所的钥匙卡,但很快你就发现这个问题已经严重得不得不换一种方式解决了,它的数量在游泳旺季期间是根本不能达到客户需求的,如果要满足大批量的客户的更衣需求,那必然还要再购入大量的移动厕所。

对此,你得想个办法彻底解决这个问题了。

一、书接上文

在这个故事中,我们很容易找到问题的根本原因就是因为更衣间无法共享,当然在现实中这是不可能存在的,我们都知道,只有共享更衣室才是最合理的设计。

在程序设计中也是一样的道理,这个我相信尽管有很多开发者没有刻意去学习设计模式,但在开发中也会下意识去解决这个非共享资源低效占用的问题。在设计模式中,享元模式就是这个问题的其中一个解决方法,接下来我们来更详细地介绍它。

二、代码解析
普通代码
Typescript 复制代码
class Swimmer {
  public name: string;
  public roomID: string;
  public roomPrice: number;

  constructor(name: string, roomID: string, roomPrice: number) {
    this.name = name;
    this.roomID = roomID;
    this.roomPrice = roomPrice;
  }

  useRoom() {
    console.log(`${this.name}正在使用编号为${this.roomID}的更衣间,花费${this.roomPrice}。`);
  }
}

const swimmer1 = new Swimmer('小刘', '男01', 20);
swimmer1.useRoom();

const swimmer2 = new Swimmer('小李', '男01', 20);
swimmer2.useRoom();

在普通代码的实现上,由于更衣间不共享,我们会将更衣间的信息也都"绑定"在游泳者对象中。但实际上我们都知道更衣间是应该用于共享的,所以应该独立出一个对象用作共享,也就是后面我们所说的"享元对象"。

使用享元模式代码

在享元模式中,我们首先要理解两个概念:内部状态、外部状态:

① 内部状态

是指存储在享元对象内部的、不可变的数据,它不随外部环境变化。在上面的例子中,更衣间的编号和价格并不受到使用者的影响,所以可以被视为更衣间享元对象中的内部状态。

内部状态通常设计为不可变,是由于它被多个实例对象共享,若发生更改,导致不可预期的行为,举个例子:更衣间一开始男女通用,被多位游泳者共享,但后续突然指定性别,则会导致错误。

② 外部状态

是依赖于享元对象上下文或外部环境的状态。假设我们给更衣间贴了牌子表示是谁的,那这时"表示是谁的"这个状态就是外部状态,因为不同的使用者这个状态是不一样的。

通过上面的分析我们便可以将内部状态提取出来并单独实现更衣间享元对象(即仅持有内部状态的共享对象)。

Typescript 复制代码
/**
 * 更衣间享元对象
 */
class LockerRoom {
  private id: string;
  private price: number;

  constructor(id: string, price: number) {
    this.id = id;
    this.price = price;
  }

  public use(user: string): void {
    console.log(`${user}正在使用编号为${this.id}的更衣间,花费${this.price}。`);
  }
}

/**
 * 游泳者
 */
class Swimmer {
  public name: string;
  private room: LockerRoom;  

  constructor(name: string, room: LockerRoom) {
    this.name = name;
    this.room = room;
  }

  useRoom() {
    this.room.use(this.name);
  }
}

const swimmer = new Swimmer("小刘", new LockerRoom('男01', 20));
swimmer1.useRoom();

对于上面的代码,如果你认真思考,你会想到一个问题:上面只是将内部状态抽离并封装成享元对象,"共享"体现在哪?是的,享元模式不仅要需要构造享元对象,还需要一个享元工厂实现享元对象的共享使用:

享元工厂

享元工厂方法接收目标享元对象的内在状态作为参数,如果它能在缓存池中找到所需享元,则将其返回给客户端; 如果没有找到,它就会新建一个享元,并将其添加到缓存池中。

Typescript 复制代码
/**
 * 享元工厂
 */
class LockerRoomFactory {
  private dressingRooms: { [key: string]: LockerRoom } = {};

  public getLockerRooms(id: string, price: number): LockerRoom {
    const key = `${id}_${price}`;
    if (!(key in this.dressingRooms)) {
      this.dressingRooms[key] = new LockerRoom(id, price);
    }
    return this.dressingRooms[key];
  }
}

const factory = new LockerRoomFactory();

// 使用时,通过享元工厂获取享元对象,而非直接通过 new 创建享元对象
const swimmer1 = new Swimmer("小刘", factory.getLockerRooms('男01', 20));
swimmer1.useRoom();

const swimmer2 = new Swimmer("小李", factory.getLockerRooms('男01', 20));
swimmer2.useRoom();

本来很想说 Prototype原型对象 的设计很有享元模式的味道,但由于它并没有类似享元工厂管理共享对象的概念,因此它最多只是接近享元模式中的"内部状态共享"。

三、享元模式

享元模式是一种结构型设计模式,它摒弃了在每个对象中保存所有数据的方式,通过共享多个对象所共有的相同状态,让你能在有限的内存容量中载入更多对象。

但需要注意的是享元模式根本上是一种优化,不是必需。在应用该模式之前,要确定程序中是否存在大量重复对象导致显著内存浪费问题,且需确保对象状态能明确拆分为可共享的内部状态和动态传入的外部状态。

行为型设计模式

策略模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 路程规划 路程规划 </math>路程规划

(扮演:乙方员工)

你接了一个外包项目,为某游泳馆开发一款综合APP,其中有个功能客户输入定位后就能在地图上看到前往游泳馆的最快路线。

由于首个版本发布的时候只需要规划自驾路线,所以你直接将逻辑写在上下文里,但后续游泳馆人员反馈需要加入了步行规划,你又直接修改了上下文代码。但当你好不容易开发完了,游泳馆方面又提出了新的需求,希望能加上骑行者规划路径......

你看着庞大的上下文代码,实在是不想每次修改或新增方式都去修改它了,于是你跟领导申请了一天时间用于重构这块的代码,但是你想好了用什么设计模式去重构吗?

一、书接上文

在故事中,由于开发者将路径规划的逻辑都写在上下文中,这导致代码变得非常臃肿,无论是修复简单的缺陷还是添加新的规划算法, 都会影响到整个上下文, 从而增加在已有正常运行代码中引入错误的风险。在实际开发中,如果是团队合作的项目,还会大大增加代码相互冲突的几率。

那怎样的程序设计才能更好地解决这个问题呢?在上面的故事中,很明显我们能发现路线的规划方式其实是一种"策略",它们是相互独立的且可以互换的,这种场景下应用"策略模式"再合理不过了,接下来我们来学习策略模式。

二、代码解析
普通代码

按照故事中描述,开发者初始的程序设计如下面的代码所示:

Typescript 复制代码
class APP {
  getNearestRoute(type: string, start: Coord, end: Coord): Coord[] { 
    switch (type) {
      case 'walking':
        return [];
      case 'cycling':
        return [];
      case 'driving':
        return [];
      default:
        throw new Error('没有匹配到对应的处理方式');
    }
  }  
}

这种写法相信我们都非常熟悉了,在选项比较少且选项逻辑简单且极少发生变化的情况下,这也是一个不错的选择。但在这个例子中,每一个选项中的实现都可能非常复杂,且很容易会出现"类型拓展"、"计算逻辑调整"的情况,如果还依旧采用上面的写法,在多次增量开发后该方法/类会变得十分臃肿且难以维护。

使用策略模式

对此我们可以做以下调整,将策略的实现提取到外部实现:

Typescript 复制代码
type Coord = { x: number; y: number; };

// 定义策略接口
type Strategy = (start: Coord, end: Coord) => Coord[];

// 策略列表
const strategies: Record<string, Strategy> = {
  // 步行策略
  walking: () => [],
  // 骑行策略  
  cycling: () => [],
  // 自驾策略  
  driving: () => [],
}

class APP {  
  private strategy: Strategy;  

  constructor(strategy: Strategy) {  
      this.strategy = strategy;  
  }

  setStrategy(strategy: Strategy) {  
      this.strategy = strategy;  
  }

  getNearestRoute(start: Coord, end: Coord): Coord[] {  
      return this.strategy(start, end);  
  }  
}   

乍一看,你可能会觉得这不也存在"导致臃肿"的问题,因为strategies会不断拓展,但实际上你的关注点错了,因为这里的列表strategies仅为了用更少代码演示才如此实现,在策略模式的实际应用中每个策略可以将其拆分到各处独立实现再集中引入(可见下方代码示例),策略模式真正需要我们关注的是它将 "算法实现""算法应用" 的代码隔离开了,同时强调算法之间是可以无缝切换的。

在这个代码示例中,我们可以看到,如果我们需要应用一个新的策略,我们无需要修改主类中的代码,我们只需要编写一个新策略(这里也就是一个方法)并将该策略直接指定为业务中的应用策略,即可实现无缝切换,不用改动到主类的代码。

Typescript 复制代码
// 将策略在外部具体实现并在此引用进来
import walking from 'strategies/walking';
import cycling from 'strategies/cycling';

const app = new APP(walking);

// 修改策略
app.setStrategy(cycling);

// 应用策略计算最近路线
app.getNearestRoute();
三、策略模式

策略模式 是一种行为设计模式, 它能让你定义一系列算法, 并将每种算法分别放入独立的策略类中,使算法的对象能够相互替换。在上面的例子中由于Javascript的灵活性,我们可以无需封装独立的算法类,而是简单地将函数作为"策略"对象(函数本身就是对象)。

在使用策略模式之前,需要注意的是, "策略"必须遵循可相互替换,如果策略本身内部某些逻辑和主业务强相关,或者与主业务存在各种变量依赖,那就不算是一个合格的"策略";另外如果某段代码逻辑后续基本不会再做拓展了也不会发生变化,那直接与主类逻辑写一起会更合适,不然硬要抽离代码应用策略模式反倒涉嫌过度设计,让程序变得复杂了。

状态模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 学员考试 学员考试 </math>学员考试

(扮演:专家)

在过去几年的学员考试中,由于每个项目考试都需要等学员进场后才根据学员的水平等级分配到不同的考场中考试,进场后才临时分配这个安排使得考试流程变得十分混乱,上面的领导今年特意找到你让你今年一定要解决好这个问题。

因此,你打算如何调整呢?

一、书接上文

在故事中每个项目考试需要等学员进场后才根据学员的等级分配到不同的考场中考试,实际学员的水平等级是短时间内是固定的,这个分配的操作我们是可以直接提前到考试前的,让学员在考前先获取其水平等级对应的各个考场位置,然后直接前往考试即可。

身为开发者的你,不知道有没有觉得这个逻辑似曾相识呢?它非常类似我们在业务代码中根据某一个状态的值而执行不同操作,但在故事中,我们采取了一种非常低效的方式,我们在每次不同的操作前都要经过一次判断。如果我们应用"状态模式",那将能得到不小的改善,接下来我们来详细学习一下它。

二、代码解析
普通代码
Typescript 复制代码
class Student {
  public name: string;
  private grade: string;

  constructor(name: string, grade: string) {
    this.name = name;
    this.grade = grade;
  }

  public test(): void {
    switch (this.grade) {
      case 'A':
        console.log("A等级考试");
        break;
      case 'B':
        console.log("B等级考试");
        break;
      default:
        throw Error('无该等级考试');
    }
  }
  // !假如还有其他考试
  // public test2() { switch (this.grade)... }
}

const student1 = new Student('小刘', "A");
student1.test();

const student2 = new Student('小李', "B");
student2.test();

在普通代码实现中,test()作为一次考试操作,它内部根据学员的等级分配前往不同的考场考试,也许单这样看没什么大问题,但是实际业务中也许不只有一次test()操作,如果每个操作都需要根据等级判断,那将使得学员类变得异常臃肿,而且一旦后续需要添加新的状态时,那将是一次吐血的体验(需要在每个操作中补充新的状态条件)。

使用状态模式
Typescript 复制代码
abstract class Grade {
  public abstract test(): void;
  public abstract test2(): void;
}

class GradeA extends Grade {
  public test(): void {
    console.log("A等级考试");
  }
  // public test2() { ... }
}

class GradeB extends Grade {
  public test(): void {
    console.log("B等级考试");
  }
  // public test2() { ... }
}

class Student {
  public name: string;
  private grade: Grade;

  constructor(name: string, grade: Grade) {
    this.name = name;
    this.grade = grade;
  }

  setGrade(grade: Grade) {
    this.grade = grade;
  }

  public test(): void {
    this.grade.test();
  }

  // public test2() {
  //   this.grade.test2();
  // }
}

const student1 = new Student('小刘', new GradeA());
student1.test();

const student2 = new Student('小李', new GradeB());
student2.test();

在状态模式中,每个状态对应的行为操作会被封装在单独的状态类中,多个状态类应该实现相同的接口方法(如上面具体实现Exam抽象类),这使我们可以在后续非常容易拓展新的状态或是切换状态,而不是在对象内部通过一系列条件判断去区分和执行不同的行为。

状态模式 vs 策略模式

看到这里,你可能会觉得状态模式和策略模式很相似,都是将具体实现抽离出来并通过外部去切换应用,只是状态模式看起来更像是某个状态下的多个策略组合成的一个状态类了。

实际上,上面并非符合经典状态模式的定义,经典状态模式定义下"状态"是内部切换的,而非外部切换的。承接上面的故事,假设A等级考试test考试通过后会根据分数内部自行调整升级当前用户等级:

Typescript 复制代码
class GradeA extends Grade {
  student: Student;

  constructor(student: Student) {
    this.student = student;
  }

  public test(): void {
    // 分数大于等于60时内部切换状态,而非外部手动切换状态
    const score = 60;
    if (score >= 60) {
      this.student.setGrade(new GradeB(this.student))
    }
  }
}

没有内部切换状态仅封装行为的状态模式实现可以视为状态模式的简化形式。

维度 状态模式 策略模式
设计意图 集中管理对象的状态变化后的行为,状态内部行为会产生状态变化并切换 封装可替换的算法或行为,外部根据需求动态切换策略
切换原因 对象的状态因为某些行为发生更改(如按钮"未开启态"的情况下点击切换为"开启态") 外部主动选择的策略
三、状态模式

状态模式是一种行为设计模式, 能让你在一个对象的内部状态变化时改变其行为, 使其看上去就像改变了自身所属的类一样。

从上面的例子中我们也能感受到,当使用状态模式后,拓展不同状态下的行为和添加新的状态都变得非常轻松,可以消除臃肿的条件语句。

四、相关注意

如果对象需要根据自身当前状态进行不同行为, 同时状态的数量非常多且与状态相关的代码可能频繁变更的话,可使用状态模式。但相反的是,如果只有很少的几个状态, 或者很少发生改变, 那么应用该模式可能会显得小题大做。

迭代器模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 储存柜建议 储存柜建议 </math>储存柜建议

(扮演:游泳者)

每次你满怀兴致去游泳馆都会碰上一件令人感到郁闷的事。

游泳馆更衣室有数个小型存储柜,但是这些存储柜都是相互独立的,只要在刷VIP卡后才会检测是否被使用,当无人使用时会在刷卡后自动弹出柜门,所以当你打算存储物品的时候,你不得不一个个尝试,直到刷出空柜子。

今天你实在无法再忍受这种看脸的事情了,于是你向游泳馆老板提出了一个非常有建设性的意见,你还记得你提的建议吗?

一、书接上文

"既然不想人为逐个去检查是不是空柜子,那能不能加个总控逐个检查空柜子呢?"

在程序中,"迭代器模式"就是一种很好的用于顺序访问聚合对象中每个元素的方式。迭代器模式使得我们可以遍历不同的集合结构(如数组、链表等)而不需要了解它们的内部实现。

二、代码解析
方案一:不使用迭代器模式

直接遍历集合来查找空的存储柜。

Typescript 复制代码
// 存储柜类
class Locker {
  private occupied: boolean = false;

  isOccupied(): boolean {
    return this.occupied;
  }

  use(): void {
    if (!this.occupied) {
      this.occupied = true;
      console.log("Locker is now in use.");
    } else {
      console.log("Locker is already occupied.");
    }
  }
}

// 客户端代码
const locker1 = new Locker();
const locker2 = new Locker();
const locker3 = new Locker();

const lockers: Locker[] = [locker1, locker2, locker3];

for (const locker of lockers) {
  if (!locker.isOccupied()) {
    locker.use();
    break;
  }
}
方案二:使用迭代器模式

通过迭代器模式,将存储柜的遍历逻辑封装在迭代器类中,使得系统更加灵活和可扩展。

Typescript 复制代码
class Locker {
  private occupied: boolean = false;

  isOccupied(): boolean {
    return this.occupied;
  }

  use(): void {
    if (!this.occupied) {
      this.occupied = true;
      console.log("Locker is now in use.");
    } else {
      console.log("Locker is already occupied.");
    }
  }
}

class LockerIterator {
  private lockers: Locker[];
  private position: number = 0;

  constructor(lockers: Locker[]) {
    this.lockers = lockers;
  }

  hasNext(): boolean {
    return this.position < this.lockers.length;
  }

  next(): Locker | undefined {
    while (this.hasNext()) {
      const locker = this.lockers[this.position++];
      if (!locker.isOccupied()) {
        return locker;
      }
    }
    return undefined;
  }
}

// 客户端代码
const locker1 = new Locker();
const locker2 = new Locker();
const locker3 = new Locker();

const lockers: Locker[] = [locker1, locker2, locker3];
const iterator = new LockerIterator(lockers);

const availableLocker = iterator.next();
if (availableLocker) {
  availableLocker.use();
  console.log("找到空柜子并使用。");
} else {
  console.log("没有空柜子。");
}

这里需要注意的是迭代器中next方法通常返回下一个元素,而不应该包含业务逻辑(像上面的use方法的调用),将业务逻辑放在客户端代码中,可以保持迭代器的"单一职责"------遍历元素。

三、迭代器模式

迭代器模式是一种行为型设计模式,它允许我们顺序访问一个聚合对象(如数组、链表等)中的各个元素,而不需要暴露该对象的内部表示,另外迭代器模式将遍历的逻辑封装在迭代器类中,这也让外部客户端的代码更为简洁和易于维护。

有人可能会觉得,这还不如直接遍历数组,但是实际开发中我们会遇到各种各样的数据结构,例如树结构、图结构等,那这些情况我们将无法直接当做数组遍历,需要单独实现遍历逻辑,你可以理解成迭代器的作用就是将遍历的操作封装起来了,给外部提供一种依次遍历的方式。

在实际应用中,当你需要减少重复的遍历逻辑或是你希望对外隐藏你遍历的逻辑时,迭代器就能发挥其用处。

四、应用场景
  1. 遍历集合:需要多处遍历一个集合中的所有元素,而不需要了解集合的内部实现。

  2. 统一接口:为不同的集合结构提供一个统一的遍历接口,如树结构的深度优先遍历。

Typescript 复制代码
// 假设树结构如下
class TreeNode {
  constructor(value) {
    this.value = value;
    this.children = [];
  }
}

// 实现树结构的深度优先迭代器
function createTreeIterator(root) {
  const stack = [root]; // 使用栈模拟深度优先遍历

  return {
    next: function() {
      if (stack.length === 0) {
        return { done: true }; // 遍历结束
      }

      const node = stack.pop(); // 弹出当前节点
      const value = node.value;

      // 将子节点逆序压入栈
      for (let i = node.children.length - 1; i >= 0; i--) {
        stack.push(node.children[i]);
      }

      return { value, done: false };
    }
  };
}

const root = new TreeNode(1);
root.children.push(new TreeNode(2), new TreeNode(3));
const iterator = createTreeIterator(root);

let result = iterator.next();
while (!result.done) {
  console.log(result.value); // 依次输出 1, 2, 3
  result = iterator.next();
}

观察者模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 赛事通知 赛事通知 </math>赛事通知

(扮演:游泳馆老板)

临近夏季学生游泳大赛,最近总有一些家长打电话过来询问赛事的具体时间和地点,但是具体的通知还未下达,我们只能回复家长让过段时间再来询问,但有些家长来电过于频繁,导致不得已需要增派临时客服。

你很清楚当前这种你问我答的方式是一种极其糟糕的处理方式,所以你不得不沉下心来好好思考有没有更好的办法了。

一、书接上文

不知道你是不是也有一样的想法,那就是将家长们的信息记录下来,并让家长们等待回电,当赛事通知下来有个准信后我们再回电给家长,这种方式光想想都非常合理,它可以有效地解决来电过于频繁占用来电的情况。

这种处理方式和设计模式中的"观察者模式"有着异曲同工之妙,在观察者模式中,拥有一些值得关注的状态的对象通常被称为目标,也将其称为发布者(publisher),比如这里的游泳馆,而所有希望关注发布者状态变化的其他对象被称为订阅者(subscribers),比如故事中留下联系信息的家长,发布者会在其状态发生变化时通知所有订阅者。观察者模式使得我们可以在对象之间建立松散耦合的关系。

二、代码解析
方案一:不使用观察者模式
Typescript 复制代码
class Parent {
  private name: string;

  constructor(name: string) {
    this.name = name;
  }

  notify(message: string): void {
    console.log(`${this.name} received message: ${message}`);
  }
}

const parent1 = new Parent("Parent 1");
const parent2 = new Parent("Parent 2");

const parents: Parent[] = [parent1, parent2];

// 当赛事通知下来后,手动逐个通知所有家长
for (const parent of parents) {
  parent.notify("比赛在XXX举办。");
}

在这种方式下,每次有新的通知时,都需要手动逐个通知家长,乍一看没问题啊,但是请想象一下,假如赛事通知下来的地方不止一处代码呢?这意味着你要在每一处都需要加上这段手动通知的逻辑,这不仅效率低下,而且很容易出错。

类比实际开发中用户点击A、B按钮都会导致页面中多处UI发生一样的样式变化,如果你只是想在A、B点击各种实现,那假设哪一天多了个C按钮也是一样的功能呢,或者UI的变换逻辑需要调整,这些都够你吃一壶的。

当然你可以说将这段通知的逻辑抽离成一个方法调用就不用重复写了,但是假设哪一天你就恰好忘了一处呢?这种方式治标不治本。

方案二:使用观察者模式

通过观察者模式,将家长注册为观察者,当赛事信息变动时,游泳馆作为发布者一次性通知所有观察者,使得系统更加高效和灵活。

Typescript 复制代码
// 订阅者类(家长)
class Parent {
  private name: string;

  constructor(name: string) {
    this.name = name;
  }

  update(message: string): void {
    console.log(`${this.name} 接收到消息:${message}`);
  }
}

// 发布者类(通知赛事的游泳馆)
class GYM {
  private observers: Parent[] = [];

  registerObserver(observer: Parent): void {
    this.observers.push(observer);
  }

  removeObserver(observer: Parent): void {
    const index = this.observers.indexOf(observer);
    if (index !== -1) {
      this.observers.splice(index, 1);
    }
  }

  notifyObservers(message: string): void {
    for (const observer of this.observers) {
      observer.update(message);
    }
  }
}

const gym = new GYM();

const parent1 = new Parent("1号家长");
const parent2 = new Parent("2号家长");

gym.registerObserver(parent1);
gym.registerObserver(parent2);

// 当赛事通知下来后,发布者(游泳馆)通知所有订阅者(家长)
gym.notifyObservers("游泳比赛将在六月一日早上8点开始,请家长提前做好准备。");

观察者模式定义了对象间的一对多依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都能得到通知并自动更新。

三、发布-订阅模式

发布-订阅模式和观察者模式有很多相似之处,但它们并不是严格的父子关系,而是两种不同的设计模式,尽管它们在某些方面有重叠。可以说,发布-订阅模式是观察者模式的一种更松散耦合的变体,特别适用于异步通信场景。

观察者模式 vs 发布-订阅模式
  • 观察者模式:订阅者直接依赖于发布者,发布者维护订阅者列表并直接通知它们。订阅者和发布者之间存在直接引用,耦合度较高。
  • 发布-订阅模式:发布者和订阅者通过消息代理进行通信,彼此之间没有直接引用。发布者和订阅者通过消息代理解耦,耦合度较低。拿上面的故事举例子,家长(观察者)让某个机构帮忙收集游泳馆比赛信息,有比赛就通知家长,这里家长和游泳馆直接没有直接联系,家长不知道是哪个游泳馆会举行比赛,游泳馆也不知道有哪些家长关注比赛消息,机构收到相关消息就直接发布就好了。
发布-订阅模式代码示例
Typescript 复制代码
// 消息代理类
class MessageBroker {
  private subscribers: { [key: string]: Function[] } = {};

  subscribe(eventType: string, callback: Function): void {
    if (!this.subscribers[eventType]) {
      this.subscribers[eventType] = [];
    }
    this.subscribers[eventType].push(callback);
  }

  publish(eventType: string, data: any): void {
    if (this.subscribers[eventType]) {
      this.subscribers[eventType].forEach(callback => callback(data));
    }
  }
}

// 客户端代码
const broker = new MessageBroker();

const parent1 = (message: string) => console.log(`1号家长接收到消息: ${message}`);
const parent2 = (message: string) => console.log(`2号家长接收到消息: ${message}`);

broker.subscribe("event", parent1);
broker.subscribe("event", parent2);

// 当赛事通知下来后,发布消息
broker.publish("event", "游泳比赛将在六月一日早上8点开始,请家长提前做好准备。");
四、常见应用场景
  1. 事件驱动系统 :在事件驱动系统中,使用观察者模式实现事件的订阅和通知(如:window.addEventListener)。
  2. 模型-视图更新:在MVC架构中,使用观察者模式实现模型和视图的同步更新。
  3. 消息通知系统:在消息通知系统中,使用观察者模式实现消息的发布和订阅,这里其实用发布-订阅模式更合适,更灵活。

命令模式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 自动化管理系统 自动化管理系统 </math>自动化管理系统

(扮演:保障科大将)

身为游泳馆保障科的一员大将,你平时总在各种设备上花大量的时间,包括灯光、温度控制、泳池清洁等,导致无法抽出时间去做其他的事项。虽然你勤勤恳恳,但是最近你却听到了一个八卦,说是老板觉得保障科的存在感不强、总摸鱼、干事效率低,准备裁掉整个部门,对此你感到十分委屈,当务之急,你必须想到一种更高效的方式来控制这些设备的操作,以抽出时间精力去做更多的事务。

一、书接上文

什么会是更高效的方式?那必然是自动化,这些设备的操作实际上都是固定的,在故事中我们可以寻找外包编写自动化执行程序,将这些操作以一种"命令"的方式编写在程序中,并调用"命令"依次自动执行即可,从程序设计的角度来看,这非常类似"命令模式"。

二、代码解析

首先我们需要模拟两个设备类:灯光类和泳池清理设备类。

Typescript 复制代码
// 模拟设备类 - 灯光
class Light {
  on() {
    console.log("打开灯光");
  }

  off() {
    console.log("关闭灯光");
  }
}

// 模拟设备类 - 泳池清理
class PoolCleaner {
  start() {
    console.log("打开泳池清理");
  }

  stop() {
    console.log("关闭泳池清理");
  }
}
方案一:不使用命令模式

不使用命令模式时,每种设备的操作都是独立的,管理起来比较复杂。

Typescript 复制代码
const light = new Light();
const poolCleaner = new PoolCleaner();

// 开启
light.on();
poolCleaner.start();

// 关闭
light.off();
poolCleaner.stop();
方案二:使用命令模式

通过命令模式,我们可以将设备的操作封装成命令对象,使得系统更加灵活和可扩展。

Typescript 复制代码
// 命令类型
type Command = {
  execute: () => void;
};

// 具体命令(打开灯光)
const createLightOnCommand = (light: Light): Command => ({
  execute: () => light.on(),
});

// 具体命令(关闭灯光)
const createLightOffCommand = (light: Light): Command => ({
  execute: () => light.off(),
});

// 具体命令(启动泳池清洁器)
const createPoolCleanerStartCommand = (poolCleaner: PoolCleaner): Command => ({
  execute: () => poolCleaner.start(),
});

// 具体命令(停止泳池清洁器)
const createPoolCleanerStopCommand = (poolCleaner: PoolCleaner): Command => ({
  execute: () => poolCleaner.stop(),
});

// 调用者类
class RemoteControl {
  private commandQueue: Command[] = [];

  addCommand(command: Command): void {
    this.commandQueue.push(command);
  }

  executeCommands(): void {
    while (this.commandQueue.length > 0) {
      const command = this.commandQueue.shift();
      if (command) {
        command.execute();
      }
    }
  }
}

// 客户端代码
const light = new Light();
const poolCleaner = new PoolCleaner();

const lightOnCommand = createLightOnCommand(light);
const lightOffCommand = createLightOffCommand(light);
const poolCleanerStartCommand = createPoolCleanerStartCommand(poolCleaner);
const poolCleanerStopCommand = createPoolCleanerStopCommand(poolCleaner);

const remoteControl = new RemoteControl();

// 添加命令列表
remoteControl.addCommand(lightOnCommand);
remoteControl.addCommand(poolCleanerStartCommand);
remoteControl.addCommand(lightOffCommand);
remoteControl.addCommand(poolCleanerStopCommand);

// 执行命令列表
remoteControl.executeCommands();
三、命令模式

命令模式是一种行为型设计模式,它将一段操作逻辑封装成一个命令对象(仅有一个执行方法) ,该转换可以使你实现方法的参数化或是通过放入执行队列实现延迟执行。

通过命令模式你还可以实现行为撤销的操作,如下方例子:

Typescript 复制代码
// 命令类型
type Command = {
  execute: () => void;
  undo: () => void;
};

// 具体命令(打开灯光)
const createLightOnCommand = (light: Light): Command => ({
  execute: () => light.on(),
  undo: () => light.off(),
});

// 具体命令(关闭灯光)
const createLightOffCommand = (light: Light): Command => ({
  execute: () => light.off(),
  undo: () => light.on(),
});

// 具体命令(启动泳池清洁器)
const createPoolCleanerStartCommand = (poolCleaner: PoolCleaner): Command => ({
  execute: () => poolCleaner.start(),
  undo: () => poolCleaner.stop(),
});

// 具体命令(停止泳池清洁器)
const createPoolCleanerStopCommand = (poolCleaner: PoolCleaner): Command => ({
  execute: () => poolCleaner.stop(),
  undo: () => poolCleaner.start(),
});

// 调用者类
class RemoteControl {
  private commandQueue: Command[] = [];
  private executedCommands: Command[] = [];

  addCommand(command: Command): void {
    this.commandQueue.push(command);
  }

  executeCommands(): void {
    const command = this.commandQueue.shift();

    const execute = (command: Command) => {
      command.execute();
      this.executedCommands.push(command);
      this.executeCommands();
    }

    if (command) {
      execute(command);
    }
  }

  undoLastCommand(): void {
    const command = this.executedCommands.pop();
    if (command) {
      command.undo();
    }
  }
}

// 客户端代码
const light = new Light();
const poolCleaner = new PoolCleaner();

const lightOnCommand = createLightOnCommand(light);
const lightOffCommand = createLightOffCommand(light);
const poolCleanerStartCommand = createPoolCleanerStartCommand(poolCleaner);
const poolCleanerStopCommand = createPoolCleanerStopCommand(poolCleaner);

const remoteControl = new RemoteControl();

// 添加命令列表
remoteControl.addCommand(lightOnCommand);
remoteControl.addCommand(poolCleanerStartCommand);
remoteControl.addCommand(lightOffCommand);
remoteControl.addCommand(poolCleanerStopCommand);

// 执行命令列表
remoteControl.executeCommands();

// 撤销最后一个命令
remoteControl.undoLastCommand();
四、常见应用场景
  1. 事务管理:将事务操作封装成命令对象,支持事务的回滚和重做。
  2. 任务调度:将任务封装成命令对象,支持任务的调度和执行。
  3. 宏命令:将一组操作封装成命令对象,支持批量操作。

最后

部分设计模式暂未完成编写,我会后续补充(没有反馈的话可能会拖🤫),感兴趣的你也可根据自己的理解发布文章并发文章链接在评论区,我认为很赞的话就直接引用了~

讲更多的故事,让更多人收益,点赞收藏人人有责哈哈😆~


以下设计模式仍待补充:

结构型设计模式(组合模式、桥接模式)

行为型设计模式(模板方法模式、中介者模式、职责链模式、访问者模式、备忘录模式)

相关推荐
anyup_前端梦工厂9 分钟前
React 单一职责原则:优化组件设计与提高可维护性
前端·javascript·react.js
天天扭码36 分钟前
面试官:算法题”除自身以外数组的乘积“ 我:😄 面试官:不能用除法 我:😓
前端·算法·面试
小小小小宇39 分钟前
十万字JS不良实践总结(逼疯审核版)
前端
喝拿铁写前端42 分钟前
从列表页到规则引擎:一个组件封装过程中的前端认知进阶
前端·vue.js·架构
小小小小宇1 小时前
React Lanes(泳道)机制
前端
zhaoyqcsdn1 小时前
建造者模式详解及其在自动驾驶场景的应用举例(以C++代码实现)
c++·笔记·设计模式
zhangxingchao1 小时前
Jetpack Compose 之 Modifier(上)
前端
龙萌酱1 小时前
力扣每日打卡17 49. 字母异位词分组 (中等)
前端·javascript·算法·leetcode
工呈士1 小时前
HTML与Web性能优化
前端·html