Fork me on GitHub

结合实际案例讲解前端设计模式

1. 什么是设计模式?

设计模式是一套被反复使用、多数人知晓、经过分类编目的、代码设计经验的总结。它是为了可重用代码,让代码更容易的被他人理解并保证代码的可靠性。
就像是在做数学题时,已知直角三角形两边长,求另一边,我们会直接用勾股定理,而不会去证明为什么勾股定理是成立的。所以,设计模式实际上是“拿来主义”在软件领域的贯彻实践,它是一套现成的工具,拿来即用。下面来看一下设计模式的设计原则。
 

2. 设计原则概述

设计原则是设计模式的指导理论,它可以帮助我们规避不良的软件设计。设计模式遵循SOLID原则,SOLID 指代的五个基本原则分别是:
●单一职责原则(Single Responsibility Principle)
开放封闭原则指的就是对扩展开放、对修改关闭。编写代码的时候不可避免地会碰到修改的情况,而遵循开闭原则就意味着当代码需要修改时,可以通过编写新的代码来扩展已有的代码,而不是直接修改已有代码本身。
●开放封闭原则(Opened Closed Principle)
应该有且仅有一个原因引起类的变更。这个原则很好理解,一个类代码量越多,功能就越复杂,维护成本也就越高。遵循单一职责原则可以有效地控制类的复杂度。
●里式替换原则(Liskov Substitution Principle)
里氏替换原则是指在使用父类的地方可以用它的任意子类进行替换。里氏替换原则是对类的继承复用作出的要求,要求子类可以随时替换掉其父类,同时功能不被破坏,父类的方法仍然能被使用。
●接口隔离原则(Interface Segregation Principle)
不应该依赖它不需要的接口,也就是说一个类对另一个类的依赖应该建立在最小的接口上。目的就是为了降低代码之间的耦合性,方便后续代码修改。
●依赖反转原则(Dependency Inversion Principle)
准确说应该是避免依赖倒置,好的依赖关系应该是类依赖于抽象接口,不应依赖于具体实现。这样设计的好处就是当依赖发生变化时,只需要传入对应的具体实例即可。
●最少知识原则(The Least Knowledge Principle)
 一个类对于其他类知道得越少越好,就是说一个对象应当对其他对象尽可能少的了解。这一条原则要求任何一个对象或者方法只能调用该对象本身和内部创建的对象实例,如果要调用外部的对象,只能通过参数的形式传递进来。这一点和纯函数的思想相似。
 

3.设计模式3种类型(可以跳过)

(1)创建型

创建型模式的主要关注点是“如何创建和使用对象”,这些模式的核心特点就是将对象的创建与使用进行分离,从而降低系统的耦合度。使用者不需要关注对象的创建细节,对象的创建由相关的类来完成。

具体包括下面几种模式:
●抽象工厂模式,提供一个超级工厂类来创建其他工厂类,然后通过工厂类创建类实例;
●构造器模式,将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象;
●工厂模式,定义一个用于创建生成类的工厂类,由调用者提供的参数决定生成什么类实例;
●原型模式,将一个对象作为原型,通过对其进行克隆创建新的实例;
●单例模式,生成一个全局唯一的实例,同时提供访问这个实例的函数。
 

(2)结构型

结构型模式描述如何将类或对象组合在一起形成更大的结构。它分为类结构型模式和对象结构型模式,类结构型模式采用继承机制来组织接口和类,对象结构型模式釆用组合或聚合来生成新的对象。

具体包括下面几种模式:
●适配器模式,将一个类的接口转换成另一个类的接口,使得原本由于接口不兼容而不能一起工作的类能一起工作;
●桥接模式,将抽象与实现分离,使它们可以独立变化,它是用组合关系代替继承关系来实现的,从而降低了抽象和实现这两个可变维度的耦合度;
●组合模式,将对象组合成树状层次结构,使用户对单个对象和组合对象具有一致的访问性;
●装饰器模式,动态地给对象增加一些职责,即增加其额外的功能;
●外观模式,为多个复杂的子系统提供一个统一的对外接口,使这些子系统更加容易被访问;
●享元模式,运用共享技术来有效地支持大量细粒度对象的复用;
●代理模式,为某对象提供一种代理以控制对该对象的访问,即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。
 

(3)行为型

行为型模式用于描述程序在运行时复杂的流程控制,即描述多个类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,它涉及算法与对象间职责的分配。

行为型模式分为类行为模式和对象行为模式,类的行为模式采用继承机制在子类和父类之间分配行为,对象行为模式采用多态等方式来分配子类和父类的职责。

具体包括下面几种模式:
●责任链模式,把请求从链中的一个对象传到下一个对象,直到请求被响应为止,通过这种方式去除对象之间的耦合;
●命令模式,将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开;
●策略模式,定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的改变不会影响使用算法的用户;
●解释器模式,提供如何定义语言的文法,以及对语言句子的解释方法,即解释器;
●迭代器模式,提供一种方法来顺序访问聚合对象中的一系列数据,而不暴露聚合对象的内部表示;
●中介者模式,定义一个中介对象来简化原有对象之间的交互关系,降低系统中对象间的耦合度,使原有对象之间不必相互了解;
●备忘录模式,在不破坏封装性的前提下,获取并保存一个对象的内部状态,以便以后恢复它;
●观察者模式,多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为;
●状态模式,类的行为基于状态对象而改变;
●访问者模式,在不改变集合元素的前提下,为一个集合中的每个元素提供多种访问方式,即每个元素有多个访问者对象访问;
●模板方法模式,定义一个操作中的算法骨架,将算法的一些步骤延迟到子类中,使得子类在可以不改变该算法结构的情况下重定义该算法的某些特定步骤。
 
 

 4.前端常用到的主要有以下几种:

●单例模式
●工厂模式
●原型模式
●状态模式
●策略模式
●代理模式
●装饰器模式
●适配器模式
●迭代器模式
●观察者模式/发布订阅模式
 

单例模式

1. 什么是单例模式? 

单例模式 (Singleton Pattern)又称为单体模式,保证一个类只有一个实例,并提供一个访问它的全局访问点。也就是说,第二次使用同一个类创建新对象的时候,应该得到与第一次创建的对象完全相同的对象。

2. 单例模式的实现

常见的单例(而非单例模式的应用):
●浏览器中的 window 和 document 全局变量,这两个对象都是单例,任何时候访问他们都是一样的对象,window 表示包含 DOM 文档的窗口,document 是窗口中载入的 DOM 文档,分别提供了各自相关的方法。
●在 ES6 新增语法的 Module 模块特性,通过 import/export 导出模块中的变量是单例的,也就是说,如果在某个地方改变了模块内部变量的值,别的地方再引用的这个值是改变之后的。、
●项目中的全局状态管理模式 Vuex 维护的全局状态,vue-router维护的路由实例,在单页应用的单页面中都属于单例的应用(但不属于单例模式的应用)。
 
 
在实现单例模式的时候,需要注意以下几点:
●访问时始终返回的是同一个实例;
●自行实例化,无论是一开始加载的时候就创建好,还是在第一次被访问时;
●一般还会提供一个 getInstance 方法用来获取它的实例;
 
结构大概如下图

具体实现:
这里最关键的就是要让构造函数具备判断自己是否已经创建过一个实例的能力。我们将他写成一个单独的函数getInstance:
 
class SingleCase {
    show() {
        console.log('我是一个单例对象')
    }
    static getInstance() {
        // 判断是否已经new过1个实例
        if (!SingleCase.instance) {
            // 若这个唯一的实例不存在,那么先创建它
            SingleCase.instance = new SingleCase()
        }
        // 如果这个唯一的实例已经存在,则直接返回
        return SingleCase.instance
    }
}
const s1 = SingleCase.getInstance()
const s2 = SingleCase.getInstance()
s1 === s2    // true
 除了上面这种实现方法之外,getInstance的逻辑还可以用闭包来实现:
SingleCase.getInstance = (function() {
    // 定义自由变量instance,模拟私有变量
    let instance = null
    return function() {
        // 判断自由变量是否为null
        if(!instance) {
            // 如果为null则new出唯一实例
            instance = new SingleCase()
        }
        return instance
    }
})()

 

3. Vue中的单例模式

(1)Element UI
Element UI是使用Vue开发的一个前端UI框架。ElementUI 中的全屏 Loading 蒙层调用有两种形式:
●指令形式:Vue.use(Loading.directive)
●服务形式:Vue.prototype.$loading = service

指令形式注册的使用方式 :
<div :v-loading.fullscreen="true">...</div>;

服务形式注册的使用方式 :

this.$loading({ fullscreen: true });

 

用服务方式使用全屏 Loading 是单例的,即在前一个全屏 Loading 关闭前再次调用全屏 Loading,并不会创建一个新的 Loading 实例,而是返回现有全屏 Loading 的实例。

下面是 ElementUI 实现全屏 Loading 的源码:

import Vue from 'vue'
import loadingVue from './loading.vue'
const LoadingConstructor = Vue.extend(loadingVue)
let fullscreenLoading
const Loading = (options = {}) => {
    if (options.fullscreen && fullscreenLoading) {
        return fullscreenLoading
    }
    let instance = new LoadingConstructor({
        el: document.createElement('div'),
        data: options
    })
    if (options.fullscreen) {
        fullscreenLoading = instance
    }
    return instance
}
export default Loading

这里的单例是 fullscreenLoading,是存放在闭包中的,如果用户传的 options 的 fullscreen 为 true 且已经创建了单例,则直接返回之前创建的单例,如果之前没有创建过,则创建单例并赋值给闭包中的 fullscreenLoading 后返回新创建的单例实例。

(2)Vuex

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。Vuex,它们都实现了一个全局的 Store 用于存储应用的所有状态。这个 Store 的实现,正是单例模式的典型应用。


Vuex 使用单一状态树,用一个对象就包含了全部的应用层级状态。至此它便作为一个“唯一数据源 (SSOT)”而存在。这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。 ——Vuex官方文档

在Vue中,组件之间是独立的,组件间通信最常用的办法是 props(限于父子组件通信),稍微复杂一点的(比如兄弟组件通信)可以通过自己实现简单的事件监听函数来解决。但当组件非常多、组件间关系复杂、且嵌套层级很深的时候,这种原始的通信方式会使我们的逻辑变得复杂难以维护。这时最好的做法是将共享的数据抽出来、放在全局,供组件们按照一定的的规则去存取数据,保证状态以一种可预测的方式发生变化。于是便有了 Vuex,这个用来存放共享数据的唯一数据源,就是 Store。

那Vuex是如何确保Store的唯一性的呢?

首先,先来看看如何在项目中引入 Vuex:

// 安装vuex插件
Vue.use(Vuex)
// 将store注入到Vue实例中
new Vue({
    el: '#app',
    store
})

通过调用Vue.use()方法,安装了 Vuex 插件。Vuex 插件是一个对象,它在内部实现了一个 install 方法,这个方法会在插件安装时被调用,从而把 Store 注入到Vue实例里去。也就是说每 install 一次,都会尝试给 Vue 实例注入一个 Store。
在 install 方法里,有一段逻辑和上面的 getInstance 的逻辑非常相似:

let Vue // 这个Vue的作用和楼上的instance作用一样
...
export function install (_Vue) {
  // 判断传入的Vue实例对象是否已经被install过Vuex插件(是否有了唯一的state)
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  // 若没有,则为这个Vue实例对象install一个唯一的Vuex
  Vue = _Vue
  // 将Vuex的初始化逻辑写进Vue的钩子函数里
  applyMixin(Vue)
}

这就是 Vuex 源码中单例模式的实现办法了,套路可以说和上面的getInstance如出一辙。通过这种方式,可以保证一个 Vue 实例(即一个 Vue 应用)只会被 install 一次 Vuex 插件,所以每个 Vue 实例只会拥有一个全局的 Store。

4. 单例模式的优缺点

单例模式主要解决的问题就是节约资源,保持访问一致性。

单例模式优点如下:
●节约开支,提高性能: 单例模式在创建后在内存中只存在一个实例,节约了内存开支和实例化时的性能开支,特别是需要重复使用一个创建开销比较大的类时,比起实例不断地销毁和重新实例化,单例能节约更多资源,比如数据库连接;
●解决资源多重占用: 单例模式可以解决对资源的多重占用,比如写文件操作时,因为只有一个实例,可以避免对一个文件进行同时操作;
●提高系统流畅度: 只使用一个实例,也可以减小垃圾回收机制 GC(Garbage Collecation) 的压力,表现在浏览器中就是系统卡顿减少,操作更流畅,CPU 资源占用更少;

单例模式缺点如下:
●对扩展不友好:一般不容易扩展,因为单例模式一般自行实例化,没有接口;
●与单一职责原则冲突:一个类应该只关心内部逻辑,而不关心外面怎么样来实例化;

 

5. 单例模式的使用场景

单例模式的使用场景:
●当一个类的实例化过程消耗的资源过多,可以使用单例模式来避免性能浪费;
●当项目中需要一个公共的状态,那么需要使用单例模式来保证访问一致性;

 

工厂模式

1. 什么是工厂模式?

工厂模式就是根据不用的输入返回不同的实例,一般用来创建同一类对象,它的主要思想就是将对象的创建与对象的实现分离。
在创建对象时,不暴露具体的逻辑,而是将逻辑封装在函数中,那么这个函数就可以被视为一个工厂。工厂模式根据抽象程度的不同可以分为:简单工厂、工厂方法、抽象工厂。

2. 工厂模式的实现方式

下面就分别看一下简单工厂模式、工厂方法模式、抽象工厂模式这三种工厂模式的实现方式。

(1)简单工厂模式
简单工厂模式又叫静态工厂模式,由一个工厂对象决定创建某一种产品对象类的实例。主要用来创建同一类对象。

下面来看一个权限管理的例子,需要根据用户的权限进行页面的渲染。所以,在不同用户权限等级的构造函数中,需要保存该用户可以访问到的页面,再根据权限进行实例化用户。

在ES6中,这里不再使用构造函数来创建对象,而是使用ES6中的class关键字来创建类,并使用static关键字将简单工厂封装到User类的静态方法下:

//User类
class User {
  //构造器
  constructor(opt) {
    this.name = opt.name;
    this.viewPage = opt.viewPage;
  }
  //静态方法
  static getInstance(role) {
    switch (role) {
      case 'superAdmin':
        return new User({ name: '超级管理员', viewPage: ['首页', '应用数据', '权限管理'] });
        break;
      case 'admin':
        return new User({ name: '管理员', viewPage: ['首页', '应用数据'] });
        break;
      case 'user':
        return new User({ name: '普通用户', viewPage: ['首页'] });
        break;
      default:
        throw new Error('参数错误, 可选参数:superAdmin、admin、user')
    }
  }
}
// 实例化对象
let superAdmin = User.getInstance('superAdmin');
let admin = User.getInstance('admin');
let normalUser = User.getInstance('user');

User就是一个简单工厂,在该函数中有3个实例中分别对应不同的权限的用户。当调用工厂函数时,只需要传递superAdmin, admin, user这三个可选参数中的一个获取对应的实例对象。

简单工厂模式的优势就在于,只需要一个参数,就可以获得所需的对象,无需知道对象创建的具体细节。但是,在函数内部包含了对象所有的创建逻辑,和判断逻辑的代码,如果判断逻辑很多,或者代码逻辑很复杂,这样工厂函数就会变的很复杂,很庞大,难以维护。所以,简单工厂只适合以下情况:
●创建的对象数量较少;
●创建的对象的逻辑不是很复杂。

(2)工厂方法模式

工厂方法模式本意是将实际创建对象的工作放在子类中,这样核心类就变成了抽象类。但是在JavaScript中,我们无法像传统面向对象语言那样去实现创建类,所以,只要遵循它的主要思想即可。

虽然ES6也没有实现abstract,但是可以使用new.target来模拟出抽象类。new.target指向直接被new执行的构造函数,对new.target进行判断,如果指向了该类则抛出错误来使得该类成为抽象类。
new.target属性允许你检测函数或构造方法是否是通过new运算符被调用的。在通过new运算符被初始化的函数或构造方法中,new.target返回一个指向构造方法或函数的引用。在普通的函数调用中,new.target 的值是undefined。
在上面的简单工厂模式中。每次添加一个构造函数都要修改两处代码,现在对它加以改造:

class User {
  constructor(name = '', viewPage = []) {
    if(new.target === User) {
      throw new Error('抽象类不能实例化!');
    }
    this.name = name;
    this.viewPage = viewPage;
  }
}
class UserFactory extends User {
  constructor(name, viewPage) {
    super(name, viewPage)
  }
  create(role) {
    switch (role) {
      case 'superAdmin': 
        return new UserFactory( '超级管理员', ['首页', '应用数据', '权限管理'] );
        break;
      case 'admin':
        return new User({ name: '管理员', viewPage: ['首页', '应用数据'] });
        break;
      case 'user':
        return new UserFactory( '普通用户', ['首页'] );
        break;
      default:
        throw new Error('参数错误, 可选参数:superAdmin、admin、user')
    }
  }
}
let userFactory = new UserFactory();
let superAdmin = userFactory.create('superAdmin');
let admin = userFactory.create('admin');
let user = userFactory.create('user');

工厂方法可以看做是一个实例化对象的工厂,它需要做的就是实例化对象。

 

(3)抽象工厂模式

上面两种方式都是直接生成实例,而抽象工厂模式并不能直接生成实例,而是用于产品类簇的创建。
在网站登录中,上面实例中的user可能使用不用的第三方登录,例如微信、QQ、微博,这三类账号就是对应的类簇。在抽象工厂中,类簇一般用于父类的定义,并在父类中定义一些抽象的方法(声明但不能使用的方法),在通过抽象工厂让子类继承父类,所以,抽象工厂实际上就是实现子类继承父类的方法。
在传统面向对象的语言中常用abstract进行声明,但是在JavaScript中,abstract是属于保留字,可以通过在类的方法中抛出错误来模拟抽象类。

function getAbstractUserFactory(type) {
  switch (type) {
    case 'wechat':
      return UserOfWechat;
      break;
    case 'qq':
      return UserOfQq;
      break;
    case 'weibo':
      return UserOfWeibo;
      break;
    default:
      throw new Error('参数错误, 可选参数:wechat、qq、weibo')
  }
}
let WechatUserClass = getAbstractUserFactory('wechat');
let QqUserClass = getAbstractUserFactory('qq');
let WeiboUserClass = getAbstractUserFactory('weibo');
let wechatUser = new WechatUserClass('微信张三');
let qqUser = new QqUserClass('QQ张三');
let weiboUser = new WeiboUserClass('微博张三');

总结:

●简单工厂模式又叫静态工厂方法,用来创建某一种产品对象的实例,用来创建单一对象;
●工厂方法模式是将创建实例推迟到子类中进行;
●抽象工厂模式是对类的工厂抽象用来创建产品类簇,不负责创建某一类产品的实例。

在实际的业务中,需要根据实际的业务复杂度来选择合适的模式。对于非大型的前端应用来说,灵活使用简单工厂其实就能解决大部分问题。

 
 

3. 工厂模式的通用实现

注意: 这里所说的工厂模式是指简单工厂模式。在工厂模式中,主要有两个重要的概念:
Factory :工厂,负责返回产品实例
Product :产品,访问者从工厂拿到产品实例

其结构如下:
 

下面用通用的方法实现,这里直接用 class 语法:
/* 工厂类 */
class Factory {
    static getInstance(type) {
        switch (type) {
            case 'Product1':
                return new Product1()
            case 'Product2':
                return new Product2()
            default:
                throw new Error('当前没有这个产品')
        }
    }
}
/* 产品类1 */
class Product1 {
    constructor() { this.type = 'Product1' }
    
    operate() { console.log(this.type) }
}
/* 产品类2 */
class Product2 {
    constructor() { this.type = 'Product2' }
    
    operate() { console.log(this.type) }
}
const prod1 = Factory.getInstance('Product1')
prod1.operate()                                   // 输出: Product1
const prod2 = Factory.getInstance('Product3')  // 输出: Error 当前没有这个产品

 


需要注意,由于 JavaScript 很灵活,简单工厂模式返回的产品对象不一定非要是类实例,也可以是字面量形式的对象,所以可以根据场景灵活选择返回的产品对象形式。
 

4. Vue中的工厂模式


在Vue中,很多地方也是用到了工厂模式,下面来看下其中的两个例子。
 

(1)VNode

和原生的 document.createElement 类似,Vue 这种具有虚拟 DOM 树(Virtual Dom Tree)机制的框架在生成虚拟 DOM 的时候,提供了 createElement 方法用来生成 VNode,用来作为真实 DOM 节点的映射:
createElement('h3', { class: 'main-title' }, [
    createElement('img', { class: 'avatar', attrs: { src: '../avatar.jpg' } }),
    createElement('p', { class: 'user-desc' }, 'hello world')
])

 

createElement 函数结构大概如下:
class Vnode (tag, data, children) { ... }
function createElement(tag, data, children) {
      return new Vnode(tag, data, children)
}

可以看到,createElement 函数内会进行 VNode 的具体创建,创建的过程是很复杂的,而框架提供的 createElement 工厂方法封装了复杂的创建与验证过程,对于使用者来说就很方便了。
 

(2)vue-router

在Vue在路由创建模式中,也多次用到了工厂模式:
 
export default class VueRouter {
    constructor(options) {
        this.mode = mode    // 路由模式
        
        switch (mode) {           // 简单工厂
            case 'history':       // history 方式
                this.history = new HTML5History(this, options.base)
                break
            case 'hash':          // hash 方式
                this.history = new HashHistory(this, options.base, this.fallback)
                break
            case 'abstract':      // abstract 方式
                this.history = new AbstractHistory(this, options.base)
                break
            default:
                // ... 初始化失败报错
        }
    }
}
 
 
在上面的代码中,mode 是路由创建的模式,这里有三种 History、Hash、Abstract,其中,History 是 H5 的路由方式,Hash 是路由中带 # 的路由方式,Abstract 代表非浏览器环境中路由方式,比如 Node、weex 等;this.history 用来保存路由实例,vue-router 中使用了工厂模式的思想来获得响应路由控制类的实例。

Vue-Router没有把工厂方法的产品创建流程封装出来,而是直接将产品实例的创建流程暴露在 VueRouter 的构造函数中,在被 new 的时候创建对应产品实例,相当于 VueRouter 的构造函数就是一个工厂方法。

如果一个系统不是单页面应用,而是多页面应用,那么就需要创建多个 VueRouter 的实例,此时 VueRouter 的构造函数也就是工厂方法将会被多次执行,以分别获得不同实例。
 

5. 工厂模式的优缺点

工厂模式将对象的创建和实现进行了分离,其优点如下:
良好的封装,代码结构清晰,访问者无需知道对象的创建流程,特别是创建比较复杂的情况下;
扩展性优良,通过工厂方法隔离了用户和创建流程隔离,符合开放封闭原则;
解耦了高层逻辑和底层产品类,符合最少知识原则,不需要的就不要去交流;

工厂模式的缺点如下:带来了额外的系统复杂度,增加了抽象性;
 

6. 工厂模式的使用场景

那么什么时候使用工厂模式呢:
对象的创建比较复杂,而访问者无需知道创建的具体流程;
处理大量具有相同属性的小对象;
 

状态模式

1. 什么是状态模式?

状态模式 (State Pattern)允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类,类的行为随着它的状态改变而改变。

状态模式主要解决的是当控制一个对象状态的条件表达式过于复杂时的情况。把状态的判断逻辑转移到表示不同状态的一系列类中,可以把复杂的判断逻辑简化。

当程序需要根据不同的外部情况来做出不同操作时,最直接的方法就是使用 switch-case 或 if-else 语句将这些可能发生的情况全部兼顾到,但是这种做法应付复杂一点的状态判断时就有点力不从心,开发者得找到合适的位置添加或修改代码,这个过程很容易出错,这时引入状态模式可以某种程度上缓解这个问题。

在等红绿灯的时候,红绿灯的状态和行人汽车的通行逻辑是有关联的:
1红灯亮:行人通行,车辆等待;
2绿灯亮:行人等待,车辆通行;
3黄灯亮:行人等待,车辆等待;
 

还有下载文件时,有好几个状态,比如下载验证、下载中、暂停下载、下载完毕、下载失败,文件在不同状态下表现的行为也不一样,比如下载中时显示可以暂停下载和下载进度,下载失败时弹框提示并询问是否重新下载等等。类似的场景还有很多,比如女生作为你的朋友、好朋友、女朋友、老婆等不同状态的时候,行为也不同 。

在这些场景中,有以下特点:
1对象有有限多个状态,且状态间可以相互切换;
2各个状态和对象的行为逻辑有比较强的对应关系,即在不同状态时,对应的处理逻辑不一样;
 

2. 状态模式的实现

下面用最常用的方式来实现上面的红绿灯的例子:
 
let trafficLight = (function() {
    let state = '绿灯'        // 闭包缓存状态
    
    return {
        // 设置交通灯状态 
        setState: function(target) {
            if (target === '红灯') {
                state = '红灯'
                console.log('交通灯颜色变为 红色,行人通行 & 车辆等待')
            } else if (target === '黄灯') {
                state = '黄灯'
                console.log('交通灯颜色变为 黄色,行人等待 & 车辆等待')
            } else if (target === '绿灯') {
                state = '绿灯'
                console.log('交通灯颜色变为 绿色,行人等待 & 车辆通行')
            } else {
                console.error('交通灯还有这颜色?')
            }
        },
        
        // 获取交通灯状态 
        getState: function() {
            return state
        }
    }
})()

trafficLight.setState('红灯') // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
trafficLight.setState('黄灯') // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
trafficLight.setState('绿灯') // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

trafficLight.setState('紫灯') // 输出: 交通灯还有这颜色?
这里可以使用if-else来实现,也可以使用swich-case来实现,但是这样实现是存在问题的,这里处理的逻辑比较简单,如果比较复杂,在增加新的状态时,比如增加了 蓝灯、紫灯 等颜色及其处理逻辑的时候,需要到 setState 方法里找到对应地方修改。

在实际项目中,if-else 伴随的业务逻辑处理通常比较复杂,找到要修改的状态就不容易,特别是如果是别人的代码,或者接手遗留项目时,需要看完这个 if-else 的分支处理逻辑,新增或修改分支逻辑的过程中也很容易引入 Bug。

正式因为这样非常的不方便维护状态及其对应的行为,所以引入了状态模式的理念,状态模式把每种状态和对应的处理逻辑封装在一起,比如下面用一个类实例将红绿灯的逻辑封装起来:
// 抽象状态类 
var AbstractState = function() {}

// 抽象方法 
AbstractState.prototype.employ = function() {
    throw new Error('抽象方法不能调用!')
}

// 交通灯状态类 
var State = function(name, desc) {
    this.color = { name, desc }
}

State.prototype = new AbstractState()
State.prototype.employ = function(trafficLight) {
    console.log('交通灯颜色变为 ' + this.color.name + ',' + this.color.desc)
    trafficLight.setState(this)
}

// 交通灯类 
var TrafficLight = function() {
    this.state = null
}

// 获取交通灯状态 
TrafficLight.prototype.getState = function() {
    return this.state
}

// 设置交通灯状态 
TrafficLight.prototype.setState = function(state) {
    this.state = state
}

// 实例化一个红绿灯
var trafficLight = new TrafficLight()

// 实例化红绿灯可能有的三种状态
var redState = new State('红色', '行人等待 & 车辆等待')
var greenState = new State('绿色', '行人等待 & 车辆通行')
var yellowState = new State('黄色', '行人等待 & 车辆等待')

redState.employ(trafficLight)    // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
yellowState.employ(trafficLight) // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
greenState.employ(trafficLight)  // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行
这里的不同状态是同一个类的类实例,比如 redState 这个类实例,就把所有红灯状态处理的逻辑封装起来,如果要把状态切换为红灯状态,那么只需要 redState.employ() 把交通灯的状态切换为红色,并且把交通灯对应的行为逻辑也切换为红灯状态。

状态模式与策略模式很相似:
策略模式把可以相互替换的策略算法提取出来
状态模式把事物的状态及其行为提取出来。

下面使用 ES6 的 Class 语法对上面的代码进行改造:
// 抽象状态类 
class AbstractState {
    constructor() {
        if (new.target === AbstractState) {
            throw new Error('抽象类不能直接实例化!')
        }
    }
    
    // 抽象方法 
    employ() {
        throw new Error('抽象方法不能调用!')
    }
}

// 交通灯类 
class State extends AbstractState {
    constructor(name, desc) {
        super()
        this.color = { name, desc }
    }
    
    // 覆盖抽象方法 
    employ(trafficLight) {
        console.log('交通灯颜色变为 ' + this.color.name + ',' + this.color.desc)
        trafficLight.setState(this)
    }
}

// 交通灯类 
class TrafficLight {
    constructor() {
        this.state = null
    }
    
    // 获取交通灯状态 
    getState() {
        return this.state
    }
    
    // 设置交通灯状态 
    setState(state) {
        this.state = state
    }
}

const trafficLight = new TrafficLight()

const redState = new State('红色', '行人等待 & 车辆等待')
const greenState = new State('绿色', '行人等待 & 车辆通行')
const yellowState = new State('黄色', '行人等待 & 车辆等待')

redState.employ(trafficLight)    // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
yellowState.employ(trafficLight) // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
greenState.employ(trafficLight)  // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

如果要新建状态,不用修改原有代码,只要加上下面的代码:
const blueState = new State('蓝色', '行人倒立 & 车辆飞起')
blueState.employ(trafficLight)    // 输出: 交通灯颜色变为 蓝色,行人倒立 & 车辆飞起
传统的状态区分一般是基于状态类扩展的不同状态类,如何实现实现看需求具体了,比如逻辑比较复杂,通过新建状态实例的方法已经不能满足需求,那么可以使用状态类的方式。

最后,提供一个状态类的实现,同时引入状态的切换逻辑:
// 抽象状态类 
class AbstractState {
    constructor() {
        if (new.target === AbstractState) {
            throw new Error('抽象类不能直接实例化!')
        }
    }
    
    // 抽象方法 
    employ() {
        throw new Error('抽象方法不能调用!')
    }
    
    changeState() {
        throw new Error('抽象方法不能调用!')
    }
}
// 交通灯类-红灯 
class RedState extends AbstractState {
    constructor() {
        super()
        this.colorState = '红色'
    }
    
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人通行 & 车辆等待')
        // const redDom = document.getElementById('color-red')    // 业务相关操作
        // redDom.click()
    }
    
    changeState(trafficLight) {
        trafficLight.setState(trafficLight.yellowState)
    }
}
// 交通灯类-绿灯 
class GreenState extends AbstractState {
    constructor() {
        super()
        this.colorState = '绿色'
    }
    
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人等待 & 车辆通行')
        // const greenDom = document.getElementById('color-green')
        // greenDom.click()
    }
    
    changeState(trafficLight) {
        trafficLight.setState(trafficLight.redState)
    }
}
// 交通灯类-黄灯
class YellowState extends AbstractState {
    constructor() {
        super()
        this.colorState = '黄色'
    }
    
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人等待 & 车辆等待')
        // const yellowDom = document.getElementById('color-yellow')
        // yellowDom.click()
    }
    
    changeState(trafficLight) {
        trafficLight.setState(trafficLight.greenState)
    }
}
// 交通灯类 
class TrafficLight {
    constructor() {
        this.redState = new RedState()
        this.greenState = new GreenState()
        this.yellowState = new YellowState()
        
        this.state = this.greenState
    }
    
    // 设置交通灯状态 
    setState(state) {
        state.employ(this)
        this.state = state
    }
    
    changeState() {
        this.state.changeState(this)
    }
}
const trafficLight = new TrafficLight()
trafficLight.changeState()    // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
trafficLight.changeState()    // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
trafficLight.changeState()    // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

如果要增加新的交通灯颜色,也是很方便的:
 
// 交通灯类-蓝灯 
class BlueState extends AbstractState {
    constructor() {
        super()
        this.colorState = '蓝色'
    }
    
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人倒立 & 车辆飞起')
        const redDom = document.getElementById('color-blue')
        redDom.click()
    }
}
const blueState = new BlueState()
trafficLight.employ(blueState)    // 输出: 交通灯颜色变为 蓝色,行人倒立 & 车辆飞起

3. 状态管理的原理

所谓对象的状态,通常指的就是对象实例的属性的值。行为指的就是对象的功能,行为大多可以对应到方法上。状态模式把状态和状态对应的行为从原来的大杂烩代码中分离出来,把每个状态所对应的功能处理封装起来,这样选择不同状态的时候,其实就是在选择不同的状态处理类。

也就是说,状态和行为是相关联的,它们的关系可以描述总结成:状态决定行为。由于状态是在运行期被改变的,因此行为也会在运行期根据状态的改变而改变,看起来,同一个对象,在不同的运行时刻,行为是不一样的,就像是类被修改了一样。

为了提取不同的状态类共同的外观,可以给状态类定义一个共同的状态接口或抽象类,正如之前最后的两个代码示例一样,这样可以面向统一的接口编程,无须关心具体的状态类实现。
 
4. 状态模式的优缺点
状态模式的优点:
结构相比之下清晰,避免了过多的 switch-case 或 if-else 语句的使用,避免了程序的复杂性提高系统的可维护性;
符合开闭原则,每个状态都是一个子类,增加状态只需增加新的状态类即可,修改状态也只需修改对应状态类就可以了;
封装性良好,状态的切换在类的内部实现,外部的调用无需知道类内部如何实现状态和行为的变换。

状态模式的缺点:引入了多余的类,每个状态都有对应的类,导致系统中类的个数增加。
 

5. 状态模式的应用场景

在以下场景中可以使用状态模式:
操作中含有庞大的多分支的条件语句,且这些分支依赖于该对象的状态,那么可以使用状态模式来将分支的处理分散到单独的状态类中;
对象的行为随着状态的改变而改变,那么可以考虑状态模式,来把状态和行为分离,虽然分离了,但是状态和行为是对应的,再通过改变状态调用状态对应的行为;
 

6. 状态模式与其他模式的区别

(1)状态模式和策略模式
状态模式和策略模式在之前的代码就可以看出来,看起来比较类似,他们的区别:
状态模式: 重在强调对象内部状态的变化改变对象的行为,状态类之间是平行的,无法相互替换;
策略模式: 策略的选择由外部条件决定,策略可以动态的切换,策略之间是平等的,可以相互替换;

状态模式的状态类是平行的,意思是各个状态类封装的状态和对应的行为是相互独立、没有关联的,封装的业务逻辑可能差别很大毫无关联,相互之间不可替换。但是策略模式中的策略是平等的,是同一行为的不同描述或者实现,在同一个行为发生的时候,可以根据外部条件挑选任意一个实现来进行处理。
 
(2)状态模式和发布-订阅模式
这两个模式都是在状态发生改变的时候触发行为,不过发布-订阅模式的行为是固定的,那就是通知所有的订阅者,而状态模式是根据状态来选择不同的处理逻辑。
状态模式: 根据状态来分离行为,当状态发生改变的时候,动态地改变行为;
发布-订阅模式: 发布者在消息发生时通知订阅者,具体如何处理则不在乎,或者直接丢给用户自己处理;

这两个模式是可以组合使用的,比如在发布-订阅模式的发布消息部分,当对象的状态发生了改变,触发通知了所有的订阅者后,可以引入状态模式,根据通知过来的状态选择相应的处理。
 

策略模式

1. 什么是策略模式?

策略模式 (Strategy Pattern)又称政策模式,其定义一系列的算法,把它们一个个封装起来,并且使它们可以互相替换。封装的策略算法一般是独立的,策略模式根据输入来调整采用哪个算法。关键是策略的实现和使用分离。
在生活中,螺丝的规格有很多,螺丝刀尺寸也不少,如果每碰到一种规格就买一个螺丝刀,那就得堆满了螺丝刀。所以现在人们都用多功能的螺丝刀套装,螺丝刀把只需要一个,碰到不同规格的螺丝只要换螺丝刀头就行了,很方便,体积也变小很多。
再举个栗子,一辆车的轮胎有很多规格,在泥泞路段开的多的时候可以用泥地胎,在雪地开得多可以用雪地胎,高速公路上开的多的时候使用高性能轮胎,针对不同使用场景更换不同的轮胎即可,不需更换整个车。
这些都是策略模式的实例,螺丝刀/车属于封装上下文,封装和使用不同的螺丝刀头/轮胎,螺丝刀头/轮胎这里就相当于策略,可以根据需求不同来更换不同的使用策略。

在这些场景中,有以下特点:
1螺丝刀头/轮胎(策略)之间相互独立,但又可以相互替换;
2螺丝刀/车(封装上下文)可以根据需要的不同选用不同的策略;
 

2. 策略模式的实现

来看一个具体的例子,某个电商网站,通过打折促销来销售库存物品,有的商品满 100 减 30,有的商品满 200 减 80,有的商品直接 8 折出售,这样的逻辑该去实现呢。
 
 
function priceCalculate(discountType, price) {
    if (discountType === 'minus100_30') {           // 满100减30
        return price - Math.floor(price / 100) * 30
    }
    else if (discountType === 'minus200_80') {  // 满200减80
        return price - Math.floor(price / 200) * 80
    }
    else if (discountType === 'percent80') {    // 8折
        return price * 0.8
    }
}
priceCalculate('minus100_30', 270)    // 输出: 210
priceCalculate('percent80', 250)      // 输出: 200
 
通过判断输入的折扣类型来计算商品总价的方式,几个 if-else 就满足了需求,但是这样的做法的缺点也很明显:
1priceCalculate 函数随着折扣类型的增多,if-else 判断语句会变得越来越臃肿;
2如果增加了新的折扣类型或者折扣类型的算法有所改变,那么需要更改 priceCalculate 函数的实现,这是违反开放-封闭原则的;
3可复用性差,如果在其他的地方也有类似这样的算法,但规则不一样,上述代码不能复用;

下面来改造一下,将计算折扣的算法部分提取出来保存为一个对象,折扣的类型作为 key,这样索引的时候通过对象的键值索引调用具体的算法:
 
 
const DiscountMap = {
    minus100_30: function(price) {
        return price - Math.floor(price / 100) * 30
    },
    minus200_80: function(price) {
        return price - Math.floor(price / 200) * 80
    },
    percent80: function(price) {
        return price * 0.8
    }
}
// 计算总售价
function priceCalculate(discountType, price) {
    return DiscountMap[discountType] && DiscountMap[discountType](price)
}
priceCalculate('minus100_30', 270)
priceCalculate('percent80', 250)
// 输出: 210
// 输出: 200
 
这样算法的实现和算法的使用就被分开了,想添加新的算法也变得十分简单:
DiscountMap.minus150_40 = function(price) {
    return price - Math.floor(price / 150) * 40
}

 

如果希望计算算法隐藏起来,那么可以借助 IIFE 使用闭包的方式,这时需要添加增加策略的入口,以方便扩展:
const PriceCalculate = (function() {
    /* 售价计算方式 */
    const DiscountMap = {
        minus100_30: function(price) {      // 满100减30
            return price - Math.floor(price / 100) * 30
        },
        minus200_80: function(price) {      // 满200减80
            return price - Math.floor(price / 200) * 80
        },
        percent80: function(price) {        // 8折
            return price * 0.8
        }
    }
    
    return {
        priceClac: function(discountType, price) {
            return DiscountMap[discountType] && DiscountMap[discountType](price)
        },
        addStrategy: function(discountType, fn) {        // 注册新计算方式
            if (DiscountMap[discountType]) return
            DiscountMap[discountType] = fn
        }
    }
})()
PriceCalculate.priceClac('minus100_30', 270)    // 输出: 210
PriceCalculate.addStrategy('minus150_40', function(price) {
    return price - Math.floor(price / 150) * 40
})
PriceCalculate.priceClac('minus150_40', 270)    // 输出: 230

 


这样算法就被隐藏起来,并且预留了增加策略的入口,便于扩展。

下面就来实现一个通用的策略模式,可以根据上面的例子提炼一下策略模式,折扣计算方式可以被认为是策略(Strategy),这些策略之间可以相互替代,而具体折扣的计算过程可以被认为是封装上下文(Context),封装上下文可以根据需要选择不同的策略。

策略模式中主要有下面概念:
1Context :封装上下文,根据需要调用需要的策略,屏蔽外界对策略的直接调用,只对外提供一个接口,根据需要调用对应的策略;
2Strategy :策略,含有具体的算法,其方法的外观相同,因此可以互相代替;
3StrategyMap :所有策略的合集,供封装上下文调用;

结构图如下:

 

 


下面使用通用化的方法实现一下。
 
const StrategyMap = {}
function context(type, ...rest) {
    return StrategyMap[type] && StrategyMap[type](...rest)
}
StrategyMap.minus100_30 = function(price) { 
      return price - Math.floor(price / 100) * 30
}
context('minus100_30', 270)            // 输出: 210

 

3. 策略模式的实际应用

 
(1)表格 formatter
Element UI 的表格控件的 Column 接受一个 formatter 参数,用来格式化内容,其类型为函数,并且还可以接受几个特定参数,像这样: Function(row, column, cellValue, index)。

以文件大小转化为例,后端经常会直接传 bit 单位的文件大小,那么前端需要根据后端的数据,根据需求转化为自己需要的单位的文件大小,比如 KB/MB。

首先实现文件计算的算法:
export const StrategyMap = {
    // Strategy 1: 将文件大小(bit)转化为 KB 
    bitToKB: val => {
        const num = Number(val)
        return isNaN(num) ? val : (num / 1024).toFixed(0) + 'KB'
    },
    // Strategy 2: 将文件大小(bit)转化为 MB 
    bitToMB: val => {
        const num = Number(val)
        return isNaN(num) ? val : (num / 1024 / 1024).toFixed(1) + 'MB'
    }
}
// Context: 生成el表单 formatter 
const strategyContext = function(type, rowKey){ 
  return function(row, column, cellValue, index){
      StrategyMap[type](row[rowKey])
  }
}
export default strategyContext

 


那么在组件中可以直接使用:
<template>
    <el-table :data="tableData">
        <el-table-column prop="date" label="日期"></el-table-column>
        <el-table-column prop="name" label="文件名"></el-table-column>
        <!-- 直接调用 strategyContext -->
        <el-table-column prop="sizeKb" label="文件大小(KB)"
                         :formatter='strategyContext("bitToKB", "sizeKb")'>
        </el-table-column>
        <el-table-column prop="sizeMb" label="附件大小(MB)"
                         :formatter='strategyContext("bitToMB", "sizeMb")'>
        </el-table-column>
    </el-table>
</template>
<script type='text/javascript'>
    import strategyContext from './strategyContext.js'
    
    export default {
        name: 'ElTableDemo',
        data() {
            return {
                strategyContext,
                tableData: [
                    { date: '2019-05-02', name: '文件1', sizeKb: 1234, sizeMb: 1234426 },
                    { date: '2019-05-04', name: '文件2', sizeKb: 4213, sizeMb: 8636152 }]
            }
        }
    }
</script>
<style scoped></style>

 


运行结果如下图

(2)表单验证

除了表格中的 formatter 之外,策略模式也经常用在表单验证的场景。Element UI 的 Form 表单 具有表单验证功能,用来校验用户输入的表单内容。实际需求中表单验证项一般会比较复杂,所以需要给每个表单项增加 validator 自定义校验方法。

可以像官网示例一样把表单验证都写在组件的状态 data 函数中,但是这样就不好复用使用频率比较高的表单验证方法了,这时可以结合策略模式和函数柯里化的知识来重构一下。
首先在项目的工具模块(一般是 utils 文件夹)实现通用的表单验证方法:
// src/utils/validates.js
// 姓名校验 由2-10位汉字组成 
export function validateUsername(str) {
    const reg = /^[\u4e00-\u9fa5]{2,10}$/
    return reg.test(str)
}
// 手机号校验 由以1开头的11位数字组成  
export function validateMobile(str) {
    const reg = /^1\d{10}$/
    return reg.test(str)
}
// 邮箱校验 
export function validateEmail(str) {
    const reg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/
    return reg.test(str)
}

 

然后在 utils/index.js 中增加一个柯里化方法,用来生成表单验证函数:
// src/utils/index.js
import * as Validates from './validates.js'
// 生成表格自定义校验函数 
export const formValidateGene = (key, msg) => (rule, value, cb) => {
    if (Validates[key](value)) {
        cb()
    } else {
        cb(new Error(msg))
    }
}

 

上面的 formValidateGene 函数接受两个参数,第一个是验证规则,也就是 src/utils/validates.js 文件中提取出来的通用验证规则的方法名,第二个参数是报错的话表单验证的提示信息。
<template>
    <el-form ref="ruleForm"
             label-width="100px"
             class="demo-ruleForm"
             :rules="rules"
             :model="ruleForm">
        
        <el-form-item label="用户名" prop="username">
            <el-input v-model="ruleForm.username"></el-input>
        </el-form-item>
        
        <el-form-item label="手机号" prop="mobile">
            <el-input v-model="ruleForm.mobile"></el-input>
        </el-form-item>
        
        <el-form-item label="邮箱" prop="email">
            <el-input v-model="ruleForm.email"></el-input>
        </el-form-item>
    </el-form>
</template>
<script type='text/javascript'>
    import * as Utils from '../utils'
    
    export default {
        name: 'ElTableDemo',
        data() {
            return {
                ruleForm: { pass: '', checkPass: '', age: '' },
                rules: {
                    username: [{
                        validator: Utils.formValidateGene('validateUsername', '姓名由2-10位汉字组成'),
                        trigger: 'blur'
                    }],
                    mobile: [{
                        validator: Utils.formValidateGene('validateMobile', '手机号由以1开头的11位数字组成'),
                        trigger: 'blur'
                    }],
                    email: [{
                        validator: Utils.formValidateGene('validateEmail', '不是正确的邮箱格式'),
                        trigger: 'blur'
                    }]
                }
            }
        }
    }
</script>

 


可以看见在使用的时候非常方便,把表单验证方法提取出来作为策略,使用柯里化方法动态选择表单验证方法,从而对策略灵活运用,大大加快开发效率。

运行结果:
 

4. 策略模式的优缺点

策略模式将算法的实现和使用拆分,这个特点带来了很多优点:
1策略之间相互独立,但策略可以自由切换,这个策略模式的特点给策略模式带来很多灵活性,也提高了策略的复用率;
2如果不采用策略模式,那么在选策略时一般会采用多重的条件判断,采用策略模式可以避免多重条件判断,增加可维护性;
3可扩展性好,策略可以很方便的进行扩展;

策略模式的缺点:
1策略相互独立,因此一些复杂的算法逻辑无法共享,造成一些资源浪费;
2如果用户想采用什么策略,必须了解策略的实现,因此所有策略都需向外暴露,这是违背迪米特法则/最少知识原则的,也增加了用户对策略对象的使用成本。
 

5. 策略模式的适用场景

策略模式的使用场景如下:
1多个算法只在行为上稍有不同的场景,这时可以使用策略模式来动态选择算法;
2算法需要自由切换的场景;
3有时需要多重条件判断,那么可以使用策略模式来规避多重条件判断的情况;
 

6. 策略模式与其他模式的区别

(1)状态模式和策略模式
状态模式和策略模式在之前的代码就可以看出来,看起来比较类似,他们的区别:
状态模式: 重在强调对象内部状态的变化改变对象的行为,状态类之间是平行的,无法相互替换;
策略模式: 策略的选择由外部条件决定,策略可以动态的切换,策略之间是平等的,可以相互替换;

状态模式的状态类是平行的,意思是各个状态类封装的状态和对应的行为是相互独立、没有关联的,封装的业务逻辑可能差别很大毫无关联,相互之间不可替换。但是策略模式中的策略是平等的,是同一行为的不同描述或者实现,在同一个行为发生的时候,可以根据外部条件挑选任意一个实现来进行处理。
 
(2) 策略模式和模板方法模式
策略模式和模板方法模式的作用比较类似,但是结构和实现方式有点不一样。
策略模式 让我们在程序运行的时候动态地指定要使用的算法;
模板方法模式 是在子类定义的时候就已经确定了使用的算法;
 

代理模式

1. 什么是代理模式?

代理模式 (Proxy Pattern)又称委托模式,它为目标对象创造了一个代理对象,以控制对目标对象的访问。

代理模式把代理对象插入到访问者和目标对象之间,从而为访问者对目标对象的访问引入一定的间接性。正是这种间接性,给了代理对象很多操作空间,比如在调用目标对象前和调用后进行一些预操作和后操作,从而实现新的功能或者扩展目标的功能。

对于明星,他们一般都会有经纪人或者助理,如果某导演来请这个明星演出,或者某个品牌来找明星做广告,需要经纪人帮明星做接洽工作。而且经纪人也起到过滤的作用,毕竟明星也不是什么电影和广告都会接。

打官司是件非常麻烦的事,包括查找法律条文、起草法律文书、法庭辩论、签署法律文件、申请法院执行等等流程。此时,当事人就可聘请代理律师来完成整个打官司的所有事务。当事人只需与代理律师签订全权委托协议,那么整个打官司的过程,当事人都可以不用出现。法院的一些复杂事务都可以通过代理律师来完成,而法院需要当事人完成某些工作的时候,比如出庭,代理律师才会通知当事人,并为当事人出谋划策。

在类似的场景中,有以下特点:
导演/法院(访问者)对明星/当事人(目标)的访问都是通过经纪人/律师(代理)来完成;
经纪人/律师(代理)对访问有过滤的功能;
 

2. 代理模式的实现

下面来实现一下上面明星&经纪人的例子:
 
 
/* 明星 */
var SuperStar = {
    name: '小鲜肉',
    playAdvertisement: function(ad) {
        console.log(ad)
    }
}
/* 经纪人 */
var ProxyAssistant = {
    name: '经纪人张某',
    playAdvertisement: function(reward, ad) {
        if (reward > 1000000) {             // 如果报酬超过100w
            console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
            SuperStar.playAdvertisement(ad)
        } else
            console.log('没空,滚!')
    }
}
ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没空,滚
 
这里通过经纪人的方式来和明星取得联系,经纪人会视条件过滤一部分合作请求。

可以升级一下,比如如果明星没有档期的话,可以通过经纪人安排档期,当明星有空的时候才让明星来拍广告。这里通过 Promise 的方式来实现档期的安排:
 
/* 明星 */
const SuperStar = {
    name: '小鲜肉',
    playAdvertisement(ad) {
        console.log(ad)
    }
}
/* 经纪人 */
const ProxyAssistant = {
    name: '经纪人张某',
    scheduleTime() {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                console.log('小鲜鲜有空了')
                resolve()
            }, 2000)                        // 发现明星有空了
        })
    },
    playAdvertisement(reward, ad) {
        if (reward > 1000000) {             // 如果报酬超过100w
            console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
            ProxyAssistant.scheduleTime()   // 安排上了
              .then(() => SuperStar.playAdvertisement(ad))
        } else
            console.log('没空,滚!')
    }
}
ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没空,滚
ProxyAssistant.playAdvertisement(1000001, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没问题,我们小鲜鲜最喜欢拍广告了!
// 2秒后
// 输出: 小鲜鲜有空了
// 输出: 纯蒸酸牛奶,味道纯纯,尽享纯蒸
 
这里就简单实现了经纪人对请求的过滤,对明星档期的安排,实现了一个代理对象的基本功能。

对于上面的例子,明星就相当于被代理的目标对象(Target),而经纪人就相当于代理对象(Proxy),希望找明星的人是访问者(Visitor),他们直接找不到明星,只能找明星的经纪人来进行业务商洽。主要有以下几个概念:
1Target: 目标对象,也是被代理对象,是具体业务的实际执行者;
2Proxy: 代理对象,负责引用目标对象,以及对访问的过滤和预处理;
 

ES6 原生提供了 Proxy 构造函数,这个构造函数让我们可以很方便地创建代理对象:
var proxy = new Proxy(target, handler);
参数中 target 是被代理对象,handler 用来设置代理行为。
这里使用 Proxy 来实现一下上面的经纪人例子:
/* 明星 */
const SuperStar = {
    name: '小鲜肉',
    scheduleFlag: false,            // 档期标识位,false-没空(默认值),true-有空
    playAdvertisement(ad) {
        console.log(ad)
    }
}
/* 经纪人 */
const ProxyAssistant = {
    name: '经纪人张某',
    scheduleTime(ad) {
        const schedule = new Proxy(SuperStar, {             // 在这里监听 scheduleFlag 值的变化
            set(obj, prop, val) {
                if (prop !== 'scheduleFlag') return
                if (obj.scheduleFlag === false &&
                  val === true) {                     // 小鲜肉现在有空了
                    obj.scheduleFlag = true
                    obj.playAdvertisement(ad)         // 安排上了
                }
            }
        })
        
        setTimeout(() => {
            console.log('小鲜鲜有空了')
            schedule.scheduleFlag = true              // 明星有空了
        }, 2000)
    },
    playAdvertisement(reward, ad) {
        if (reward > 1000000) {             // 如果报酬超过100w
            console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
            ProxyAssistant.scheduleTime(ad)
        } else
            console.log('没空,滚!')
    }
}
ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没空,滚
ProxyAssistant.playAdvertisement(1000001, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没问题,我们小鲜鲜最喜欢拍广告了!
// 2秒后
// 输出: 小鲜鲜有空了
// 输出: 纯蒸酸牛奶,味道纯纯,尽享纯蒸

在 ES6 之前,一般是使用 Object.defineProperty 来完成相同的功能,可以使用这个 API 改造一下:

/* 明星 */
const SuperStar = {
    name: '小鲜肉',
    scheduleFlagActually: false,            // 档期标识位,false-没空(默认值),true-有空
    playAdvertisement(ad) {
        console.log(ad)
    }
}
/* 经纪人 */
const ProxyAssistant = {
    name: '经纪人张某',
    scheduleTime(ad) {
        Object.defineProperty(SuperStar, 'scheduleFlag', {     // 在这里监听 scheduleFlag 值的变化
            get() {
                return SuperStar.scheduleFlagActually
            },
            set(val) {
                if (SuperStar.scheduleFlagActually === false &&
                  val === true) {                           // 小鲜肉现在有空了
                    SuperStar.scheduleFlagActually = true
                    SuperStar.playAdvertisement(ad)         // 安排上了
                }
            }
        })
        
        setTimeout(() => {
            console.log('小鲜鲜有空了')
            SuperStar.scheduleFlag = true
        }, 2000)                            // 明星有空了
    },
    playAdvertisement(reward, ad) {
        if (reward > 1000000) {             // 如果报酬超过100w
            console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
            ProxyAssistant.scheduleTime(ad)
        } else
            console.log('没空,滚!')
    }
}
ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没空,滚
ProxyAssistant.playAdvertisement(1000001, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
// 输出: 没问题,我们小鲜鲜最喜欢拍广告了!
// 2秒后
// 输出: 小鲜鲜有空了
// 输出: 纯蒸酸牛奶,味道纯纯,尽享纯蒸

3. 代理模式在实战中的应用

(1)拦截器

上面使用代理模式代理对象的访问的方式,一般又被称为拦截器。

拦截器的思想在实战中应用非常多,比如我们在项目中经常使用 Axios 的实例来进行 HTTP 的请求,使用拦截器 interceptor 可以提前对 request 请求和 response 返回进行一些预处理,比如:
1request 请求头的设置,和 Cookie 信息的设置;
2权限信息的预处理,常见的比如验权操作或者 Token 验证;
3数据格式的格式化,比如对组件绑定的 Date 类型的数据在请求前进行一些格式约定好的序列化操作;
4空字段的格式预处理,根据后端进行一些过滤操作;
5response 的一些通用报错处理,比如使用 Message 控件抛出错误;

除了 HTTP 相关的拦截器之外,还有路由跳转的拦截器,可以进行一些路由跳转的预处理等操作。

 

(2)前端框架的数据响应式化

现在的很多前端框架或者状态管理框架都使用上面介绍的 Object.defineProperty 和 Proxy 来实现数据的响应式化,比如 Vue,Vue 2.x 使用前者,而 Vue 3.x 则使用后者。

Vue 2.x 中通过 Object.defineProperty 来劫持各个属性的 setter/getter,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,从而实现数据的响应式化,也就是数据到视图的双向绑定。

为什么 Vue 2.x 到 3.x 要从 Object.defineProperty 改用 Proxy 呢,是因为前者的一些局限性,导致的以下缺陷:
1无法监听利用索引直接设置数组的一个项,例如:vm.items[indexOfItem] = newValue;
2无法监听数组的长度的修改,例如:vm.items.length = newLength;
3无法监听 ES6 的 Set、WeakSet、Map、WeakMap 的变化;
4无法监听 Class 类型的数据;
5无法监听对象属性的新加或者删除;

除此之外还有性能上的差异,基于这些原因,Vue 3.x 改用 Proxy 来实现数据监听了。当然缺点就是对 IE 用户的不友好,兼容性敏感的场景需要做一些取舍。

 

(3)缓存代理

在高阶函数的文章中,就介绍了备忘模式,备忘模式就是使用缓存代理的思想,将复杂计算的结果缓存起来,下次传参一致时直接返回之前缓存的计算结果。

 

(4)保护代理和虚拟代理

有的书籍中着重强调代理的两种形式:保护代理和虚拟代理:
1保护代理 :当一个对象可能会收到大量请求时,可以设置保护代理,通过一些条件判断对请求进行过滤;
2虚拟代理 :在程序中可以能有一些代价昂贵的操作,此时可以设置虚拟代理,虚拟代理会在适合的时候才执行操作。

保护代理其实就是对访问的过滤,之前的经纪人例子就属于这种类型。而虚拟代理是为一个开销很大的操作先占位,之后再执行,比如:
1一个很大的图片加载前,一般使用菊花图、低质量图片等提前占位,优化图片加载导致白屏的情况;
2现在很流行的页面加载前使用骨架屏来提前占位,很多 WebApp 和 NativeApp 都采用这种方式来优化用户白屏体验;

(5)正向代理与反向代理

还有个经常用的例子是反向代理(Reverse Proxy),反向代理对应的是正向代理(Forward Proxy),他们的区别是:
1正向代理: 一般的访问流程是客户端直接向目标服务器发送请求并获取内容,使用正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器(原始服务器),然后由代理服务器和原始服务器通信,转交请求并获得的内容,再返回给客户端。正向代理隐藏了真实的客户端,为客户端收发请求,使真实客户端对服务器不可见;
2反向代理: 与一般访问流程相比,使用反向代理后,直接收到请求的服务器是代理服务器,然后将请求转发给内部网络上真正进行处理的服务器,得到的结果返回给客户端。反向代理隐藏了真实的服务器,为服务器收发请求,使真实服务器对客户端不可见。

反向代理一般在处理跨域请求的时候比较常用,属于服务端开发人员的日常操作了,另外在缓存服务器、负载均衡服务器等等场景也是使用到代理模式的思想。


4. 代理模式的优缺点

代理模式的主要优点有:
1代理对象在访问者与目标对象之间可以起到中介和保护目标对象的作用;
2代理对象可以扩展目标对象的功能;
3代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开闭原则;

代理模式的缺点主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式
 
5. 代理模式与其他模式的区别
很多其他的模式,比如状态模式、策略模式、访问者模式其实也是使用了代理模式。
 
(1)代理模式与适配器模式
代理模式和适配器模式都为另一个对象提供间接性的访问,他们的区别:
适配器模式: 主要用来解决接口之间不匹配的问题,通常是为所适配的对象提供一个不同的接口;
代理模式: 提供访问目标对象的间接访问,以及对目标对象功能的扩展,一般提供和目标对象一样的接口;
 
(2)代理模式与装饰者模式
装饰者模式实现上和代理模式类似,都是在访问目标对象之前或者之后执行一些逻辑,但是目的和功能不同:
装饰者模式: 目的是为了方便地给目标对象添加功能,也就是动态地添加功能;
代理模式: 主要目的是控制其他访问者对目标对象的访问;
 

装饰器模式

1. 什么是装饰器模式?

装饰器模式,又名装饰者模式。它的定义是“在不改变原对象的基础上,通过对其进行包装拓展,使得原有对象可以动态具有更多功能,从而满足用户的更复杂需求”。

装饰器模式的本质是功能动态组合,即动态地给一个对象添加额外的职责,就增加功能角度来看,使用装饰器模式比用继承更为灵活。好处就是有效地把对象的核心职责和装饰功能区分开,并且通过动态增删装饰去除目标对象中重复的装饰逻辑。

我们在买房之后,就可以居住了。但是,往往会对房屋进行装饰,通水电、刷漆、铺地板、购置家具,安装家电等等。这样,就让房屋就有了各种各样的特性,刷漆、铺地板之后房子变的更美观了;摆放家具、家电之后,房屋就更加便捷了等等。但是,我们并没有改变房子是用来居住的基本功能,这就是装饰的作用。

我们好多人喜欢给手机买手机壳,装上手机壳之后,手机就变得更加耐磨,耐摔,更加好看等,但是并没有改变手机的功能,只是对其进行了装饰。

这两个例子中,都有以下特点:
装饰不影响原有的功能,原有功能可以照常使用;
装饰可以增加多个,共同给目标对象添加额外功能。
 

2. 装饰器模式的原理

装饰器模式的原理:

 

 


可以从上图看出,在表现形式上,装饰器模式和适配器模式比较类似,都属于包装模式。在装饰器模式中,一个对象被另一个对象包装起来,形成一条包装链,并增加了原先对象的功能。
 

3. 装饰器模式的使用场景

 
(1)给浏览器事件添加新功能
添加装饰器函数常被用来给原有浏览器或 DOM 绑定事件上绑定新的功能,比如在 onload 上增加新的事件,或在原来的事件绑定函数上增加新的功能,或者在原本的操作上增加用户行为埋点:
window.onload = function() {
    console.log('原先的 onload 事件 ')
}
/* 发送埋点信息 */
function sendUserOperation() {
    console.log('埋点:用户当前行为路径为 ...')
}
/* 将新的功能添加到 onload 事件上 */
window.onload = function() {
    var originOnload = window.onload
    return function() {
        originOnload && originOnload()
        sendUserOperation()
    }
}()
// 输出: 原先的 onload 事件
// 输出: 埋点:用户当前行为路径为 ...

可以看到通过添加装饰函数,为 onload 事件回调增加新的方法,且并不影响原本的功能,可以把上面的方法提取出来作为一个工具方法:
window.onload = function() {
    console.log('原先的 onload 事件 ')
}
/* 发送埋点信息 */
function sendUserOperation() {
    console.log('埋点:用户当前行为路径为 ...')
}
/* 给原生事件添加新的装饰方法 */
function originDecorateFn(originObj, originKey, fn) {
    originObj[originKey] = function() {
        var originFn = originObj[originKey]
        return function() {
            originFn && originFn()
            fn()
        }
    }()
}
// 添加装饰功能
originDecorateFn(window, 'onload', sendUserOperation)
// 输出: 原先的 onload 事件
// 输出: 埋点:用户当前行为路径为 ...
 
(2)给浏览器事件添加新功能
下面再看一个场景:点击一个按钮后,如果用户还未登录,就弹窗提示用户“您还未登录哦~”。
<body>
    <button id='open'>点击打开</button>
    <button id='close'>关闭弹框</button>
</body>
<script>
    // 弹框创建逻辑,这里我们复用了单例模式面试题的例子
    const Modal = (function() {
        let modal = null
        return function() {
            if(!modal) {
                modal = document.createElement('div')
                modal.innerHTML = '您还未登录哦~'
                modal.id = 'modal'
                modal.style.display = 'none'
                document.body.appendChild(modal)
            }
            return modal
        }
    })()
    
    // 点击打开按钮展示模态框
    document.getElementById('open').addEventListener('click', function() {
        // 未点击则不创建modal实例,避免不必要的内存占用
        const modal = new Modal()
        modal.style.display = 'block'
    })
    
    // 点击关闭按钮隐藏模态框
    document.getElementById('close').addEventListener('click', function() {
        const modal = document.getElementById('modal')
        if(modal) {
            modal.style.display = 'none'
        }
    })
</script>

 

后来因为业务需求的变更,要求在弹框被关闭后把按钮的文案改为“快去登录”,同时把按钮置灰。

这个需求更改看起来比较简单,但是,可能不止有一个按钮有这个需求,那有可能还要更该很多代码。况且,直接去修改已有的函数体的话,就违背了“开放封闭原则”;在一个函数体中写很多逻辑,就违背了“单一职责原则”。

我们要做的就是将原来的逻辑与新的逻辑分离,将旧的逻辑抽离出来:
// 将展示Modal的逻辑单独封装
function openModal() {
    const modal = new Modal()
    modal.style.display = 'block'
}

编写新逻辑:
// 按钮文案修改逻辑
function changeButtonText() {
    const btn = document.getElementById('open')
    btn.innerText = '快去登录'
}
// 按钮置灰逻辑
function disableButton() {
    const btn =  document.getElementById('open')
    btn.setAttribute("disabled", true)
}
// 新版本功能逻辑整合
function changeButtonStatus() {
    changeButtonText()
    disableButton()
}

然后把三个操作逐个添加open按钮的监听函数里:
document.getElementById('open').addEventListener('click', function() {
    openModal()
    changeButtonStatus()
})

 

这样,就实现了“只添加,不修改”的装饰器模式,使用changeButtonStatus的逻辑装饰了旧的按钮点击逻辑。

以上是ES5中的实现,ES6中,可以以一种更加面向对象化的方式去写:
// 定义打开按钮
class OpenButton {
    // 点击后展示弹框(旧逻辑)
    onClick() {
        const modal = new Modal()
        modal.style.display = 'block'
    }
}
// 定义按钮对应的装饰器
class Decorator {
    // 将按钮实例传入
    constructor(open_button) {
        this.open_button = open_button
    }
    
    onClick() {
        this.open_button.onClick()
        // “包装”了一层新逻辑
        this.changeButtonStatus()
    }
    
    changeButtonStatus() {
        this.changeButtonText()
        this.disableButton()
    }
    
    disableButton() {
        const btn =  document.getElementById('open')
        btn.setAttribute("disabled", true)
    }
    
    changeButtonText() {
        const btn = document.getElementById('open')
        btn.innerText = '快去登录'
    }
}

 

这里把按钮实例传给了 Decorator,以便于后续 Decorator 可以对它进行逻辑的拓展。
 

4. 装饰器模式的优缺点

装饰器模式的优点:
可维护性高: 我们经常使用继承的方式来实现功能的扩展,但这样会给系统中带来很多的子类和复杂的继承关系,装饰器模式允许用户在不引起子类数量暴增的前提下动态地修饰对象,添加功能,装饰器和被装饰器之间松耦合,可维护性好;
灵活性好: 被装饰器可以使用装饰器动态地增加和撤销功能,可以在运行时选择不同的装饰器,实现不同的功能,灵活性好;
复用性高: 装饰器模式把一系列复杂的功能分散到每个装饰器当中,一般一个装饰器只实现一个功能,可以给一个对象增加多个同样的装饰器,也可以把一个装饰器用来装饰不同的对象,有利于装饰器功能的复用;
多样性: 可以通过选择不同的装饰器的组合,创造不同行为和功能的结合体,原有对象的代码无须改变,就可以使得原有对象的功能变得更强大和更多样化,符合开闭原则;

装饰器模式的缺点:
使用装饰器模式时会产生很多细粒度的装饰器对象,这些装饰器对象由于接口和功能的多样化导致系统复杂度增加,功能越复杂,需要的细粒度对象越多;
由于更大的灵活性,也就更容易出错,特别是对于多级装饰的场景,错误定位会更加繁琐;
 
5. 装饰器模式的适用场景
如果不希望系统中增加很多子类,那么可以考虑使用装饰器模式;
需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,这时采用装饰器模式可以很好实现;
当对象的功能要求可以动态地添加,也可以动态地撤销,可以考虑使用装饰器模式;
 

6. 其他相关模式

(1)装饰器模式与适配器模式
装饰器模式和适配器模式都是属于包装模式,然而他们的意图有些不一样:
装饰器模式: 扩展功能,原有功能还可以直接使用,一般可以给目标对象多次叠加使用多个装饰器;
适配器模式: 功能不变,但是转换了原有接口的访问格式,一般只给目标对象使用一次;
(2)装饰器模式与组合模式
这两个模式有相似之处,都涉及到对象的递归调用,从某个角度来说,可以把装饰器模式看做是只有一个组件的组合模式。
装饰器模式: 动态地给对象增加功能;
组合模式: 管理组合对象和叶子对象,为它们提供一致的操作接口给客户端,方便客户端的使用;
(3)装饰器模式与策略模式
装饰器模式和策略模式都包含有许多细粒度的功能模块,但是他们的使用思路不同:
装饰器模式: 可以递归调用,使用多个功能模式,功能之间可以叠加组合使用;
策略模式: 只有一层选择,选择某一个功能;
 

适配器模式

1. 适配器模式

适配器模式(Adapter Pattern)又称包装器模式,将一个类(对象)的接口(方法、属性)转化为用户需要的另一个接口,解决类(对象)之间接口不兼容的问题。

主要功能是进行转换匹配,目的是复用已有的功能,而不是来实现新的接口。也就是说,访问者需要的功能应该是已经实现好了的,不需要适配器模式来实现,适配器模式主要是负责把不兼容的接口转换成访问者期望的格式而已。

在生活中我们会遇到形形色色的适配器,最常见的就是转接头了,比如不同规格电源接口的转接头、3.5 毫米耳机插口转接头、DP/miniDP/HDMI/DVI/VGA 等视频转接头、电脑、手机、ipad 的电源适配器,都是属于适配器的范畴。

在类似场景中,这些例子有以下特点:
1旧有接口格式已经不满足现在的需要;
2通过增加适配器来更好地使用旧有接口;
 

2. 适配器模式的实现

下面来实现一下电源适配器的例子,使用中国插头标准:
var chinaPlug = {
    type: '中国插头',
    chinaInPlug() {
        console.log('开始供电')
    }
}
chinaPlug.chinaInPlug()
// 输出:开始供电

但是出国旅游,到了日本,需要增加一个日本插头到中国插头的电源适配器,来将原来的电源线用起来:
var chinaPlug = {
    type: '中国插头',
    chinaInPlug() {
        console.log('开始供电')
    }
}
var japanPlug = {
    type: '日本插头',
    japanInPlug() {
        console.log('开始供电')
    }
}
/* 日本插头电源适配器 */
function japanPlugAdapter(plug) {
    return {
        chinaInPlug() {
            return plug.japanInPlug()
        }
    }
}
japanPlugAdapter(japanPlug).chinaInPlug()
// 输出:开始供电

 

适配器模式的原理大概如下图:

访问者需要目标对象的某个功能,但是这个对象的接口不是自己期望的,那么通过适配器模式对现有对象的接口进行包装,来获得自己需要的接口格式。
 

3. 适配器模式的应用

适配器模式在日常开发中还是比较频繁的,其实可能已经使用了,但却不知道原来这就是适配器模式。适配器可以将新的软件实体适配到老的接口,也可以将老的软件实体适配到新的接口,具体如何来进行适配,可以根据具体使用场景来灵活使用。
 

(1)业务数据适配

在实际项目中,我们经常会遇到树形数据结构和表形数据结构的转换,比如全国省市区结构、公司组织结构、军队编制结构等等。以公司组织结构为例,在历史代码中,后端给了公司组织结构的树形数据,在以后的业务迭代中,会增加一些要求非树形结构的场景。比如增加了将组织维护起来的功能,因此就需要在新增组织的时候选择上级组织,在某个下拉菜单中选择这个新增组织的上级菜单。或者增加了将人员归属到某一级组织的需求,需要在某个下拉菜单中选择任一级组织。

在这些业务场景中,都需要将树形结构平铺开,但是又不能直接将旧有的树形结构状态进行修改,因为在项目别的地方已经使用了老的树形结构状态,这时可以引入适配器来将老的数据结构进行适配:
/* 原来的树形结构 */
const oldTreeData = [
    {
        name: '总部',
        place: '一楼',
        children: [
            { name: '财务部', place: '二楼' },
            { name: '生产部', place: '三楼' },
            {
                name: '开发部', place: '三楼', children: [
                    {
                        name: '软件部', place: '四楼', children: [
                            { name: '后端部', place: '五楼' },
                            { name: '前端部', place: '七楼' },
                            { name: '技术支持部', place: '六楼' }]
                    }, {
                        name: '硬件部', place: '四楼', children: [
                            { name: 'DSP部', place: '八楼' },
                            { name: 'ARM部', place: '二楼' },
                            { name: '调试部', place: '三楼' }]
                    }]
            }
        ]
    }
]
/* 树形结构平铺 */
function treeDataAdapter(treeData, lastArrayData = []) {
    treeData.forEach(item => {
        if (item.children) {
            treeDataAdapter(item.children, lastArrayData)
        }
        const { name, place } = item
        lastArrayData.push({ name, place })
    })
    return lastArrayData
}
treeDataAdapter(oldTreeData)
// 返回平铺的组织结构

增加适配器后,就可以将原先状态的树形结构转化为所需的结构,而并不改动原先的数据,也不对原来使用旧数据结构的代码有所影响。
 

(2)Vue 计算属性

Vue 中的计算属性也是一个适配器模式的实例,以官网的例子为例:
<template>
    <div id="example">
        <p>Original message: "{{ message }}"</p>  <!-- Hello -->
        <p>Computed reversed message: "{{ reversedMessage }}"</p>  <!-- olleH -->
    </div>
</template>
<script type='text/javascript'>
    export default {
        name: 'demo',
        data() {
            return {
                message: 'Hello'
            }
        },
        computed: {
            reversedMessage: function() {
                return this.message.split('').reverse().join('')
            }
        }
    }
</script>

旧有 data 中的数据不满足当前的要求,通过计算属性的规则来适配成我们需要的格式,对原有数据并没有改变,只改变了原有数据的表现形式。
 

4. 源码中的适配器模式

Axios 是比较热门的网络请求库,在浏览器中使用的时候,Axios 的用来发送请求的 adapter 本质上是封装浏览器提供的 API XMLHttpRequest,
可以看看源码中是如何封装这个 API 的,为了方便观看,进行了一些省略:
module.exports = function xhrAdapter(config) {
    return new Promise(function dispatchXhrRequest(resolve, reject) {
        var requestData = config.data
        var requestHeaders = config.headers
        
        var request = new XMLHttpRequest()
        
        // 初始化一个请求
        request.open(config.method.toUpperCase(),
          buildURL(config.url, config.params, config.paramsSerializer), true)
        
        // 设置最大超时时间
        request.timeout = config.timeout
        
        // readyState 属性发生变化时的回调
        request.onreadystatechange = function handleLoad() { ... }
        
        // 浏览器请求退出时的回调
        request.onabort = function handleAbort() { ... }
        
        // 当请求报错时的回调
        request.onerror = function handleError() { ... }
        
        // 当请求超时调用的回调
        request.ontimeout = function handleTimeout() { ... }
        
        // 设置HTTP请求头的值
        if ('setRequestHeader' in request) {
            request.setRequestHeader(key, val)
        }
        
        // 跨域的请求是否应该使用证书
        if (config.withCredentials) {
            request.withCredentials = true
        }
        
        // 响应类型
        if (config.responseType) {
            request.responseType = config.responseType
        }
        
        // 发送请求
        request.send(requestData)
    })
}
 
 
 
可以看到这个模块主要是对请求头、请求配置和一些回调的设置,并没有对原生的 API 有改动,所以也可以在其他地方正常使用。这个适配器可以看作是对 XMLHttpRequest 的适配,是用户对 Axios 调用层到原生 XMLHttpRequest 这个 API 之间的适配层。
 

5. 适配器模式的优缺点

适配器模式的优点:

1已有的功能如果只是接口不兼容,使用适配器适配已有功能,可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码;
2可扩展性良好,在实现适配器功能的时候,可以调用自己开发的功能,从而方便地扩展系统的功能;
3灵活性好,因为适配器并没有对原有对象的功能有所影响,如果不想使用适配器了,那么直接删掉即可,不会对使用原有对象的代码有影响;


适配器模式的缺点:

会让系统变得零乱,明明调用 A,却被适配到了 B,如果系统中这样的情况很多,那么对可阅读性不太友好。如果没必要使用适配器模式的话,可以考虑重构,如果使用的话,可以考虑尽量把文档完善。
 

6. 适配器模式的适用场景

当你想用已有对象的功能,却想修改它的接口时,一般可以考虑一下是不是可以应用适配器模式。
如果你想要使用一个已经存在的对象,但是它的接口不满足需求,那么可以使用适配器模式,把已有的实现转换成你需要的接口;
如果你想创建一个可以复用的对象,而且确定需要和一些不兼容的对象一起工作,这种情况可以使用适配器模式,然后需要什么就适配什么;
 

7. 适配器模式与其他模式的区别

适配器模式和代理模式、装饰者模式看起来比较类似,都是属于包装模式,也就是用一个对象来包装另一个对象的模式,下面来看一下他们的异同。
 
(1)适配器模式与代理模式
适配器模式: 提供一个不一样的接口,由于原来的接口格式不能用了,提供新的接口以满足新场景下的需求;
代理模式: 提供一模一样的接口,由于不能直接访问目标对象,找个代理来帮忙访问,使用者可以就像访问目标对象一样来访问代理对象;
 
(2)适配器模式、装饰者模式与代理模式
适配器模式: 功能不变,只转换了原有接口访问格式;
装饰者模式: 扩展功能,原有功能不变且可直接使用;
代理模式: 原有功能不变,但一般是经过限制访问的;
 
 

迭代器模式

1. 什么是迭代器模式?

迭代器模式 (Iterator Pattern)用于顺序地访问聚合对象内部的元素,又无需知道对象内部结构。使用了迭代器之后,使用者不需要关心对象的内部构造,就可以按序访问其中的每个元素。

银行里的点钞机就是一个迭代器,放入点钞机的钞票里有不同版次的人民币,每张钞票的冠字号也不一样,但当一沓钞票被放入点钞机中,使用者并不关心这些差别,只关心钞票的数量,以及是否有假币。

这里使用 JavaScript 的方式来点一下钞:
const bills = ['MCK013840031', 'MCK013840032', 'MCK013840033', 'MCK013840034', 'MCK013840035']

bills.forEach(function(bill) {
    console.log('当前钞票的冠字号为 ' + bill)
})

 


由于JavaScript 已经内置了迭代器的实现,所以实现起来非常简单。
 

2. 迭代器的简单实现

前面的 forEach 方法是在 IE9 之后才原生提供的,那么在 IE9 之前的时代里,如何实现一个迭代器呢,可以使用 for 循环自己实现一个 forEach:
 
var forEach = function(arr, cb) {
    for (var i = 0; i < arr.length; i++) {
        cb.call(arr[i], arr[i], i, arr)
    }
}
forEach(['hello', 'world', '!'], function(currValue, idx, arr) {
    console.log('当前值 ' + currValue + ',索引为 ' + idx)
})
// 输出: 当前值 hello,索引为 0
// 输出: 当前值 world,索引为 1
// 输出: 当前值 !    ,索引为 2

 

3. JavaScript 原生支持

随着 JavaScript 的 ECMAScript 标准每年的发展,给越来越多好用的 API 提供了支持,比如 Array 上的 filter、forEach、reduce、flat 等,还有 Map、Set、String 等数据结构,也提供了原生的迭代器支持,给开发提供了很多便利。

JavaScript 中还有很多类数组结构,比如:
arguments:函数接受的所有参数构成的类数组对象;
NodeList:是 querySelector 接口族返回的数据结构;
HTMLCollection:是 getElementsBy 接口族返回的数据结构;

对于这些类数组结构,可以通过一些方式来转换成普通数组结构,以 arguments 为例:
// 方法一
var args = Array.prototype.slice.call(arguments)
// 方法二
var args = [].slice.call(arguments)
// 方法三 ES6提供
const args = Array.from(arguments)
// 方法四 ES6提供
const args = [...arguments];

转换成数组之后,就可以使用 JavaScript 在 Array 上提供的各种方法了。
 

4. ES6 中的迭代器

ES6 规定,默认的迭代器部署在对应数据结构的 Symbol.iterator 属性上,如果一个数据结构具有 Symbol.iterator 属性,就被视为可遍历的,就可以用 for...of 循环遍历它的成员。也就是说,for...of循环内部调用的是数据结构的Symbol.iterator 方法。

for-of 循环可以使用的范围包括 Array、Set、Map 结构、上文提到的类数组结构、Generator 对象,以及字符串。

通过 for-of 可以使用 Symbol.iterator 这个属性提供的迭代器可以遍历对应数据结构,如果对没有提供 Symbol.iterator 的目标使用 for-of 则会抛错:
 
 
var foo = { a: 1 }
for (var key of foo) {
    console.log(key)
}
// 输出: Uncaught TypeError: foo is not iterable
 
 
可以给一个对象设置一个迭代器,让一个对象也可以使用 for-of 循环:
 
 
var bar = {
    a: 1,
    [Symbol.iterator]: function() {
        var valArr = [
            { value: 'hello', done: false },
            { value: 'world', done: false },
            { value: '!', done: false },
            { value: undefined, done: true }
        ]
        return {
            next: function() {
                return valArr.shift()
            }
        }
    }
}
for (var key of bar) {
    console.log(key)
}
// 输出: hello
// 输出: world
// 输出: !
 
可以看到 for-of 循环连 bar 对象自己的属性都不遍历了,遍历获取的值只和 Symbol.iterator 方法实现有关。
 

5. 迭代器模式总结

迭代器模式早已融入我们的日常开发中,在使用 filter、reduce、map 等方法的时候,不要忘记这些便捷的方法就是迭代器模式的应用。当使用迭代器方法处理一个对象时,可以关注与处理的逻辑,而不必关心对象的内部结构,侧面将对象内部结构和使用者之间解耦,也使得代码中的循环结构变得紧凑而优美。
 

观察者模式/发布-订阅模式

1. 什么是观察者模式/发布-订阅模式?

(1)观察者模式:

观察者模式(Observer Pattern)定义了一种一对多的关系,让多个订阅者对象同时监听某一个发布者,或者叫主题对象,这个主题对象的状态发生变化时就会通知所有订阅自己的订阅者对象,使得它们能够自动更新自己。
观察者模式属于行为型模式,行为型模式关注的是对象之间的通讯,观察者模式就是观察者和被观察者之间的通讯。

 

(2)发布-订阅模式:

在23种设计模式中没有发布-订阅模式的,其实它是发布订阅模式的一个别名,但两者又有所不同。这个别名非常形象地诠释了观察者模式里两个核心的角色要素——发布者和订阅者。
很多人在微博上关注了A,那么当A发布微博动态的时候微博就会为我们推送这个动态。在这个例子中,A就是发布者,我们是订阅者,微博就是调度中心,我们和A之间是没有直接信息来往的,都是通过微博平台来协调的,这就是发布-订阅模式。
虽然发布-订阅模式是观察者模式的一个别名,但是发布-订阅模式经过发展,已经独立于观察者模式,成为一种比较重要的设计模式。
这两种模式的最大区别就是发布-订阅模式有一个调度中心:

 

 可以看到,观察者模式是由具体目标调度的,而发布-订阅模式是统一由调度中心调的,所以观察者模式的订阅者与发布者之间是存在依赖的,而发布-订阅模式则不会,这就实现了解耦。

 

2. 观察者模式的实现

发布者:

// 定义发布者类
class Publisher {
  constructor() {
    this.observers = []
    console.log('Publisher created')
  }
  // 增加订阅者
  add(observer) {
    console.log('Publisher.add invoked')
    this.observers.push(observer)
  }
  // 移除订阅者
  remove(observer) {
    console.log('Publisher.remove invoked')
    this.observers.forEach((item, i) => {
      if (item === observer) {
        this.observers.splice(i, 1)
      }
    })
  }
  // 通知所有订阅者
  notify() {
    console.log('Publisher.notify invoked')
    this.observers.forEach((observer) => {
      observer.update(this)
    })
  }
}

订阅者:

// 定义订阅者类
class Observer {
    constructor() {
        console.log('Observer created')
    }
    update() {
        console.log('Observer.update invoked')
    }
}

3. 发布-订阅模式的实现

在 DOM 上绑定的事件处理函数 addEventListener就是使用的发布-订阅模式。
我们经常将一些操作挂载在 onload 事件上执行,当页面元素加载完毕,就会触发注册在 onload 事件上的回调。我们无法预知页面元素何时加载完毕,但是通过订阅 window 的 onload 事件,window 会在加载完毕时向订阅者发布消息,也就是执行回调函数。

window.addEventListener('load', function () {
    console.log('loaded!')
})

下面就来实现一下发布-订阅模式,在实现之前,下来看几个概念:
Publisher :发布者,当消息发生时负责通知对应订阅者
Subscriber :订阅者,当消息发生时被通知的对象
SubscriberMap :持有不同 type 的数组,存储有所有订阅者的数组
type :消息类型,订阅者可以订阅的不同消息类型
subscribe :该方法为将订阅者添加到 SubscriberMap 中对应的数组中
unSubscribe :该方法为在 SubscriberMap 中删除订阅者
notify :该方法遍历通知 SubscriberMap 中对应 type 的每个订阅者

其结构如下:

 

 

 

这里以在商城订阅鞋的消息为例:

class Publisher {
    constructor() {
        this._subsMap = {}
    }
    
    // 消息订阅 
    subscribe(type, cb) {
        if (this._subsMap[type]) {
            if (!this._subsMap[type].includes(cb))
                this._subsMap[type].push(cb)
        } else this._subsMap[type] = [cb]
    }
    
    // 消息退订 
    unsubscribe(type, cb) {
        if (!this._subsMap[type] ||
            !this._subsMap[type].includes(cb)) return
        const idx = this._subsMap[type].indexOf(cb)
        this._subsMap[type].splice(idx, 1)
    }
    
    // 消息发布 
    notify(type, ...payload) {
        if (!this._subsMap[type]) return
        this._subsMap[type].forEach(cb => cb(...payload))
    }
}
const adadis = new Publisher()
adadis.subscribe('运动鞋', message => console.log('152xxx' + message))    // 订阅运动鞋
adadis.subscribe('运动鞋', message => console.log('138yyy' + message))
adadis.subscribe('帆布鞋', message => console.log('139zzz' + message))    // 订阅帆布鞋
adadis.notify('运动鞋', ' 运动鞋到货了 ')   // 打电话通知买家运动鞋消息
adadis.notify('帆布鞋', ' 帆布鞋售罄了 ') // 打电话通知买家帆布鞋消息
// 输出:  152xxx 运动鞋到货了 
// 输出:  138yyy 运动鞋到货了 
// 输出:  139zzz 帆布鞋售罄了 

4. Vue中的发布-订阅模式

(1)EventBus

在Vue中有一套事件机制,其中一个用法是 EventBus。在多层组件的事件处理中,如果你觉得一层层 $on、$emit 比较麻烦,那就可以使用 EventBus 来解决组件间的数据通信问题。

eventBus事件总线适用于父子组件、非父子组件等之间的通信,使用步骤如下:

(1)创建事件中心管理组件之间的通信

// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()

(2)发送事件

假设有两个兄弟组件firstCom和secondCom:

<template>
  <div>
    <first-com></first-com>
    <second-com></second-com>
  </div>
</template>
<script>
import firstCom from './firstCom.vue'
import secondCom from './secondCom.vue'
export default {
  components: { firstCom, secondCom }
}
</script>

在firstCom组件中发送事件:

<template>
  <div>
    <button @click="add">加法</button>    
  </div>
</template>
<script>
import {EventBus} from './event-bus.js' // 引入事件中心
export default {
  data(){
    return{
      num:0
    }
  },
  methods:{
    add(){
      EventBus.$emit('addition', {
        num:this.num++
      })
    }
  }
}
</script>

(3)接收事件

在secondCom组件中发送事件:

 
<template>
  <div>求和: {{count}}</div>
</template>
<script>
import { EventBus } from './event-bus.js'
export default {
  data() {
    return {
      count: 0
    }
  },
  mounted() {
    EventBus.$on('addition', param => {
      this.count = this.count + param.num;
    })
  }
}
</script>

在上述代码中,这就相当于将num值存贮在了事件总线中,在其他组件中可以直接访问。事件总线就相当于一个桥梁,不用组件通过它来通信。

实现组件间的消息传递时,如果是中大型项目还是推荐使用 Vuex,因为如果 Bus 上的事件挂载过多,就可能分不清消息的来源和先后顺序,对可维护性是一种破坏,后期维护起来会很困难。

 

(2)Vue源码

发布-订阅模式在源码中应用很多,特别是现在很多前端框架都会有的双向绑定机制的场景,这里以现在很火的 Vue 为例,来分析一下 Vue 是如何利用发布-订阅模式来实现视图层和数据层的双向绑定。先借用官网的双向绑定原理图:

 

 

 组件渲染函数(Component Render Function)被执行前,会对数据层的数据进行响应式化。响应式化大致就是使用 Object.defineProperty 把数据转为 getter/setter,并为每个数据添加一个订阅者列表的过程。这个列表是 getter 闭包中的属性,将会记录所有依赖这个数据的组件。也就是说,响应式化后的数据相当于发布者。

每个组件都对应一个 Watcher 订阅者。当每个组件的渲染函数被执行时,都会将本组件的 Watcher 放到自己所依赖的响应式数据的订阅者列表里,这就相当于完成了订阅,一般这个过程被称为依赖收集(Dependency Collect)。
组件渲染函数执行的结果是生成虚拟 DOM 树(Virtual DOM Tree),这个树生成后将被映射为浏览器上的真实的 DOM树,也就是用户所看到的页面视图。
当响应式数据发生变化的时候,也就是触发了 setter 时,setter 会负责通知(Notify)该数据的订阅者列表里的 Watcher,Watcher 会触发组件重渲染(Trigger re-render)来更新(update)视图。

Vue 的源码:

// src/core/observer/index.js 响应式化过程
Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
        // ...
        const value = getter ? getter.call(obj) : val // 如果原本对象拥有getter方法则执行
        dep.depend()                     // 进行依赖收集,dep.addSub
        return value
    },
    set: function reactiveSetter(newVal) {
        // ...
        if (setter) { setter.call(obj, newVal) }    // 如果原本对象拥有setter方法则执行
        dep.notify()               // 如果发生变更,则通知更新
    }
})

这个 dep 上的 depend 和 notify 就是订阅和发布通知的具体方法。简单来说,响应式数据是消息的发布者,而视图层是消息的订阅者,如果数据更新了,那么发布者会发布数据更新的消息来通知视图更新,从而实现数据层和视图层的双向绑定。

 

5. 观察者模式的优缺点

观察者模式有以下优点:
观察者模式在被观察者和观察者之间建立一个抽象的耦合。被观察者角色所知道的只是一个具体观察者列表,每一个具体观察者都符合一个抽象观察者的接口。被观察者并不认识任何一个具体观察者,它只知道它们都有一个共同的接口。 由于被观察者和观察者没有紧密地耦合在一起,因此它们可以属于不同的抽象化层次。如果被观察者和观察者都被扔到一起,那么这个对象必然跨越抽象化和具体化层次。
观察者模式支持广播通讯。被观察者会向所有的登记过的观察者发出通知,

观察者模式有以下缺点:
如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。
如果在被观察者之间有循环依赖的话,被观察者会触发它们之间进行循环调用,导致系统崩溃。在使用观察者模式是要特别注意这一点。
如果对观察者的通知是通过另外的线程进行异步投递的话,系统必须保证投递是以自恰的方式进行的。
虽然观察者模式可以随时使观察者知道所观察的对象发生了变化,但是观察者模式没有相应的机制使观察者知道所观察的对象是怎么发生变化的。

 

6. 发布-订阅模式的优缺点

发布-订阅模式最大的优点就是解耦:
时间上的解耦 :注册的订阅行为由消息的发布方来决定何时调用,订阅者不用持续关注,当消息发生时发布者会负责通知;
对象上的解耦 :发布者不用提前知道消息的接受者是谁,发布者只需要遍历处理所有订阅该消息类型的订阅者发送消息即可(迭代器模式),由此解耦了发布者和订阅者之间的联系,互不持有,都依赖于抽象,不再依赖于具体;

由于它的解耦特性,发布-订阅模式的使用场景一般是:当一个对象的改变需要同时改变其它对象,并且它不知道具体有多少对象需要改变。发布-订阅模式还可以帮助实现一些其他的模式,比如中介者模式。

发布-订阅模式也有缺点:
增加消耗 :创建结构和缓存订阅者这两个过程需要消耗计算和内存资源,即使订阅后始终没有触发,订阅者也会始终存在于内存;
增加复杂度 :订阅者被缓存在一起,如果多个订阅者和发布者层层嵌套,那么程序将变得难以追踪和调试。

缺点主要在于理解成本、运行效率、资源消耗,特别是在多级发布-订阅时,情况会变得更复杂。

posted @ 2022-11-17 15:20  广东靓仔-啊锋  阅读(424)  评论(0编辑  收藏  举报