前端于我
前端|设计模式

从设计模式开始

每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该方案而不必做重复劳动。 —— Christopher Alexander

设计模式的基本原则(SOLID)

  1. 单一功能原则(Single Responsibility Principle)
  2. 开放封闭原则 (Opened Closed Principle)
  3. 里式替换原则 (Liskov Substitution Principle)
  4. 接口隔离原则 (Interface Segregation Principle)
  5. 依赖反转原则 (Dependency Inversion Principle)

以上是五个设计模式的基本原则,另外还有七大基本原则的说法,除了以上五大原则之外,还有迪米特法则(Law Of Demeter)组合/聚合复用原则 (Composite/Aggregate Reuse Principle)

设计模式的核心思想–封装变化, 将变与不变分离,确保变化的部分灵活,不变的部分稳定,即健壮的代码。

单一功能原则

单一功能原则表示一个模块的组成元素之间的功能相关性,简单的来说,即一个类只负责一项职责,保持其独立性与纯粹性。

开放封闭原则

开放封闭原则表示软件实体(类,函数,模块等)应该可以被拓展,但是不可被修改。

  1. 能拓展已存在的系统,能够提供新的功能满足新的需求,应拥有很强的适应性与灵活性。
  2. 已存在的模块,特别是那些重要的抽象模块或核心模块,不需要被修改,应拥有很强的稳定性和持久性。

里氏替换原则

里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。

假设存在功能A1,由类A完成,现在需要对A1进行扩展,扩展后的功能为B1。那么功能B1类A的子类B完成,但是子类B在完成新功能B1时可能修改类A的功能,导致功能A1发生故障。

里氏替换原则告诉我们,当使用继承的时候,尽量不要修改父类方法的预期行为。

接口隔离原则

接口隔离原则指,软件实体不应该依赖它不需要的接口,一个类与另一个类之间的依赖应该建立在最小的接口上。

假设有一个抽象类System,其拥有一个正常系统拥有的所有抽象方法,比如network,bluetooth,camera等。此时有一个电子手表的类需要通过继承Ststem进行实现,但这就必须实现一个手表不具有的功能,导致不必要的依赖。这就不符合最小接口依赖的原则。更好的做法是细化接口,接口中的方法尽可能少。但是凡是都有个度,接口细分过细会导致接口数量过多,是的设计复杂化,重要的是掌握适度。

依赖反转原则

依赖反转原则指高层模块不应该依赖底层模块,两者都应该是抽象的。或者说是抽象不应该依赖于细节,细节应该依赖抽象。

假设有一个打印机类,其存在一个打印方法,可以打印word文档。但是当你需要打印一份Excel文档时,发现其无法打印,因为它只实现了Word文档打印。

class Word {
  getContent() {
    return 'word文档内容'
  }
}

class Printer {
  print(word) {
    System.print(word.getContent())
  }
}

而依照依赖反转原则,Word文档与Excel文档,甚至是书本,Pdf等实际类应该继承自统一的抽象类,都应实现统一的抽象方法。所以遵循依赖反转原则可以降低类之间的耦合性,提高系统的稳定性,降低修改系统的风险。

迪米特原则

迪米特原则又称最少知道原则,它表示一个对象应该对其他对象保持最少得了解。通俗来说,迪米特原则有些类似于接口隔离原则,但是其指的是两个互相独立的类或对象之间应该保持最低程度的耦合。

组合/聚合复用原则

组合/聚合复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分; 新的对象通过向这些对象的委派达到复用已有功能的目的。

在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生了改变,则子类的实现也不得不改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。

总体说来,组合/聚合复用原则告诉我们:组合或者聚合好过于继承。

23种设计模式

  1. 创建型
  1. 结构型
  1. 行为型

模式详解

构造器模式

其实在前端开发中,构造器模式可以很简单的理解为类,如下代码就是一个构造器

function User(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
}

构造器解决了相似的重复的问题,比如上面的User构造函数,当需要生成多个不同的用户信息时,可以通过类快速的构造出多个实例。

这也就是上面提到的设计模式的核心思想–封装变化,将变与不变分离。

工厂模式

工厂模式是对创建对象的过程单独封装,例如:

function Factory(name, age, career) {
  let work;
  switch(career) {
    case 'coder':
      work = ['写代码', '写代码', '写代码'];
      break;
    case 'disigner':
      work = ['切图', '切图', '切图'];
      break;
    // more ...
  }
  return new User(name, age, career, work);
}

可以从上面代码很直观的看出,工程模式则是对构造器满足不了变化的数据时的进一步封装,是对于创建对象的过程的单独封装。

抽象工厂模式

上面的工厂模式乍一看没什么问题,但是仔细思考就会发现不妥。当我们的加入的work越多,其Fatory就会越庞大。这是因为没有遵循遵守开放封闭原则

这里使用Typescript(JS中没有抽象类这个概念), 我们可以用抽象工厂模式的思想进行改写:

abstract class Men {
  abstract info(): any;
  abstract work(): any;
}

上面代码声明了一个抽象类Men, 假设每个人都具有这个类里面定义的两个属性,但是这两个属性具体长什么样因人而异。这就是一个抽象工厂。

class User {
  say () {
    console.log('我简单讲两句!')
  }
}

class Boss extends Men {
  info () {
    return new User()
  }

  work () {
    return new BossJob()
  }
}

class Coder extends Men {
  info () {
    return new User()
  }

  work () {
    return new CoderJon()
  }
}

然后假设有两个角色,分别是BossCoder,继承自抽象类Men,也就必须实现两个基础方法(抽象方法)。其中基础属性info不做特异化处理,这里简化为仅work具有差异。

abstract class Job {
  abstract todo(): void
}

class BossJob extends Job {
  todo () {
    console.log('吃饭睡觉打豆豆')
  }
}

class CoderJob extends Job {
  todo () {
    console.log('吃饭睡觉打代码')
  }
}

最终实现抽象类Job以及两个具体类(具体工厂)。可以发现整个抽象工厂的示例其实在于抽取其共性,比如人都有两个属性,工作都有一个具体的描述。

抽象工厂本质上处理的其实也是类,但是是一帮非常棘手、繁杂的类,这些类中不仅能划分出门派,还能划分出等级,同时存在着千变万化的扩展可能性。

在实例中,由于基础属性是不变的,如果某天程序员的工作内容变化了,那也只需要变更具体的CoderJob类,而抽象类由于定义的是共性的基础的属性,不需要改变。

抽象工厂模式的定义,是围绕一个超级工厂创建其他工厂

单例模式

保证一个类仅有一个实例,并提供一个访问它的全局访问点,这就叫单例模式。

在前端实际工作中,单例模式也常有应用。比如实现一个全局的事件发布订阅,可能会有如下的实现:

class EventBus {
  eventMap

  constructor () {
    this.eventMap = new Map()
  }

  addListener (key: string, callback: Function) {
    if (!this.eventMap.has(key)) {
      this.eventMap.set(key, [callback])
    } else {
      const eventList = this.eventMap.get(key)
      eventList.push(callback)
    }
  }

  removeListener (key: string, callback: Function) {
    const eventList = this.eventMap.get(key)
    if (eventList && eventList.length > 0) {
      this.eventMap.set(key, eventList.filter((item: Function) => item !== callback))
    }
  }

  emit (key: string) {
    const eventList = this.eventMap.get(key)
    if (eventList && eventList.length > 0) {
      eventList.forEach((callback: Function) => {
        callback()
      })
    }
  }
}

const eventBus = new EventBus()

export default eventBus

如上代码,仅暴露出了EventBus的一个实例,全局都访问这个单实例,这就是一个简单的单例模式。又或者像Redux/Vuex此类应用较为广泛的状态管理库,也是使用单例模式去保证全局访问的是同一实例。

原型模式

原型编程范式的核心思想就是利用实例来描述对象,用实例作为定义对象和继承的基础。在 JavaScript 中,原型编程范式的体现就是基于原型链的继承

在 JavaScript 中,每个构造函数都拥有一个prototype属性,它指向构造函数的原型对象,这个原型对象中有一个 constructor 属性指回构造函数;每个实例都有一个__proto__属性,当我们使用构造函数去创建实例时,实例的__proto__属性就会指向构造函数的原型对象。

而当我们访问一个对象属性时,如果当前对象实例没有该属性,则会沿着__proto__属性逐级向上查找,这通过__proto__串联的多个原型对象就被叫做原型链。

所以在JavaScript中,并不需要刻意的去使用原型模式。

装饰器模式

装饰器模式的定义是“在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求”。

装饰器的使用有些类似于React中常见的高阶函数,实际上原先的全局状态管理库Mobx就提供了装饰器API用以于React组件进行数据绑定。

装饰类:

// 使用装饰器装饰类
@testable
class MyTestableClass {
  // ...
}

// 定义装饰器
function testable(target) {
  target.isTestable = true;
}

// 成功为类增加了一个属性isTestable
MyTestableClass.isTestable // true

装饰类属性:

class Math {
  @log
  add(a, b) {
    return a + b;
  }
}
function log(target, name, descriptor) {
  var oldValue = descriptor.value;
  descriptor.value = function() {
    console.log(`Calling ${name} with`, arguments);
    return oldValue.apply(this, arguments);
  };
  return descriptor;
}
const math = new Math();
// passed parameters should get logged now
math.add(2, 4);

可以直观的看到,装饰器可以在不修改旧逻辑代码的同时增强与拓展类与其属性。但是由于装饰器目前还是一个处于第二阶段(Stage 2)的提案(还没成为标准),所以一些本来提供装饰器API的库,如Mobx,为了更符合标准选择暂时放弃装饰器的API。所以目前的浏览器以及Node都不支持装饰器语法,需要安装babel进行转码。

适配器模式

适配器模式在前端开发工作中很常见,比如请求方法封装时,如果存在多个后端服务,为了使用方便,常见的方法是将需要调用哪个后端服务隐藏到封装的底层,暴露出去的请求方法入参是统一的。

async function request(url, options) {
  if (url.startsWith('/java-api/')) {
    return javaRequest(url, options)
  }
  if (url.startsWith('/php-api/')) {
    return phpRequest(url, options)
  }
  throw new Error('未知服务')
}

俗话说的好,没有什么是加一层逻辑解决不了的,如果不行,那就再加一层。

而常见的多端开发框架中,此类适配器就更常见了,如ReactNative/UniApp/Taro之类多端框架,由于不同端的底层API不一致,所以往往需要在实现逻辑是注意底层的适配,所以也就要经常使用到适配器模式了。

代理模式

代理模式对于前端开发来说也算是耳熟能详了,毕竟国内最常用的前端框架之一Vue.js核心双向数据代理就是基于代理思想实现的。

而除了Vue.js的代理,常见的还有事件代理,网络代理等。由于其实现比较常见和简单,就不再展开。

策略模式

策略模式的定义是: 定义一系列的算法, 将逻辑封装起来, 并且使它们可相互替换。

概念很抽象难以理解,请看VCR(不是)。假设有一个根据不同活动类型返回商品价格的场景,一把梭拿起键盘就是干的老王很快写出了如下代码:

function getPrice(type, originPrice) {
  if (type === 'pre') {
    // 预售商品
    if (originPrice >= 100) {
      return originPrice - 20
    }
    return originPrice * 0.9
  }
  if (type === 'onSale') {
    // 促销商品
    if (originPrice >= 100) {
      return originPrice - 30
    }
    return originPrice * 0.8
  }
  if (type === 'clear') {
    // 清仓商品
    if (originPrice >= 100) {
      return originPrice - 40
    }
    return originPrice * 0.7
  }
  return originPrice
}

很明显可以看出,以上代码缺乏设计模式作为指导思想,后期随着业务场景(if else)增加,会变得难以维护。

基于设计模式的单一功能与开放封闭原则,重写以上代码:

const priceMap = {
  pre(price) {
    if (originPrice >= 100) {
      return originPrice - 20
    }
    return originPrice * 0.9
  },
  onSale(price) {
    if (originPrice >= 100) {
      return originPrice - 30
    }
    return originPrice * 0.8
  },
  clear(price) {
    if (originPrice >= 100) {
      return originPrice - 40
    }
    return originPrice * 0.7
  }
}

function getPrice(type, originPrice) {
  return priceMap[type] ? priceMap[type](originPrice) : originPrice
}

以上,通过一个活动价映射对象,将if/else逻辑抽离收敛,后续增加或删改对应类型价格,仅需要修改对应活动映射属性。做到了对价格函数的简单封装,应易于替换使用。

状态模式

状态模式允许一个对象在其内部状态改变时改变它的行为。

状态模式实际上与上面的策略模式十分类似,区别在于策略模式更讲究逻辑的独立,类似于纯函数,而状态模式在于其“状态”,要类比的话,就像一个是函数组件的hooks,一个是类组件的属性方法。

就拿上面的获取价格的例子改造成状态模式,则类似于如下的定义:

class GoodsPrice {
  constructor(price) {
    this.price = price
    this.type = 'normal'
  }

  setType(type) {
    this.type = type
  }

  priceMap = {
    pre() {},
    onSale() {},
    clear() {}
  }

  getPrice() {
    if (this.priceMap[this.type]) {
      return this.priceMap[this.type]()
    }
    return this.price
  }
}

以上,首先其需要有一个状态,其次,当状态改变时,其行为也需要改变。这就是状态模式。

观察者模式

观察者模式有一个别名–发布-订阅模式。这在前端中应用广泛,并且也是面试中时长碰见的一种模式。

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。

最为人耳熟能详的大概就是Vue的底层是由双向数据绑定,以及发布订阅模式。简单来说,当渲染函数访问响应式数据时,该渲染函数将被收集到响应式对象的副作用函数储存桶中,这就是订阅,而当响应式数据发生变化时会将该对象的所有副作用从储存桶中取出并执行,这也就是发布。

除此之外,例如单例模式的例子EventBus,原生DOM事件,IntersectionObserver,MutationObserver此类实现一对多的监听发布功能的都能认为是观察者模式的应用。

但是实际上,观察者模式与发布-订阅模式是存在一点区别的。观察者模式中,被观察者与观察者之间是一对多的关系,即一个被观察者可以有多个观察者。而发布-订阅模式之间是一对多的关系,即一个发布者可以有多个订阅者,但是发布者与订阅者之间没有直接联系。

迭代器模式

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》

在ES6之前的jQuery时代,要遍历一个DOM节点列表常见做法是使用JQ的$.each方法,这就可以理解为早期JS中的一种迭代器实现,因为其磨平了几种类型集合的差异,提供了一种方法顺序访问聚合对象中的各个元素。

const arr = [1, 2, 3]
const aNodes = document.getElementsByTagName('a')
const jQNodes = $('a')

$.each(arr, function (index, item) {})
$.each(aNodes, function (index, aNode) {})
$.each(jQNodes, function (index, jQNode) {})

而ES6推出了新的迭代器标准(Iterator)。任何数据结构只要具备Symbol.iterator属性,就能被迭代器遍历。如Array、Map、Set、String、TypedArray、函数的 arguments 对象、NodeList 对象。而常用的for/of循环的背后正是对迭代器的next方法的反复调用。

如以下方法调用是等价的:

const arr = [1, 2, 3]
const len = arr.length
for(item of arr) {
    console.log(`当前元素是${item}`)
}

// 等同于
const arr = [1, 2, 3]
// 通过调用iterator,拿到迭代器对象
const iterator = arr[Symbol.iterator]()

// 对迭代器对象执行next,就能逐个访问集合的成员
iterator.next()
iterator.next()
iterator.next()

正式由于各框架和语言在底层都提供了迭代器的实现,所以在实际工作中几乎没有需要手写迭代器,或是应用迭代器模式的场景。但是这也恰恰说明了迭代器模式的重要性,对于其模式设计,底层封装仍需要着重去理解。

总结

之前看设计模式总是云里雾里,似乎离我很远,但是此时再仔细梳理一遍就发现这些都是由无数前辈实践出来的经验之谈。讨论如何对千变万化的逻辑进行封装归类,封装变化,以不变应万变的编程范式。

并且从中也逐渐品味出自身不足,对于基础掌握还远远不够,在工作实践中也还需更加细心钻研,回顾品味,总结提炼。切忌编程一把梭,一个好的架构设计会让开发工作事半功倍。

自善其身,静待风来,诸君共勉。

发表于: 2023-10-28