设计模式前言

基本概念

设计模式是什么?

相信这是每一个同学在刚开始学习设计模式的时候都会存在的疑问,单单从名字上来看这确实会让人感觉是一门十分高大上的学问,但是真的是这样吗?

答案当然是否定的。相反,设计模式十分的接地气,可以说它存在于我们生活中的方方面面。

在《设计模式:可复用面向对象软件的基础》一书中,四位作者总结了 23 种常见的软件开发设计模式。在书中,设计模式的定义是:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。

通俗的讲,设计模式本质上就是一种解决特定问题的思路。当你面对某一个问题时,前人早已遇到过这类似的问题,他们总结出来了一套解决该问题的思路,你只需要遵照这样的思路就能又快又好的解决这个问题。

设计模式在前端领域的作用

在设计模式的基本概念有了一个大概的了解之后,让我们再次回到本系列课程的主题,即为什么需要从前端角度看设计模式,或者说从 JavaScript 的角度看设计模式。

在设计模式出现之初,很多程序员都从中直接或者间接的获得了灵感从而解决了某些问题。这个时候却出现了一种不和谐的声音,那就是像 JavaScript 这类动态语言完全没有设计模式的用武之地。

不得不说,这样的声音让众多从事 JavaScript 开发的程序员很是失望,也曾一度怀疑过,特别是对刚刚进入前端开发领域的同学更是造成了困惑。然而事实并非如此。

在此我们需要再一次明确设计模式的概念,即设计模式是为针对特定问题的解决方案。实际上你在前端领域很多地方都会碰到设计模式的应用。只不过你还没有意识到这是设计模式。

下面我们来举一些例子。

JS 中原型链与原型模式

在设计模式中,是这样描述原型模式的:原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。

这一模式实际上在 JavaScript 中非常重要,有 JavaScript 基础的同学应该都知道,原型与原型链是学习 JavaScript 这门语言过程中难以翻越的一座大山。

在开始演示前,需要在实验环境根目录下新建一个 index.html 文件,并添加基本代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    设计模式学习前言 - 代码示例
  </body>
</html>

就像这样:

图片描述

随后我们需要新建一个 person.js,用于演示原型链与原型模式的相关代码: 并通过 script 标签引入:

图片描述

在此我们首先来看看 JS 中的原型。首先我们构建一个 Person 的构造函数:

//  person.js

function Person(name, age, phone) {
  this.name = name; // 名称
  this.age = age; // 年龄
  this.phone = phone; // 电话
}

这是一个非常基础的 Person 构造函数,它仅仅具有三个属性,现在我们为该构造函数添加一个“吃东西”的行为:

//  person.js

Person.prototype.doEat = function (other, address) {
  // 通过在控制台打印的方式来检查吃的行为是否正确
  console.log(`${this.name}${other}${address}吃饭!`);
};

这里需要特别提醒的是,在 ES6 规范以前,我们没有 class 这个语法糖,因此只能通过在构造函数的原型上添加这个方法,而为什么要这样,这里暂时卖个关子,很快会对此做出解释。

现在来看看具体的使用,首先通过 Person 创建一个 zhangsan 实例:

//  person.js

const zhangsan = new Person("张三", 24, "13911111111");

console.log(zhangsan);

为了看到打印结果,大家首先需要根据下图的操作顺序打开在线运行环境,随后在新的页面按 F12 键打开控制台:

图片描述

然后在控制台就可以看到打印出来的 zhangsan 实例:

图片描述

首先可以看到,我们在 Person 的原型 prototype 上绑定的 doEat 方法并没有出现在具体的 zhangsan 实例中,那么我们能通过这个实例调用到 doEat 方法吗?请看:

//  person.js

zhangsan.doEat("罗翔老师", "朝阳区"); // 张三和罗翔老师在朝阳区吃饭!

可以确认的是,虽然 doEat 方法没有出现在 zhangsan 这个实例中,但是却仍然可以使用到。这就是原型链的功能,请看下面的实例:

图片描述

在控制台打印出来的实例中你会看到,除了 Person 的三个基本属性之外,还有一个 [[Prototype]] 属性对象,这个对象就是我们一直所提到的原型,且 [[Prototype]] 这个属性指向的是正是构造函数的原型对象:prototype

可以看到,doEat 方法也正好挂在与该原型对象上。且 [[Prototype]] 下的对象还有一个 [[Prototype]] 对象,这也就构成了我们提到的原型链。

至此,我们对原型和原型链就做了一个简单的介绍,如果你并不能一下子明白,也没有关系,因为在具体的原型模式那一章中我们会单独为你详细讲解原型及原型链。

回到我们本小节的主题,即原型链和原型模式。还记得我们对原型模式的定义吗,原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。

原型链的实现不正好是这样一种思路,我们在构建 zhangsan 实例后,该实例并没有 doEat 这个方法,却能调用到该方法。当我们需要大量的构建实例时,通过这样的方式就可以避免在每一个具体实例中去书写重复的方法。

这也和原型模式的定义不谋而合。即创建了大量的对象,又保证了性能,只需要通过原型链就可以调用到原型上的方法。

对象中的[[Prototype]] 属性对象,正是构造函数的原型对象

对象与构造函数的原型虽然是同一个,但是调用方法不同,对象是通过__proto__,函数是通过prototype调用,为什么?

跟对象中的[[Prototype]]有关吗?

单例模式

以上是对单例模式的定义,当你想控制实例的数量时,可以考虑单例模式。

接下来在请考虑这样一个情况,你有一个全局使用的类频繁地创建与销毁。如果大量使用该类则难免造成系统资源的浪费。那么可以通过创建一个全局可使用的实例来避免此问题出现。

让我们看看这样一个需求在前端应该怎么实现吧。

在实验环境根目录下,新建一个 singlePeople1.js 文件,同时引入 index.html 文件中(注释掉其他的 script 标签):

图片描述

首先,如果要全局使用一个实例,那就需要该类具备判断全局范围是否已经创建过实例的能力。像下面这样就是不行的:

// singlePeople1.js

class SinglePeople {
  constructor(name) {
    this.name = name;
  }
  eat = () => {
    console.log(`${this.name}可以吃东西!`);
  };
}

上述代码定义了一个 SinglePeople 类,并为其添加了属性与方法,接下来我们的目的是构造两个实例,观察这两个实例是不是同一个实例。

// singlePeople1.js

const person1 = new SinglePeople("张三");
const person2 = new SinglePeople("张三");

console.log(person1 === person2); // false

可以看到,虽然我们本意是想创建张三这个人,但是通过 SinglePeople 创建的实例却是不一样的,由此表明这样的创建方式是无法实现单例模式的。

在实验环境根目录下,新建一个 singlePeople2.js 文件,引入方式参考前面的方式即可。

因此,在代码内部,需要判断该类是否已经创建过实例了,如果创建了实例,则直接返回已创建的实例,就像这样:

// singlePeople2.js

class SinglePeople {
  constructor(name) {
    this.name = name;
  }

  static getInstance(name) {
    // 判断是否已经创建过实例
    if (!SinglePeople.instance) {
      // 实例不存在则创建
      SinglePeople.instance = new SinglePeople(name);
    }
    // 实例存在则直接返回
    return SinglePeople.instance;
  }

  eat = () => {
    console.log(`${this.name}可以吃东西!`);
  };
}

在上述代码中,我们给类增加一个属性 instance ,用于判断是否已经存在实例,来看看具体的效果:

// singlePeople2.js

const person1 = SinglePeople.getInstance("张三");
const person2 = SinglePeople.getInstance("张三");

console.log(person1 === person2); // true

通过打印结果可以发现,即使重复的去构建新的实例,也只是返回的已创建过的实例,如此就实现了单例构建。

实际上,提到单例模式在前端中典型应用的代表,那么就不得不提到 Redux 和 Vuex 中的 State。

在后续的课程中,我们不仅会对 vuex 与 redux 做介绍,还会实现一个基于 localStorage 全局的的存储类。

至此,关于设计模式的由来发展,和目前前端中的应用就简单介绍到这里,在后续不断的学习中,会不断的印证我们一开始说的,设计模式在前端中应用的非常广泛。

最后,对于设计模式我们不应该再抱有怀疑与偏见,认为它只是为了静态语言而准备的。请记住,设计模式是一种解决问题的思路,与语言无关。请保持兴趣,继续的学习下去。

SOLID 设计原则

“SOLID” 是由罗伯特·C·马丁在 21 世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。

设计原则是设计模式的指导理论,它可以帮助我们规避不良的软件设计。SOLID 指代的五个基本原则分别是:

  1. 单一功能原则(SRP)
  2. 开放封闭原则(OCP)
  3. 里式替换原则(LSP)
  4. 接口隔离原则(ISP)
  5. 依赖反转原则(DIP)

在 JavaScript 设计模式中,主要用到的设计模式基本都围绕“单一功能”和“开放封闭”这两个原则。

因此,我们在此仅简单介绍下这两种原则。

单一功能原则(SRP)

单一功能原则(Single Responsibility Principle,SRP)又称单一职责原则。

单一功能原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。简单的讲,即一个类的职责应该单一,不要承担过多的职责。

用餐厅的职责分配来举个例子,负责前台接待的服务员就不要去后厨上菜,负责上菜的也不要去收银柜负责收钱。

单一职责的优势在于,使得类的复杂性降低、类与类之间职责清晰,如此一来,代码可读性也会提高,同时也会更加容易维护。

在 ES6 之前,虽然 JavaScript 中并没有类这个明确概念,但对于函数而言仍然要遵循单一功能原则,即保证函数功能的单一性。

在实验环境根目录下,新建一个 assigningTask1.js 文件,引入方式一样。

请看这样一个例子,在如今的互联网产品开发部门中,开发人员、测试人员、产品经理这三类是基础的三个工种,现在我们需要为他们分配各自的任务:

// assigningTask1.js

function assigningTask(career) {
  switch (career) {
    // 开发人员
    case "Developer":
      console.log("写bug + 改bug");
      break;
    // 测试人员
    case "Tester":
      console.log("测bug + 提bug");
      break;
    // 产品经理
    case "Producter":
      console.log("提需求 + 验需求");
      break;
    default:
      console.log("其他");
  }
}

我们现在仅需要输入对应的职业即可为对应的人员分配工作。

// assigningTask1.js

assigningTask("Developer"); // 写bug + 改bug
assigningTask("Tester"); // 测bug + 提bug
assigningTask("Producter"); // 提需求 + 验需求

然而如果我们需要为开发人员增加一份工作,然后再为产品经理增加,然后再为测试人员增加,这样一直下去,assigningTask 函数就会越来越庞杂,会造成该函数的维护成本大大增加,并且很容易影响到其他职位的任务分配。

在实验环境根目录下,新建一个 assigningTask2.js 文件,引入方式一样。

现在我们遵循单一功能原则,assigningTask 仅仅对职责进行区分,具体的分配任务交给单独的函数去做,就像这样:

// assigningTask2.js

function assigningTask(career) {
  switch (career) {
    case "Developer":
      developerTask();
      break;
    case "Tester":
      testerTask();
      break;
    case "Producter":
      producterTask();
      break;
    default:
      console.log("其他");
  }
}

// 为开发人员分配任务
function developerTask() {
  console.log("写bug + 改bug");
}
// 为测试人员分配任务
function testerTask() {
  console.log("测bug + 提bug");
}
// 为产品经理分配任务
function producterTask() {
  console.log("提需求 + 验需求");
}

如此一来,无论为其中哪个职位添加功能,都不会影响到其他的职位,且代码的可读性与健壮性都得到了提升。

通过这样一个小例子,相信大家对单一功能原则能有一个较为清晰的理解,后续也会反复的涉及到这一原则。

开放封闭原则(OCP)

对扩展开放,对修改关闭,这就是开放封闭原则。

开放封闭原则主要体现在两个方面:

  1. 对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。
  2. 对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对类进行任何修改。

在此我们仍然给出一个小例子。

在实验环境根目录下,新建一个 ocpPeople1.js 文件,引入方式一样。

考虑人这个物种,人具有公共属性例如名字,年龄,性别等,以及公共的行为例如吃,工作等。为了遵循开放封闭原则,我们需要抽象出人这个概念,即:

// ocpPeople1.js

class People {
  constructor(name, age, sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
  }
  eat() {
    console.log("人可以吃东西!");
  }
  work() {
    console.log("人可以工作!");
  }
}

此时,为了区分不同的性别的具体工作内容,有两种方法。

方法一,在 People 中对 work 方法进行修改,就像这样:

// ocpPeople1.js

// ... 置换原 People 中的 work 方法
work() {
    if (this.sex === 'man') {
        console.log("男人主要负责体力活!");
    } else {
         console.log("女人主要负责家庭生活!");
    }
}
// ...

这样的方法就违背了封闭原则,即不要对类进行任何修改的原则。

(在实验环境根目录下,新建一个 ocpPeople2.js 文件。引入方式一样。

为了不违背封闭原则,且遵循开放原则,一起来看看方法二:

// ocpPeople2.js

class Man extends People {
  constructor(name, age, sex, beard) {
    super(name, age, sex);
    this.beard = beard;
  }

  work = () => {
    console.log("男人主要负责体力活!");
  };
}

class Woman extends People {
  constructor(name, age, sex, hair) {
    super(name, age, sex);
    this.hair = hair;
  }

  work = () => {
    console.log("女人主要负责家庭生活!");
  };
}

在上述代码中,我们新写了两个类分别继承 People 类,不仅可以区分不同性别的具体工作内容,还为不同性别添加了不同的属性。

我们用 Man 类来试验一下::

// ocpPeople2.js

const man = new Man("张三", 24, "男", "long beard");
man.eat(); // 人可以吃东西!
man.work(); // 男人主要负责体力活!

此时,如果要求从年龄方向来区分人,可以分为婴儿,青年,成年,老人:

// ocpPeople2.js

class Baby extends People {
  // ...
}

class Teenager extends People {
  // ...
}

class Adult extends People {
  // ...
}

class oldMan extends People {
  // ...
}

可以看到,我们无需再书写大量重复的公共属性,而集中于不同分类下各自的特有属性。

实际上,这不仅仅是对开放封闭原则的应用,其本身也是抽象工厂这一设计模式的体现。

至此,对于在 JavaScript 设计模式中主要用到的“单一功能”和“开放封闭”这两个原则就基本介绍完了。

整体内容简介

在前端中,应用较多的是以下几种模式:

  1. 设计模式创建型之工厂模式
  2. 设计模式创建型之单例模式
  3. 设计模式创建型之原型模式
  4. 设计模式结构型之装饰器模式
  5. 设计模式结构型之适配器模式
  6. 设计模式结构型之代理模式
  7. 设计模式行为型之策略模式
  8. 设计模式行为型之观察者模式

在这些模式中,还会涉及到其他的一些模式的对比学习,因此能了解的不仅仅这些模式。

当然了,这些并非全部设计模式,本课程主要是为了突出重点而精选了这些模式。相信通过学习这些模式之后,对其他的设计模式也能很快的掌握。

实验总结

本节内容主要是为大家介绍设计模式在前端领域的重要性和一些应用场景,激发大家对设计模式的学习热情。

接下来,会根据整体内容介绍中的顺序为大家逐一讲解这些模式,有兴趣的同学也可以提前在网上搜一搜相关的资料做一个预习,相信再来学习本课程的效果会更好。

最后,想告诉大家的是,学习设计模式是提升编程水平的有力基石,因此希望大家能保持兴趣,克服学习中的困难坚持下去,相信到最后一定会有所收获。

本节实验源码压缩包下载链接:设计模式学习前言源码

posted @   雨晨*  阅读(5)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示