前端常用设计模式总结

159 阅读19分钟

对于开发而言,设计模式这一词想必大家都不陌生,那什么是设计模式呢?设计模式的定义是:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。通俗一点说,设计模式是在某种场合下对某个问题的一种解决方案。如果再通俗一点说,设计模式就是给面向对象软件开发中的一些好的设计取个名字。

前端行业瞬息万变,很多人是从一开始的 JavaScript 开始写,然后写 jQuery、写 Angular,一直写到现在的 Vue/React 等等。我们发现前端的技术一直在更新在变化,新的框架层出不穷。我们需要无时无刻的在这学习。但大家现在需要冷静下来思考这么一个问题:我很拼,别人也很拼,所有人都在拼的时候,我特别的地方、或者准确地说——我的核心竞争力在哪里?能够决定一个前端工程师的本质的,不是那些瞬息万变的技术点,而是那些不变的东西

那什么是不变的东西?

所谓“不变的东西”,说的就是这种驾驭技术的能力

具体来说,它分为以下三个层次:

  • 能用健壮的代码去解决具体的问题;

  • 能用抽象的思维去应对复杂的系统;

  • 能用工程化的思想去规划更大规模的业务。

这三种能力在你的成长过程中是层层递进的关系,其中后两种能力可以说是对架构师的要求。事实上,能做到第一点并且把它做到扎实、做到娴熟的人,已经堪称同辈楷模

那前端工程师需不需要学习设计模式呢?需要!首先他是一个工程师,其次才是做前端的。工程师,就应该有工程师的修养,天下编程,形散而神不散。设计模式,虽然一般用java c++等语言编写,但是思想都是相通的。如果你学会了设计模式,就会在处理同样一个需求的时候,不管你用什么语言,就可以轻而易举的将设计模式转化为自己相应的语言代码。引用修言大佬说过得句话就是“技术人之间的口水战,每次但凡想上升一点高度,便要拿’架构‘这样高大上的话题出来晃晃眼。但事实上,很多人缺乏的并不是这种高瞻远瞩的激情,而是我们前面提到的“不变能力”中最基本的那一点——用健壮的代码去解决具体的问题的能力。这个能力在软件工程领域所对标的经典知识体系,恰恰就是设计模式。”

基础理论知识是一个人的基线,理论越强基线越高。再为自己定一个目标和向上攀附的阶梯,那么达到目标就是时间问题,而很多野路子工程师搞了半辈子也未达到优秀工程师的基线,很多他们绞尽脑汁得出的高深学问,不过是正规工程师看起来很自然的东西。—— 吴军

过去,人们对软件工程的理解比较狭隘,认为前端就是页面,和软件是两回事儿。随着前端应用复杂度的日新月异,如今的前端应用也妥妥地成为了软件思想的一种载体,而前端工程师,也被要求在掌握多重专业技能之余,具备最基本的软件理论知识。

此外,学习设计模式还有诸多好处。就比如写出更加优雅的代码,更好的重构项目,提高复杂代码的设计和开发能力,能够读懂源码学习框架等等。

所以说,想做一个靠谱的开发,先掌握设计模式

在学习之前我们需要先知道设计模式的核心思想是什么。

(1)找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起

(2)针对接口编程,而不是针对实现编程

(3)为了交互对象之间的松耦合设计而努力

总结起来就是封装变化。即将变化的代码影响最小化,将变化的代码与不变的代码分离,确保变化的部分灵活,不变的部分稳定。

这个过程,就叫“封装变化”;这样的代码,就是我们所谓的优秀代码,它可以经得起变化的考验。而设计模式出现的意义,就是帮我们写出这样的代码。

下面我们开始学习设计模式——创建型模式

创建型:工厂模式

简单工厂模式

简单工厂模式(Simple Factory Pattern):专门定义一个类(工厂类)来负责创建其他类的实例。可以根据创建方法的参数来返回不同类的实例,被创建的实例通常都具有共同的父类。

简单工厂模式又称为静态工厂方法(Static Factory Method)模式,它属于类创建型模式。

适用场景

如果我们希望将一些为数不多的类似的对象的创建和他们的创建细节分离开,也不需要知道对象的具体类型,可以使用简单工厂模式。

举个形象点的例子:在前端开发中,常常会使用外观各式各样的按钮:比如有的按钮有圆角,有的按钮有阴影,有的按钮有边框,有的按钮无边框等等。但是因为同一种样式的按钮可以出现在项目的很多地方,所以如果在每个地方都把创建按钮的逻辑写一遍的话显然是会造成代码的重复(而且由于业务的原因有的按钮的创建逻辑能比较复杂,代码量大)。

那么为了避免重复代码的产生,我们可以将这些创建按钮的逻辑都放在一个“工厂”里面,让这个工厂来根据你的需求(传入的参数)来创建对应的按钮并返回给你。这样一来,同样类型的按钮在多个地方使用的时候,就可以只给这个工厂传入其对应的参数并拿到返回的按钮即可。

举个栗子,宠物有很多种,我们目前有

class Dog { // 狗狗   constructor(name) { console.log(name) }}class Cat { // 小猫  constructor(name) { console.log(name) }}class Mouse { // 小老鼠  constructor(name) { console.log(name) }}  

我们正常需要去各个进货点去购买对应的小宠物

new Dog('Spike')new Cat('Tom')new Mouse('Jerry')

首先上述是同属一类的实例,我们如果要去各个进货点购买小宠物的话,劳累又伤神,所以我们可以去一家宠物店挑选我们需要的小宠物

class Pet {  constructor(name, variety) {    this.name = name    this.variety = variety  }}class PetFactory { // 小小的宠物店   constructor(type, variety) {    this.pet = ""    switch (type) {      case 'dog':variety = ['边牧', '哈士奇', '泰迪'];break;      case 'cat':variety = ['蓝猫', '狸猫', '加菲猫'];break;      case 'mouse':variety = ['仓鼠', '松鼠', '小白鼠'];break;      default:this.pet = '你还没有小宠物,快去买一只吧';    }    return new Pet(type, variety)  }}

简单工厂模式的代码在现实中的运用

function getFunction(path, params) { // get请求    console.log(path, params)}function postFunction(path, params) { // post请求    console.log(path, params)}function putFunction(path, params) { // put请求    console.log(path, params)}function ajaxSend(type, path, params) { // ajax发送请求    switch (type) {    case 'post': {      postFunction(path, params)      break;    };    case 'put': {      putFunction(path, params)      break;    };    default:       getFunction(path, params)  }}ajaxSend('get', 'path', 'params')

如上就是我们日常对 ajax 发送请求方法的简单封装,根据传入的 type 类型来匹配不同的发送请求的通用方法,在同一种类型的方法各自实现自己的逻辑,比如 get 请求参数放在 query 从 url 传递给后台,而 post 跟 put 的参数则是放在 body 里面发送给后台。

简单工厂模式的优点,实现对象的创建和使用的分离,创建,完全交给专门的工厂类负责。客户端程序员不需要关心怎么创建,只关心怎么使用。

缺点是简单工厂类不够灵活,如果我们新增一个产品就需要修改工厂类。就要修改他们的判断逻辑,如果产品很多的话,这个逻辑将会非常复杂上述代码,一旦有新产品要到工厂中投入生产,那就必须修改工厂类,这显然违背了面向对象设计原则中的开闭原则。(开闭原则:程序对于扩展式开放的,对于修改是封闭的)所有产品都是由同一个工厂创建,工厂类职责较重,具体产品与工厂类之间的耦合度高,严重影响了系统的灵活性和扩展性。

工厂方法模式

新增一个产品就需要修改工厂类,每当有新的产品加入,就必须要修改工厂类,需要在其中(工厂类)当中加入必要的业务逻辑,这就违反了开闭原则,没有办法做到灵活扩展 那在工厂模式中,之前的核心工厂变成了一个抽象接口。它负责给出工厂应该实现的方法,然后呢它不在负责所有产品的创建,然后将具体产品的创建工作交给字类去做,这样子就诞生了具体的子工厂。 子工厂,即子类,负责生产具体的产品对象。这样做呢,可以将产品类的实例化操作延迟到工厂子类中完成。即通过工厂子类来确定究竟应该实例化哪一个具体实例类。所以说我们有一个很重要的问题需要意识到,就是在现在的工厂模式中,如果你要新增一个产品, 你不需要要修改原有的工厂类逻辑,但是你需要新增一个工厂。

工厂方法模式简单来说就是解决简单工厂模式存在不方便添加新的类的问题,因为添加新的类以后依然需要修改工厂函数。

class Pet {  constructor(name, variety) {    this.name = name    this.variety = variety  }}const PetFactory = (() => {  const varietys = {    dog(name) {return variety = ['边牧', '哈士奇', '泰迪']},    cat(name) {return variety = ['蓝猫', '狸猫', '加菲猫']},    mouse(name) {return variety = ['仓鼠', '松鼠', '小白鼠']},    duck(name) {return variety = ['嘎嘎', '可达鸭', '柯尔鸭']},  }  return class {    constructor(name) {      let work      try {        console.log(name)        console.log(varietys)        console.log(varietys[name])        console.log(new Pet(name,varietys[name]()))        return new Pet(name,varietys[name](name))      } catch (error) {        console.log('你还没有小宠物,快去买一只吧')      }    }  }  })()  const xiaoming = new PetFactory('dog')

在工厂方法模式中,每一个具体工厂负责着一种具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或者一组重载的工厂方法。但是有时候我们需要一个工厂可以提供多个产品对象,而不是单一的产品对象。

抽象工厂模式

抽象工厂模式的成员和工厂方法模式的成员是一样的,只不过抽象工厂方法里的工厂是面向产品族的。 抽象工厂(Abstract Factory):抽象工厂负责声明具体工厂的创建产品族内的所有产品的接口。 具体工厂(Concrete Factory):具体工厂负责创建产品族内的产品。 抽象产品(Abstract Product):抽象产品是工厂所创建的所有产品对象的父类,负责声明所有产品实例所共有的公共接口。 具体产品(Concrete Product):具体产品是工厂所创建的所有产品对象类,它以自己的方式来实现其共同父类声明的接口。

大家知道一部智能手机的基本组成是操作系统(Operating System,我们下面缩写作 OS)和硬件(HardWare)组成。所以说如果我要开一个山寨手机工厂,那我这个工厂里必须是既准备好了操作系统,也准备好了硬件,才能实现手机的量产。考虑到操作系统和硬件这两样东西背后也存在不同的厂商,而我现在并不知道我下一个生产线到底具体想生产一台什么样的手机,我只知道手机必须有这两部分组成,所以我先来一个抽象类来约定住这台手机的基本组成:

class MobilePhoneFactory {// 提供操作系统的接口createOS() {  throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");}// 提供硬件的接口createHardWare() {  throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");}}

楼上这个类,除了约定手机流水线的通用能力之外,啥也不干。如果你尝试让它干点啥,比如 new 一个 MobilePhoneFactory 实例,并尝试调用它的实例方法。它还会给你报错,提醒你“我不是让你拿去new一个实例的,我就是个定规矩的”。在抽象工厂模式里,楼上这个类就是我们食物链顶端最大的 Boss——AbstractFactory(抽象工厂)。

抽象工厂不干活,具体工厂(ConcreteFactory)来干活!当我们明确了生产方案,明确某一条手机生产流水线具体要生产什么样的手机了之后,就可以化抽象为具体,比如我现在想要一个专门生产 Android 系统 + 高通硬件的手机的生产线,我给这类手机型号起名叫 FakeStar,那我就可以为 FakeStar 定制一个具体工厂:

// 具体工厂继承自抽象工厂class FakeStarFactory extends MobilePhoneFactory {createOS(a) {//可传参  // 提供安卓系统实例  return new AndroidOS()}createHardWare() {  // 提供高通硬件实例  return new QualcommHardWare()}}

这里我们在提供安卓系统的时候,调用了两个构造函数:AndroidOS 和 QualcommHardWare,它们分别用于生成具体的操作系统和硬件实例。像这种被我们拿来用于 new 出具体对象的类,叫做具体产品类(ConcreteProduct)。具体产品类往往不会孤立存在,不同的具体产品类往往有着共同的功能,比如安卓系统类和苹果系统类,它们都是操作系统,都有着可以操控手机硬件系统这样一个最基本的功能。因此我们可以用一个抽象产品(AbstractProduct)类来声明这一类产品应该具有的基本功能

 // 定义操作系统这类产品的抽象产品类class OS {  controlHardWare() {    throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');  }}​// 定义具体操作系统的具体产品类class AndroidOS extends OS {  controlHardWare() {    console.log('我会用安卓的方式去操作硬件')  }}​class AppleOS extends OS {  controlHardWare() {    console.log('我会用🍎的方式去操作硬件')  }}

硬件类产品同理:

// 定义手机硬件这类产品的抽象产品类class HardWare {// 手机硬件的共性方法,这里提取了“根据命令运转”这个共性  operateByOrder() {    throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');  }}// 定义具体硬件的具体产品类class QualcommHardWare extends HardWare {  operateByOrder() {    console.log('我会用高通的方式去运转')  }}​class MiWare extends HardWare {  operateByOrder() {    console.log('我会用小米的方式去运转')  }}

好了,如此一来,当我们需要生产一台FakeStar手机时,我们只需要这样做:

// 这是我的手机const myPhone = new FakeStarFactory()// 让它拥有操作系统const myOS = myPhone.createOS()// 让它拥有硬件const myHardWare = myPhone.createHardWare()// 启动操作系统(输出‘我会用安卓的方式去操作硬件’)myOS.controlHardWare()// 唤醒硬件(输出‘我会用高通的方式去运转’)myHardWare.operateByOrder()

关键的时刻来了——假如有一天,FakeStar过气了,我们需要产出一款新机投入市场,这时候怎么办?我们是不是不需要对抽象工厂MobilePhoneFactory做任何修改,只需要拓展它的种类:

class newStarFactory extends MobilePhoneFactory {  createOS() {    // 操作系统实现代码  }  createHardWare() {    // 硬件实现代码  }}

适用场景

一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是很重要的,用户无须关心对象的创建过程,将对象的创建和使用解耦。

系统中有多于一个的产品族,而每次只使用其中某一产品族。可以通过配置文件等方式来使得用户可以动态改变产品族,也可以很方便地增加新的产品族。

属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。同一个产品族中的产品可以是没有任何关系的对象,但是它们都具有一些共同的约束,如同一操作系统下的按钮和文本框,按钮与文本框之间没有直接关系,但它们都是属于某一操作系统的,此时具有一个共同的约束条件:操作系统的类型。

产品等级结构稳定,设计完成之后,不会向系统中增加新的产品等级结构或者删除已有的产品等级结构。

总结

优点:封装性,每个产品的实现不是高层模块要关心的,它要关心的是接口,是抽象,它不关心对象是如何创建出来的,这是由工厂类负责的,只要知道工厂类是谁,我就能创建出一个需要的对象,省时省力,优秀设计就应该如此。产品族内的约束为非公开状态。

缺点:抽象工厂模式的最大缺点就是产品族扩展非常困难,我们以通用代码为例,如果要增加一个产品C,也就是说产品家族由原来的2个增加到3个,看看我们的程序有多大改动吧!抽象类要增加一个方法,然后两个实现类都要修改,想想看,这严重违反了开闭原则,而且我们一直说明抽象类和接口是一个契约。改变契约,所有与契约有关系的代码都要修改,那么这段代码叫什么?叫“有毒代码”,——只要与这段代码有关系,就可能产生侵害的危险!

创建型:单例模式

在 JavaScript 中,单例模式可以用来确保一个类只有一个实例,并提供全局访问点来访问该实例。以下是一些 JavaScript 单例模式的实现方法:

  1. 字面量对象(Literal Object):使用字面量对象创建单例,可以确保该对象只被创建一次。

    var mySingleton = { prop1: "some property", prop2: "another property", method: function() { console.log("some method"); }};

  2. 构造函数(Constructor):使用构造函数创建单例,可以在构造函数内部添加单例属性。

    function MySingleton() { if (MySingleton.instance) { return MySingleton.instance; }​ MySingleton.instance = this; this.prop1 = "some property"; this.prop2 = "another property"; this.method = function() { console.log("some method"); };}​var singleton1 = new MySingleton();var singleton2 = new MySingleton();console.log(singleton1 === singleton2); // true

  3. 模块模式(Module Pattern):使用闭包和立即执行函数创建单例,可以隐藏单例属性和方法。

    var mySingleton = (function() { var instance;​ function init() { return { prop1: "some property", prop2: "another property", method: function() { console.log("some method"); } }; }​ return { getInstance: function() { if (!instance) { instance = init(); } return instance; } };})();​var singleton1 = mySingleton.getInstance();var singleton2 = mySingleton.getInstance();console.log(singleton1 === singleton2); // true

这些实现方法都可以创建 JavaScript 单例,但各有优缺点。字面量对象简单易懂,但无法使用构造函数和继承。构造函数可以使用继承,但需要手动检查实例是否已存在。模块模式可以隐藏实现细节和私有变量,但可能无法扩展。开发者应根据具体情况选择适合的实现方法。

下面是几个 JavaScript 单例模式的应用场景和代码示例:

  1. 管理共享资源

单例可以用于管理共享资源,例如全局配置、数据库连接、日志记录等。以下是一个用于管理全局配置的单例代码示例:

var Config = (function() {  var instance;​  function createInstance() {    var config = {      appName: "My App",      apiUrl: "https://example.com/api",      debug: true    };    return config;  }​  return {    getInstance: function() {      if (!instance) {        instance = createInstance();      }      return instance;    }  };})();​var config = Config.getInstance();console.log(config.appName); // "My App"console.log(config.apiUrl); // "https://example.com/api"console.log(config.debug); // true
  1. 模块化开发

单例可以用于模块化开发,以便在应用程序中只有一个实例。以下是一个用于管理模块的单例代码示例:

var Module = (function() {  var instance;​  function createInstance() {    var module = {      // 模块的方法和属性      method1: function() {        console.log("method 1");      },      method2: function() {        console.log("method 2");      },      prop1: "property 1",      prop2: "property 2"    };    return module;  }​  return {    getInstance: function() {      if (!instance) {        instance = createInstance();      }      return instance;    }  };})();​var module = Module.getInstance();module.method1(); // "method 1"module.method2(); // "method 2"console.log(module.prop1); // "property 1"console.log(module.prop2); // "property 2"
  1. 缓存数据

单例可以用于缓存数据,例如从服务器获取数据后,将数据缓存在客户端,以便在应用程序中共享访问。以下是一个用于管理缓存的单例代码示例:

var Cache = (function() {  var instance;​  function createInstance() {    var cache = {};​    function set(key, value) {      cache[key] = value;    }​    function get(key) {      return cache[key];    }​    return {      set: set,      get: get    };  }​  return {    getInstance: function() {      if (!instance) {        instance = createInstance();      }      return instance;    }  };})();​var cache = Cache.getInstance();cache.set("key1", "value1");cache.set("key2", "value2");console.log(cache.get("key1")); // "value1"console.log(cache.get("key2")); // "value2"

这些示例只是单例模式在 JavaScript 中的几个应用场景,实际上单例模式还有很多其他的应用场景。开发者可以根据具体情况选择适合的应用场景,并选择适合的实现方法。

创建型 原型模式

JavaScript 原型模式(Prototype Pattern)是一种创建型设计模式,它允许通过克隆现有对象来创建新对象,而无需显式指定要创建的类。它利用了 JavaScript 中的原型继承机制,通过克隆原型对象来创建新对象。

原型模式通常包括以下几个组成部分:

Prototype(原型):原型是一个已经存在的对象,作为新对象的原型。所有从原型克隆而来的对象都具有原型对象的属性和方法。 Clone(克隆):克隆是用于创建新对象的方法,它通过克隆原型对象来创建新对象。 下面是一个简单的 JavaScript 原型模式示例代码:

 和 car2,并为它们设置不同的 make 和 model 属性。由于 car1 和 car2 都是基于 carPrototype 克隆而来的,因此它们都具有 carPrototype 对象的属性和方法。

在上面的示例中,carPrototype 是原型对象,它定义了一个车辆的基本属性和方法。通过使用 Object.create() 方法,我们可以创建基于原型对象的新对象 car1 和 car2,并为它们设置不同的 make 和 model 属性。由于 car1 和 car2 都是基于 carPrototype 克隆而来的,因此它们都具有 carPrototype 对象的属性和方法。