JS常用设计模式

83 阅读13分钟

1.单例模式

普通单例模式

“单例模式” 就是通过类创建实例后,每次创建和获取都返回同一个实例,下面是 “单例模式” 最基本的实现。

/* ES6 写法 */
class Person {
  constructor(name) {
    this.name = name;
  }
  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Person(name);
    }
    return this.instance;
  }
}

const w1 = Person.getInstance('hello');
const w2 = Person.getInstance('world');

console.log(w1 === w2); // true
/* ES5 写法 */
function Person(name) {
  this.name = name;
}

Person.getInstance = (function () {
  let instance;
  return function (name) {
    if (!instance) {
      instance = new Person(name);
    }
    return instance;
  }
})();

const w1 = Person.getInstance('hello');
const w2 = Person.getInstance('world');

console.log(w1 === w2); // true

上面分别用 ES6 和 ES5 的方式实现了一个基本的单例模式,创建 Person 的实例时需要通过 getInstance 静态方法,这样第一次会创建一个实例,再次调用时会将之前创建的实例返回,达到单例的目的。

上面单例模式的缺点:

* 类的使用者必须要知道这是一个单例的类,创建和获取实例必须通过调用 getInstance 方法;
* 并不能真正阻止类的使用者通过 new 关键字创建出新的实例。

透明单例模式

“透明单例模式” 可以解决上面普通 “单例模式” 的不足,希望可以直接使用 new 关键字来创建类的实例,如果已经创建,再次通过 new 创建,则会返回之前创建的实例。

/* 透明单例模式 */
const Person = (function () {
  let instance;

  return function (name) {
    if (instance) {
      return instance;
    } else {
      this.name = name;
      instance = this;
    }
  }
})();

const w1 = new Person('hello');
const w2 = new Person('world');

console.log(w1 === w2); // true

“透明单例模式” 的原理是创建一个自执行函数,内部创建一个私有变量 instance 用来存储创建的实例,并通过闭包返回一个构造函数,用变量 Person 接收,当使用 new 创建实例时,先检测私有变量 instance 是否有值,如果没值则创建实例,如果有值则直接返回 instance(利用 new 关键字和构造创建实例的原理实现)。

缺点:违反了单一职责原则(一个函数只做一件事),自执行函数返回的构造函数已经不止单纯用作构建实例,同时处理了单例的判断逻辑。

单例与构建分离

针对上面 “透明单例模式” 的缺点,下面将构造函数单例处理与构建逻辑进行分离。

/* 单例逻辑与构建逻辑分离 */
// 真正的构造函数
function Person(name) {
  this.name = name;
}

Person.prototype.getName = function () {
  console.log(this.name);
}

// 新的构造函数
const CreatePerson = (function () {
  let instance;

  return function (name) {
    if (!instance) {
      instance = new Person(name);
    }
    return instance;
  }
})();

const w1 = new CreatePerson('hello');
const w2 = new CreatePerson('world');

console.log(w1 === w2); // true

上面代码将单例的逻辑与构造函数的逻辑进行了分离,真正用于构造实例的类是 Person,用于处理单例逻辑的是自执行函数返回的函数,使用 CreatePerson 变量接收,这个函数也同时约定好被当做构造函数使用(通过 new 关键字调用和直接执行效果相同)。

缺点:生成的新构造函数名字(CreatePerson)是固定的,用来创建实例的这个类(Person)也是固定的,不够灵活。
/* 封装变化 */
const CreateSingle = function (Constructor) {
  let instance;

  const SingleConstructor = function () {
    if (!instance) {
      Constructor.apply(this, arguments);
      instance = this;
    }
    return instance;
  }

  // 实现原型继承
  SingleConstructor.prototype = Object.create(Constructor.prototype);
  return SingleConstructor;
}
/* 使用方式 */
// 构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

function Dailog(name) {
  this.name = name
}

// 原型方法
Person.prototype.sayHi = function () {
  console.log(this.name + ':' + this.age);
}

Dailog.prototype.getName = function () {
  console.log(this.name);
}

// 创建新的构造函数并生成实例
const CreatePerson = CreateSingle(Person);
const w1 = new CreatePerson('hello', 18);
const w2 = new CreatePerson('world', 20);

const CreateDailog = CreateSingle(Dailog);
const s1 = new CreateDailog('model');
const s2 = new CreateDailog('view');

console.log(w1 === w2); // true
console.log(s1 === s2); // true

上面我们把创建单例的逻辑进行了封装变成了一个通用的逻辑,对于不同构造函数所创建实例,只需要传入这个构造函数并生成新的构造函数,需要注意的是,新的构造函数无法继承原构造函数的原型方法,所以通过继承实现的。

单例模式的应用

命名空间

在编写代码时,我们有时候需要人为的创建命名空间,以防止变量的相互污染,这是可以使用 “单例模式” 来实现。

/* 创建命名空间的方法 */
// 存储工具方法
const utils = {};

// 定义命名空间
utils.define = function (namespace, fn) {
  // 获取命名空间的数组
  const namespaces = namespace.split('.');

  // 最后一项为设定方法的属性名
  const methodName = namespaces.pop();

  // 定义变量存储当前命名空间的引用,默认为 utils(根命名空间)
  let current = utils;

  for (let i = 0; i < namespaces.length; i++) {
    const currentNamespace = namespaces[i];

    // 当某一个命名空间没有时,则创建这个命名空间(单例模式)
    if (!current[currentNamespace]) {
      current[currentNamespace] = {};
    }

    // 否则让当前命名空间指向已有的命名空间
    current = current[currentNamespace];
  }

  // 将传入的函数设定给最后一级命名空间的属性上
  current[methodName] = fn;
}
/* 命名空间的创建和使用 */
// 通过命名空间定义方法
utils.define('dom.class.addClass', function () {
  console.log('dom.class.addClass');
});

utils.define('string.trim', function () {
  console.log('string.trim');
});

utils.define('event.prevent', function () {
  console.log('event.prevent');
});

// 使用方法
utils.dom.class.addClass('title'); // dom.class.addClass
utils.string.trim(' hello '); // string.trim
utils.event.prevent(); // event.prevent

上面代码的设计希望通过 utils 对象的 define 方法按照传入的表示命名空间的字符串去创建方法,基本实现思路和逻辑是,当一个属性是第一次出现时,创建一个对象作为该命名空间,当再次出现时则不会重复创建命名空间(因为会出现覆盖的问题),而是沿用之前创建的命名空间。

LRU 缓存

LRU 全称为 Least Recently Used,为最近使用的意思,缓存的方式为访问一个元素时,则将其标记为活跃,当存储时,如果超出容量则删除最不常用的元素。

/* 创建 LRU 缓存类 */
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.members = [];
  }
  put(key, val) {
    let oldestIndex = -1; // 最不活跃项的索引
    let oldestAge = -1; // 最不活跃项的活跃数值
    let found = false;

    for (let i = 0; i < this.members.length; i++) {
      const member = this.members[i];

      // 如果找到当前最不活跃的项,将 oldestAge 和 oldestIndex 更新为该项对应值
      if (member.age > oldestAge) {
        oldestAge = member.age;
        oldestIndex = i;
      }

      // 如果添加项在原本 members 中已经存在,则更新 age 的值为 0
      if (member.key === key) {
        this.members[i] = { key, val, age: 0 };
        found = true; // 为了跳过 push 新增的环节
      } else {
        // 否则其他所有项 age 自增
        member.age++;
      }
    }

    if (!found) {
      if (this.members.length >= this.capacity) {
        this.members.splice(oldestIndex, 1);
      }
      this.members.push({ key, val, age: 0 });
    }
  }
  get(key) {
    for (let i = 0; i < this.members.length; i++) {
      const member = this.members[i];

      if (member.key === key) {
        member.age = 0;
        return member.val;
      }
    }
    return -1;
  }
}

上面是一个创建 LRU 缓存的类,用数组管理成员,put 方法用于新增成员,get 方法用于访问成员,当访问成员时,成员的 age 清零,代表最近活跃,当新增元素时,如果该元素已存在,则做覆盖操作,如果不存在,则推入数组中,age 设置为零,其他成员 age 自增,若数组超出容量时,先找到 age 最大的元素删除,再将新的元素推入数组,上面是一个直观但性能较差的实现,如果有兴趣可以使用链表进行优化。

/* 使用 LRU 缓存 */
const cache = new LRUCache(2);

cache.put('1', 1);
console.log(cache.members);
// [ { key: '1', val: 1, age: 0 } ]

cache.put('2', 2);
console.log(cache.members);
// [ { key: '1', val: 1, age: 1 }, { key: '2', val: 2, age: 0 } ]

cache.put('3', 3);
console.log(cache.members);
// [ { key: '2', val: 2, age: 1 }, { key: '3', val: 3, age: 0 } ]

cache.put('2', 'hello');
console.log(cache.members);
// [ { key: '2', val: 'hello', age: 0 }, { key: '3', val: 3, age: 1 } ]

总结

“单例模式” 是设计模式中非常好理解的一个,使用还是非常广泛的,在 Redux 等众多的第三方库中也有所体现.

2.发布/订阅和观察者模式

发布/订阅模式

发布/订阅模式实现

在说 “观察者模式” 之前一定要说一下 “发布/订阅模式”,因为这两个模式非常相似又有些不同,最重要的是在设计模式中使用频繁。

/* 发布/订阅的类 */
class Event {
  constructor() {
    this.events = {};
  }
  on(type, fn) {
    (this.events[type] || (this.events[type] = [])).push(fn)
  }
  emit(type) {
    if (this.events[type]) {
      this.events[type].forEach(fn => fn());
    }
  }
}

上面的类有一个基本属性 events,值为对象,用来存储不同类型的事件集合,原型方法 on 是用来订阅事件,第一个参数 type 为订阅事件的类型,fn 是要被执行的事件,emit 方法用来执行某个类型所有的事件。

/* 发布/订阅的使用 */
const event = new Event();

// 订阅事件
event.on('say', () => console.log('hello'));
event.on('say', () => console.log('world'));

// 发布事件
event.emit('say');

// hello
// world

发布/订阅模式的应用

浏览器事件监听:
<button id="btn">click</button>
<script>
  const btn = document.getElementById('btn');

  btn.addEventListener('click', () => console.log(1));
  btn.addEventListener('click', () => console.log(2));
  btn.addEventListener('click', () => console.log(3));
</script>

当点击按钮触发事件时,三个回调函数会按照添加的顺序依次执行。

Promise 的异步调用的回调管理:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve('res'), 3000);
});

promise.then(data => console.log(1, data));
promise.then(data => console.log(2, data));

上面给同一个 Promise 实例的 then 方法中添加了两个回调函数,因为在创建 Promise 实例时内部使用了定时器,所以状态的变化延后了 3s,其实在 Promise 内部也是通过队列来对 then 的回调进行统一管理,在状态发生变化后立即循环执行。

在 Node.js 中,有一个核心模块 events 提供的类 EventEmitter,几乎所有的事件都是基于这个模块实现的,如流的 data 和 end 事件、http 的 request 事件,而 EventEmitter 的内部原理就是 “发布/订阅模式”。

观察者模式

观察者模式的简单实现

下面我们是 “观察者模式” 的案例,通过上面 “发布/订阅模式” 的实现来对比一下异同。

/* 观察者模式 */
// 被观察者类
class Star {
  constructor(name) {
    this.name = name;
    this.state = '';
    this.observers = [];
  }
  getState() {
    return this.state;
  }
  setState(state) {
    this.state = state;
    this.notify(); // 更新状态后通知
  }
  attach(observer) {
    this.observers.push(observer); // 添加观察者
  }
  notify() {
    // 订阅状态的观察者更新修改后的状态
    this.observers.forEach(observer => observer.update());
  }
}

// 观察者类
class Fan {
  constructor(name, star) {
    this.name = name;
    this.star = star;
    this.star.attach(this);
  }
  update() {
    console.log('我喜欢的明星喜欢' + this.star.getState() + ',我也喜欢。');
  }
}

在 “观察者模式” 中有两个基本的类,观察者和被观察者,被观察者提供状态 state,观察者去使用这个状态,当被观察者更新状态时会主动发布到订阅了状态的观察中,实现同步更新。

const star = new Star('Super Star');
const fan = new Fan('张三', star);

star.setState('绿色');

// 我喜欢的明星喜欢绿色,我也喜欢。

观察者模式的应用

Vue 框架的数据响应式原理及 watch 方法:

<div id="root">
  <p>FullName: {{fullName}}</p>
  <p>
    FirstName:
    <input type="text" v-model="firstName" />
  </p>
  <p>
    LastName:
    <input type="text" v-model="lastName" />
  </p>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
  new Vue({
    el: '#root',
    data: {
      firstName: '张',
      lastName: '三',
      fullName: '张三'
    },
    watch: {
      firstName(newName, oldName) {
        this.fullName = newName + this.lastName;
      }
    }
  });
</script>

在 Vue 2.x 版本的数据响应式和 watch 监听的底层,就使用了 “观察者模式”,在模板解析过程中为变量添加观察者(watcher),在使用 Object.defineProperty 的 getter 和 setter 进行劫持数据,数据获取和更改会触发 get 和 set 方法,进而执行订阅和通知的逻辑,而通知的过程中调用了被统一管理的观察者的 update 方法,实现了视图层与数据层的同步。

redux 的 createStore 方法:

function createStore(reducer) {
  let state;
  let listeners = [];

  // 获取 store
  const getState = () => JSON.parse(JSON.stringify(state));

  // 订阅方法
  const subscribe = fn => {
    listeners.push(fn);

    // 取消订阅方法
    return () => {
      listeners = listeners.filter(listener => listener !== fn);
    };
  }

  // 派发动作
  const dispatch = action => {
    state = reducer(state, action);
    listeners.forEach(listener => listener());
  }

  dispatch({ type: '@INIT' });

  return {
    getState,
    subscribe,
    dispatch
  };
}

在 redux 的实现思想中也包含了 “观察者模式”,例如在 redux 与 React 的配合使用,redux 提供了订阅的方法 subscribe 和派发动作更新 store 的方法 dispatch,React 组件会使用 store 中提供的状态数据,这个 store 就是被观察者,而观察者就是 React 的各个组件,当使用 dispatch 派发动作更新数据时,会执行所有的观察者中的监听函数,实现组件数据与 store 的同步。

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

观察者模式是由 “发布/订阅模式” 演变过来的,都存在订阅、通知的事件机制,“发布/订阅模式” 是对订阅的事件进行统一管理,主动触发通知,依次执行订阅的事件,而 “观察者模式” 是通过一个单独类去订阅观察者,当状态发生变化时通知到各个 “观察者” 实现状态的更新同步。

“发布/订阅模式” 与 “观察者模式” 的区别:

* “发布/订阅模式” 事件是统一由调度中心调度,订阅发布不存在依赖;
* “观察者模式” 事件是被观察者调度,订阅与发布是存在依赖的;

总结

“观察者模式” 的意义就在于可以使多个对象数据重合的部分进行复用,同时还可以对各个对象之间解耦,最重要的是数据更新可以及时通知所有数据的使用者进行数据同步.

3.工厂模式

简单工厂模式

“简单工厂模式” 是由一个工厂对象决定创建出哪一种产品类的实例。

/* 直接创建子类实例 */
// 父类
class Plant {
  constructor(name) {
    this.name = name;
  }
  grow() {
    console.log('I am growing!');
  }
}

// 子类 —— Apple
class Apple extends Plant {
  constructor(name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 子类 —— Orange
class Orange extends Plant {
  constructor(name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 直接创建子类的实例
const apple = new Apple('苹果', '甜');
const orange = new Orange('橘子', '酸');

console.log(apple.flavour); // 甜
console.log(orange.flavour); // 酸

上面创建子类实例的方式是使用 new 关键字直接创建,这种创建方式使产生的实例和具体的类紧紧的耦合在一起,并依赖于类的具体实现,如果在子类可能随时发生变化的代码中,将对维护造成麻烦,使用 “简单工厂模式” 可以对产生的实例和具体的类进行解耦,且不必关心子类的具体实现和在未来是否发生变化。

/* 使用简单工厂模式创建子类的实例 */
// 父类
class Plant {
  constructor(name) {
    this.name = name;
  }
  grow() {
    console.log('I am growing!');
  }
}

// 子类 —— Apple
class Apple extends Plant {
  constructor(name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 子类 —— Orange
class Orange extends Plant {
  constructor(name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 工厂类
class Factory {
  static create(type) {
    switch (type) {
      case 'apple':
        return new Apple('苹果', '甜');
      case 'orange':
        return new Orange('桔子', '酸');
      default:
        throw new Error('no constructor!');
    }
  }
}

// 使用简单工厂创建子类实例
const apple = Factory.create('apple');
const orange = Factory.create('orange');

console.log(apple.flavour); // 甜
console.log(orange.flavour); // 酸

从上面代码看,我们只需要通过类型就可以得到某一个子类的实例,不需要知道子类是谁,以及具体实现,并在工厂 Factory 中做了错误处理,可以不必担心未来某一个子类发生变化或者被删除的问题。

/* 简单工厂经典案例 —— jQuery */
// jQuery 的构造函数
class JQuery {
  constructor (selector) {
    this.selector = selector;
    const elements = document.querySelectorAll(selector);
    this.length = elements.length;

    for (let i = 0; i < this.length; i++) {
      this[i] = elements[i];
    }
  }
  html() {
    return this[0].innerHTML;
  }
}

// 简单工厂函数
window.$ = function (selector) {
  return new JQuery(selector);
}

// 获取 li 标签并调用 html 方法
const html = $('li').html();
/* 简单工厂经典案例 —— React 虚拟 DOM */
// 创建虚拟 DOM 的构造函数
class VNode {
  constructor(tagName, attrs, children) {
    this.tagName = tagName;
    this.attrs = attrs;
    this.children = children;
  }
}

// 挂在 React 对象上的简单工厂函数
React.createElement = function (tagName, attrs, children) {
  return new VNode(tagName, attrs, children);
}
简单工厂模式的缺点:不满足开放封闭原则,内部可以随意修改,新增、修改子类都需要修改工厂类内部代码,在扩展的过程中工厂类的代码将会越来越臃肿。

工厂方法模式

“工厂方法模式” 可以规避 “简单工厂模式” 的缺点,又称为 “多态性工厂模式”,核心的工厂类不再负责创建出哪一种产品类的实例,而是将具体创建的工作交给子类去做。

/* 基本的工厂方法模式使用 */
// 父类
class Plant {
  constructor(name) {
    this.name = name;
  }
}

// 子类 —— Apple
class Apple extends Plant {
  constructor (name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 子类 —— Orange
class Orange extends Plant {
  constructor (name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 父类工厂(提供工厂类所共有的内容,依赖倒置原则,依赖抽象而不依赖实现)
class Factory {
  create () {}
}

// 子类工厂 —— AppleFactory
class AppleFactory extends Factory {
  static create() {
    return new Apple('苹果', '甜');
  }
}

// 子类工厂 —— OrangeFactory
class OrangeFactory extends Factory {
  static create() {
    return new Orange('桔子', '酸');
  }
}

// 创建实例
const apple = AppleFactory.create();
const orange = OrangeFactory.create();

console.log(apple.flavour); // 甜
console.log(orange.flavour); // 酸

上面是一个基础的 “工厂方法模式” 使用,解决了 “简单工厂模式” 扩展的问题(遵循开放封闭原则),创建实例虽然不耦合具体的类,但是耦合工厂的子类,下面可以通过文件拆分进行解耦。

/* plant.js */
// 父类
class Plant {
  constructor(name) {
    this.name = name;
  }
}

module.exports = Plant;
/* factory.js */
// 父类工厂(提供工厂类所共有的内容,依赖倒置原则,依赖抽象而不依赖实现)
class Factory {
  create () {}
}

module.exports = Factory;
/* apple.js */
const Plant = require('./plant');
const Factory = require('./factory');

// 子类 —— Apple
class Apple extends Plant {
  constructor (name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 子类工厂 —— AppleFactory
class AppleFactory extends Factory {
  static create() {
    return new Apple('苹果', '甜');
  }
}

module.exports = AppleFactory;
/* orange.js */
const Plant = require('./plant');
const Factory = require('./factory');

// 子类 —— Orange
class Orange extends Plant {
  constructor (name, flavour) {
    super(name);
    this.flavour = flavour;
  }
}

// 子类工厂 —— OrangeFactory
class OrangeFactory extends Factory {
  static create() {
    return new Orange('桔子', '酸');
  }
}

module.exports = OrangeFactory;
/* setting.js */
// 配置文件,将要创建实例的类型与对应的工厂关联起来
const setting = {
  apple: './apple',
  orange: './orange',
};

module.exports = setting;
/* use.js */
const setting = require('./setting');

const apple = require(setting['apple']).create();
const orange = require(setting['orange']).create();

console.log(apple.flavour); // 甜
console.log(orange.flavour); // 酸

使用上面这样的 “工厂方法模式”,扩展时只需要新增一个文件,在文件中定义具体创建实例的类和工厂类就可以了,一般会有一个配置文件将要创建实例的类型和对应的工厂关联起来,创建对应的实例只需通过类型和配置文件找到对应的工厂执行 create 方法即可。

抽象工厂模式

// 父类 —— Icon
class Icon {
  render() {}
}

// 父类 —— Button
class Button {
  render() {}
}

// 子类 —— AppleIcon 苹果图标
class AppleIcon extends Icon {
  render() {
    console.log('绘制 Mac 的图标');
  }
}

// 子类 —— AppleButton 苹果按钮
class AppleButton extends Button {
  render() {
    console.log('绘制 Mac 的按钮');
  }
}

// 子类 —— WindowsIcon Windows 图标
class WindowsIcon extends Icon {
  render() {
    console.log('绘制 Windows 的图标');
  }
}

// 子类 —— WindowsButton Windows 按钮
class WindowsButton extends Button {
  render() {
    console.log('绘制 Windows 的按钮');
  }
}

// 父类工厂
class Factory {
  createIcon() {} // 创建图标
  createButton() {} // 创建按钮
}

// 子类工厂 —— AppleFactory 用于创建苹果族产品实例
class AppleFactory extends Factory {
  createIcon() {
    return new AppleIcon();
  }
  createButton() {
    return new AppleButton();
  }
}

// 子类工厂 —— WindowsFactory 用于创建 Windows 族产品实例
class WindowsFactory extends Factory {
  createIcon() {
    return new WindowsIcon();
  }
  createButton() {
    return new WindowsButton();
  }
}

// 创建苹果工厂实例
const appleFactory = new AppleFactory();

// 创建苹果族产品
appleFactory.createIcon().render(); // 绘制 Mac 的图标
appleFactory.createButton().render(); // 绘制 Mac 的按钮

// 创建 Windows 工厂实例
const wondowsFactory = new WindowsFactory();

// 创建 Windows 族产品
wondowsFactory.createIcon().render(); // 绘制 Windows 的图标
wondowsFactory.createButton().render(); // 绘制 Windows 的按钮

在上面案例中,按照 “抽象工厂模式” 的说法,多个抽象角色指的是 Apple 和 Windows,Icon 和 Button,工厂分为 AppleFactory 和 WindowsFactory 两类,可以分别创建对应产品的 Icon 和 Button 实例。

总结

上面几种工厂模式中,“简单工厂模式” 在框架开发中使用居多,“工厂方法模式” 更多在一些比较老的且复杂的项目中用作业务模块封装和抽象,“抽象工厂模式” 在前端并不常用,应用于后端偏多。

4.适配器模式

适配器模式的概念

“适配器模式” 是指类的使用者和类的接口定义格式不符合时,通过一个中间类进行转换。

// 类 Power
class Power {
  charge() {
    return '220V';
  }
}

// 适配器
class Adaptor {
  constructor(Power) {
    this.power = new Power();
  }
  chargeTransform() {
    return this.power.charge() + ' => 22V';
  }
}

// 类 Power 的使用者
class Notepad {
  constructor(Power) {
    this.adaptor = new Adaptor(Power);
  }
  use() {
    console.log(this.adaptor.chargeTransform());
  }
}

const notepad = new Notepad(Power);
notepad.use(); // 220V => 22V

上面代码中有三个类,Power 类为电源,提供 220V 电压,Notepad 为我们的电子设备,使用电压 22V,明显两个类是不匹配的,此时的 Adaptor 就是一个适配器,作用是连接 Power 与 Notepad,将 220V 转换为 22V。

适配器模式中,通常作为适配器的类内部会存储被转换类实例的引用。

适配器模式的应用

适配参数和返回数据

在浏览器通过 Ajax 与服务端交互时,封装的请求方法会有默认参数,如果传入了参数则使用传入的参数,如果没有传入,则使用默认的参数,这是参数的适配。

在请求响应后,后端会返回给我们 JSON 格式的数据,我们在使用时希望转换成对象使用,这个转换的适配是数据接口的适配。

// 请求方法
function ajax(options) {
  const defaultOptions = {
    method: 'GET',
    dataType: 'JSON'
  };

  initParams(options, defaultOptions); // 适配参数
}

// 参数适配器
function initParams(options, defaultOptions) {
  for (let attr in options) {
    defaultOptions[attr] = options[attr] || defaultOptions[attr];
  }

  return defaultOptions;
}

// 数据适配器
function tranformData(data) {
  return JSON.parse(data);
}

// 使用适配器
ajax({
  url: 'www.pandashen.com',
  method: 'POST',
  success(json) {
    const result = tranformData(json); // 适配返回数据
    console.log(result);
  }
});

适配转换 Promise

在 Node.js 的 fs 模块中有很多异步的方法,比如 readFile,读取文件获取结果后想要继续读取下一个文件,以此类推就产生了 “回调地狱”,代码的可读性和维护性会变差,我们可以通过 “适配器模式” 将这些方法转化为 Promise 实例。

const fs = require('fs');

// 适配成 Promise
function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn.call(null, ...args, (err, data) => {
        err ? reject(err) : resolve(data);
      });
    });
  }
}

// 使用适配后的方法
const readFile = promisify(fs.readFile);
readFile('index.txt', 'utf-8').then(data => {
  console.log(data); // Hello world
});

适配技术栈变更后的旧代码

在一些老项目是 jQuery 的技术栈,请求也使用的是自带的 .ajax,如果一天项目中决定移除jQuery,请求方法.ajax,如果一天项目中决定移除 jQuery,请求方法 .ajax 自然也跟着移除了,假设我们想使用 fetch 来代替 .ajax,则要修改大量的代码,这时“适配器模式”可以对fetch进行适配,让我们继续沿用.ajax,则要修改大量的代码,这时 “适配器模式” 可以对 fetch 进行适配,让我们继续沿用 .ajax 的写法。

// 适配器
window.$ = {
  ajax(options) {
    return fetch(options.url, {
      method: options.type || 'GET',
      body: JSON.stringifily(options.data || {})
    }).then(res => res.json());
  }
};

// $.ajax 的旧代码
$.ajax({
  url: 'pandashen.com/info',
  type: 'POST',
  dataType: 'json',
  data: { id: 1 }
}).then(function (data) {
  console.log(data);
});

总结

“适配器模式” 是很常用的设计模式之一,Vue 的 computed 计算属性、Koa 兼容 1.x 和 2.x 版本的转换中间件 koa-convert 都应用了 “适配器模式”.

5.装饰器模式

装饰器模式概念

“装饰器模式” 是结构型模式之一,在不改变原有对象结构的前提下,给对象添加新功能,也可以理解 “装饰器模式” 是将一个对象嵌入另一个对象之中,相当于一个对象被另一个对象包装,包装其他对象的对象被称为 “装饰器”。

装饰器模式和适配器模式

/* 装饰器模式案例 */
// 类 Duck
class Duck {
  constructor(name) {
    this.name = name;
  }
  eat(food) {
    console.log(this.name + '吃' + food);
  }
}

// 装饰器类 TangDuck,装饰 Duck 类
class TangDuck {
  constructor(name) {
    this.duck = new Duck(name);
  }
  eat(food) {
    this.duck.eat(food);
    console.log('说谢谢');
  }
}

const tangDuck = new TangDuck('唐老鸭');
tangDuck.eat('苹果');
// 唐老鸭吃苹果
// 说谢谢
/* 适配器模式案例 */
// 类 Power
class Power {
  charge() {
    return '220V';
  }
}

// 适配器
class Adaptor {
  constructor(Power) {
    this.power = new Power();
  }
  chargeTransform() {
    return this.power.charge() + ' => 22v';
  }
}

// 类 Power 的使用者
class Notepad {
  constructor(Power) {
    this.adaptor = new Adaptor(Power);
  }
  use() {
    console.log(this.adaptor.chargeTransform());
  }
}

上面分别是 “装饰器模式” 和 “适配器模式” 的案例,但直接看代码可能会将两者混淆,原因是 “适配器” 和 “装饰器” 的类都存在了一个被装饰或者适配转换的类的引用,不同的是,“装饰器” 仅仅是对某一个类进行包装,并不会改变原来类的结构,而 “适配器” 的作用更多是去建立一个类和另一个类之间的关系和转换

装饰器模式和继承

通过上一节,我们已经知道了什么是 “装饰器模式”,下面有一个更直观的例子,我们有一个基础类 Coffee,组成是咖啡加水,这个基础上可以加奶、糖、冰,需求是可以组合加入上面的其他原料,并计算出对应的价格,大家可能第一时间想到的是继承的方式实现。

/* 继承的实现方式 */
// 水 + 咖啡
class Coffee {
  make(water) {
    return water + ' + 咖啡'
  }
  cost() {
    return 10;
  }
}

// 水 + 奶 + 咖啡
class MilkCoffee extends Coffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 奶';
  }
  cost() {
    return super.cost() + 3;
  }
}

// 水 + 糖 + 咖啡
class SugarCoffee extends Coffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 糖';
  }
  cost() {
    return super.cost() + 2;
  }
}

// 水 + 糖 + 奶 + 咖啡
class SugarMilkCoffee extends SugarCoffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 奶';
  }
  cost() {
    return super.cost() + 3;
  }
}

// 水 + 奶 + 糖 + 咖啡
class MilkSugarCoffee extends MilkCoffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 糖';
  }
  cost() {
    return super.cost() + 2;
  }
}

从继承的代码看,虽然可以实现给咖啡任意加入其他原料,但是每一种不同的排列组合都需要单独创建类,当原料种类众多时,则难以管理代码,下面是 “装饰器模式” 的实现

/* 装饰器模式的实现方式 */
class Coffee {
  make(water) {
    return water + ' + 咖啡';
  }
  cost() {
    return 10;
  }
}

class MilkCoffee {
  constructor(parent) {
    this.parent = parent;
  }
  make(water) {
    return this.parent.make(water) + ' + 奶';
  }
  cost() {
    return this.parent.cost() + 3;
  }
}

class SugarCoffee {
  constructor(parent) {
    this.parent = parent;
  }
  make(water) {
    return this.parent.make(water) + ' + 糖';
  }
  cost() {
    return this.parent.cost() + 2;
  }
}

class IceCoffee {
  constructor(parent) {
    this.parent = parent;
  }
  make(water) {
    return this.parent.make(water) + ' + 冰';
  }
  cost() {
    return this.parent.cost() + 1;
  }
}

const coffee = new Coffee();
const milkCoffee = new MilkCoffee(coffee);
const sugarCoffee = new SugarCoffee(milkCoffee);
const iceCoffee = new IceCoffee(sugarCoffee);

console.log(milkCoffee.make('水'), milkCoffee.cost());
console.log(sugarCoffee.make('水'), sugarCoffee.cost());
console.log(iceCoffee.make('水'), iceCoffee.cost());

// 水 + 咖啡 + 奶 13
// 水 + 咖啡 + 奶 + 糖 15
// 水 + 咖啡 + 奶 + 冰 16

从 “装饰器模式” 的实现代码来看,我们只需要创建和原料相同多的类就可以了,其他的方式加料只需要对上一个类进行包装即可,部分加料的顺序,当类的种类越多时,“装饰器” 的意义则体现的越明显。

装饰器模式有时候会优于继承,尤其是很多的类通过继承存在排列组合的关系时,则使用 “装饰器模式” 可以更好更高效的解决问题。

装饰器模式和 AOP 编程

在软件业,AOP 为 Aspect Oriented Programming 的缩写,意为面向切面编程,通过预编译方式和运行其动态代理实现程序功能统一维护的一种技术。

在 JavaScript 中的 AOP 就是在函数之前或之后添加一些额外的逻辑,而不需要修改函数本身逻辑。

/* AOP 编程的案例 */
// 给函数扩展 before 方法
Function.prototype.before = function (beforeFn) {
  let _this = this;
  return function () {
    beforeFn.apply(this, arguments);
    _this.apply(this, arguments);
  }
}

// 给函数扩展 after 方法
Function.prototype.after = function (afterFn) {
  let _this = this;
  return function () {
    _this.apply(this, arguments);
    afterFn.apply(this, arguments);
  }
}

// 原函数
function buy(money, goods) {
  console.log('花' + money + '元钱买' + goods);
}

// 使用 before 方法给函数增加前切面
buy = buy.before(function () {
  console.log('向媳妇要1元钱');
});

// 使用 before 方法给函数增加后切面
buy = buy.after(function () {
  console.log('还给媳妇0.2元钱');
})

buy(0.8, '盐');
// 向媳妇要1元钱
// 花0.8元钱买盐
// 还给媳妇0.2元钱
AOP 编程是由 “装饰器模式” 进化而来,或者说 “装饰器模式” 属于 AOP 编程的一种。

装饰器模式的应用

监控埋点

埋点分析,是网站分析的一种常用的数据采集方法,埋点主要分为服务器层面的埋点和客户端层面的埋点,服务器层面的埋点主要是通过客户端的请求进行分析,客户端层面的埋点分为代码埋点、自动化埋点,第三方埋点(百度、友盟等)。

<!-- 一个埋点的简单案例 -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>埋点</title>
</head>
<body>
  <button data-name="wetermelon" id="wetermelon">西瓜</button>
  <button data-name="apple" id="apple">苹果</button>
  <script>
    const wetermelon = document.getElementById('wetermelon');
    const apple = document.getElementById('apple');

    // 添加切面
    Function.prototype.after = function (afterFn) {
      let _this = this;
      return function () {
        _this.apply(this, arguments);
        afterFn.apply(this, arguments);
      }
    }

    // 事件处理函数
    function click() {
      console.log('你点击了' + this.dataset.name);
    }

    click = click.after(function () {
      // 向服务器发送统计数据
      const img = new Image();
      img.src = 'http://localhost:3000/report?name=' + this.dataset.name;
    });

    // 给所有的
    Array.from(document.querySelectorAll('button')).forEach(button => {
      button.addEventListener('click', click);
    });
  </script>
</body>
</html>
/* 负责统计点击次数的服务 */
const express = require('express');
const app = express();

// 存储按钮的点击次数
const goods = {};

app.get('/report', function (req, res) {
  const name = req.query.name;
  if (goods[name]) {
    goods[name]++;
  } else {
    goods[name] = 1;
  }

  res.json(goods);
});

app.listen(3000, function () {
  console.log('server start 3000');
});

上面的埋点就是通过 AOP 的方式在点击事件后添加了切面,用来向服务器发送请求,符合 “单一职责原则”,可以使点击事件和埋点逻辑进行 “解耦”,服务器在接收到请求之后立即对点击次数进行统计并储存,也可以通过调用 report 接口来获取当前各个按钮的点击次数。

表单校验

“装饰器模式” 的思想同样可以用在表单校验,通常表单校验逻辑是在 submit 事件触发时提交之前发生的,我们经常会将校验逻辑和提交逻辑写在一起,形成 “强耦合”,下面我们使用 AOP 的方式来实现表单校验,对校验逻辑和提交逻辑进行 “解耦”。

<!-- 应用于表单校验 -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>表单校验</title>
</head>
<body>
  用户名:<input type="text" id="username">
  密码:<input type="text" id="password">
  <button id="submit-btn">提交</button>
  <script>
    const submitBtn = document.getElementById('submit-btn');

    // 添加切面函数
    Function.prototype.before = function (beforeFn) {
      let _this = this;
      return function () {
        let result = beforeFn.apply(this, arguments);
        result && _this.apply(this, arguments);
      }
    }

    // 表单提交事件
    function submit() {
      console.log('提交表单');
    }

    // 验证用户名
    submit = submit.before(function () {
      const username = document.getElementById('username').value;
      if (!username) {
        return alert('请输入用户名');
      }
      return true;
    });

    // 验证
    submit = submit.before(function () {
      const password = document.getElementById('password').value;
      if (!password) {
        return alert('请输入密码');
      }
      return true;
    });

    submitBtn.addEventListener('click', submit);
  </script>
</body>
</html>

总结

在 JavaScript 中 “装饰器模式” 和 AOP 编程非常相似,应用也非常多,如 axios 中对请求、响应的拦截方法,Koa 中间件,都包含这样的编程思想,而在 ES6 之后 JavaScript 已经支持了原生的 “装饰器” 语法,使用起来更方便.

6.代理模式

代理模式概念

由于某些情况下一个对象不能直接引用另一个对象,所以需要代理对象在这两个对象之间起到中介作用或者实现控制,这样的模式叫 “代理模式”。

基本实现

// 假设无法客户端无法直接使用这个类
class Google {
  get(url) {
    return url + ' is google';
  }
}

// 只能通过代理操作 Google 类
class Proxy {
  constructor() {
    this.google = new Google();
  }
  get(url) {
    return this.google.get(url);
  }
}

const proxy = new Proxy();
const result = proxy.get('http://www.google.com');
console.log(result); // http://www.google.com is google

假设 Google 类我们无法直接使用,只有 Proxy 可以使用 Google,我们可以通过 Proxy 类去操作使用 Google 类,此时 Proxy 类就是一个代理。

ES6 的 Proxy

在 ES6 标准以后,JavaScript 提供了原生的代理模式 Proxy 类,可以代理其他对象,并在对象属性的获取和赋值时增加拦截。

/* ES6 Proxy 的使用 */
const lucy = {
  name: 'lucy',
  age: 20,
  height: 165
};

const lucyMother = new Proxy(lucy, {
  get(target, key) {
    if (key === 'age') {
      return target.age - 2;
    } else if (key === 'height') {
      return target.height + 5;
    } else {
      return target[key];
    }
  },
  set(target, key, val) {
    if (key === 'boyfriend') {
      if (val.age > 40) {
        console.log('太老了');
      } else if (val.salary < 20000) {
        console.log('太穷了');
      } else {
        target[key] = val;
      }
    }
  }
});

console.log(lucyMother.name); // lucy
console.log(lucyMother.age); // 18
console.log(lucyMother.height); // 170

lucyMother.boyfriend = {
  age: 42,
  salary: 25000
}
// 太老了

lucyMother.boyfriend = {
  age: 36,
  salary: 18000
}
// 太穷了

上面是一个接地气的案例,创建一个对象存储 lucy 的基本信息,使用代理创建 lucyMother 为 lucy 找男朋友,通过代理对象获取 lucy 的基本信息时会虚报年龄和身高,而在设置男朋友对象时会检查是否符合要求。

代理模式、适配器模式和装饰器模式

从代码实现来看,代理模式、适配器模式、装饰器模式非常的相似,非常容易混淆,但其实是有本质区别的。

  • 代理模式和适配器模式:代理模式不会改变原有的接口,代理类和被代理的类属性方法使用方式完全一致,而适配器模式是因为旧的接口无法使用,通过适配器创建新的接口去兼容旧的接口;
  • 代理模式和装饰器模式:装饰器功能会保证被装饰类功能正常使用的情况下新增功能,而代理模式保证原有接口,但会改变原来接口的功能;
  • 适配器模式和装饰器模式:装饰器是对一个类的包装,而适配器更多是去建立提供接口的类与无法适配的类之间的联系。

代理模式的应用

事件委托

事件委托是浏览器事件注册的一种优化手段,如果同类型的元素非常多,且都有相同的事件,如列表,则不必给每一个元素注册这个事件,而是将事件注册给父元素,即将事件委托给父元素,避免了相同事件的重复注册,这种优化利用了 “代理模式”,又称事件代理。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>事件委托</title>
</head>
<body>
  <ul id="list">
    <li>1</li>
    <li>2</li>
    <li>3</li>
  </ul>
  <script>
    const ulList = document.getElementById('list');

    ulList.addEventListener('click', function (event) {
      console.log(event.target.innerHTML);
    });
  </script>
</body>
</html>

在浏览器中,委托给父元素的事件触发后,可以通过事件对象的属性 target 获取到具体触发事件的子元素。

图片加载

图片加载是一个提高用户体验的功能,也是非常常见的,原因是浏览器向服务器请求资源图片是需要等待的,由于网络等因素的影响会导致等待的时间更长,此时我们需要一个 loading 图片来过渡,这就是图片加载的基本需求。

/* node 服务器 */
const express = require('express');
const path = require('path');

const app = express();

app.get('/loading.gif', function (req, res) {
  res.sendFile(path.resolve('img', 'loading.gif'));
});

app.get('/img/:name', function (req, res) {
  setTimeout(function () {
    res.sendFile(path.join(__dirname, req.path));
  }, 3000);
});

app.use(express.static(__dirname));

app.listen(3000, function () {
  console.log('server start 3000');
});

上面服务器模拟了加载图片响应慢的场景,loading 图片立即响应,其他图片则延迟 3s 响应。

<!-- Dom 结构 -->
<ul id="menu">
  <li data-src="/img/bg1.jpg">图片1</li>
  <li data-src="/img/bg2.jpg">图片2</li>
</ul>
<div id="bgimg"></div>
/* 没有实现 loading */
const menu = document.getElementById('menu');
const bgimg = document.getElementById('bgimg');

const background = (function () {
  const img = new Image();
  bgimg.appendChild(img)
  return {
    setSrc(src) {
      img.src = src;
    }
  }
})();

menu.addEventListener('click', function (event) {
  const src = event.target.dataset.src;
  background.setSrc(src);
});

上面的代码是没有实现懒加载的,当点击按钮向服务器请求图片时,并没有加入 loading 图片过渡,之所以说图片加载应用了 “代理模式” 并不是指加载功能本身,而是我们的实现方式,编写的代码质量要高至少要遵循单一职责原则和开放封闭原则,就是说最好不要直接在事件监听的函数中增加 loading 过渡的逻辑,而是把这个过渡功能交给代理对象去处理。

/* 使用代理对象实现 loading 过渡 */
const menu = document.getElementById('menu');
const bgimg = document.getElementById('bgimg');

// 请求图片的对象
const background = (function () {
  const img = new Image();
  bgimg.appendChild(img)
  return {
    setSrc(src) {
      img.src = src;
    }
  }
})();

// 增加 loading 过度的代理对象
const proxyBackground = (function () {
  const img = new Image();
  img.onload = function () {
    background.setSrc(this.src);
  }
  return {
    setSrc(src) {
      background.setSrc('./img/loading.gif');
      img.src = src;
    }
  }
})();

// 监听获取图片的事件中使用的是代理对象 proxyBackground
menu.addEventListener('click', function (event) {
  const src = event.target.dataset.src;

  // 防止缓存
  proxyBackground.setSrc(src + '?time=' + Date.now());
});

上面的实现方式就符合 “代理模式”,background 对象是提供基本功能,而proxyBackground(代理对象)增强了基本功能,却并没有改变接口的使用方式,依然通过 setSrc 方法去请求图片。

防抖代理

防抖的作用是在做一个操作时不需要很频繁,如搜索查询,在连续输入时如果每次触发输入事件都向后端发送请求,性能是极差的,我们希望的是连续输入只在最后一次统一发送请求,这种处理叫做防抖处理,是前端优化的手段。

<!-- 未使用防抖代理处理 -->
<input type="text" id="ipt">
<script>
  const ipt = document.getElementById('ipt');

  function post() {
    console.log('发送请求了');
  }

  ipt.addEventListener('input', post);
</script>

上面代码未使用防抖代理,每次输入都会打印 “发送请求了”。

<!-- 使用防抖代理优化 -->
<input type="text" id="ipt">
<script>
  const ipt = document.getElementById('ipt');

  function post() {
    console.log('发送请求了');
  }

  // 代理函数去执行 post
  const debouncePost = (function () {
    let timer = null;
    return function () {
      clearInterval(timer);
      timer = setTimeout(function () {
        post();
      }, 500);
    }
  })();

  ipt.addEventListener('input', debouncePost);
</script>

使用防抖代理函数优化后,保留了原有功能的基础上进行了增强,实现了连续输入停止 500ms 后统一发送一次请求,防抖的实现方式有很多种,包括并不限于函数式编程等,而上面代码使用了 “代理模式” 实现 。

总结

使用 “代理模式” 的场景在后端会更多,比如代理跨域,Nginx 代理等等,还有一点需要注意的是,“代理模式” 并非单一的,对于同一个对象,可以有多个代理对象去增强不同的功能。

7.外观模式

外观模式简介

“外观模式” 就是把一些复杂的流程封装成一个接口,提供给外部更简单的使用。

在外观模式中存在三种角色如下:

* 门面角色(Facade):是 “外观模式” 的核心,它熟悉子系统的功能,并被客户角色调用,内部实现了客户角色需求功能的组合;
* 子系统角色(System):实现了子系统的功能(多个),对于客户角色是未知的;
* 客户角色(Client):通过调用 Facede 来完成要实现的功能。

外观模式的实现

上面已经介绍了 “外观模式” 的各个角色,下面是简单的代码实现。

// 子系统角色 Sum
class Sum {
  sum(a, b) {
    return a + b;
  }
}

// 子系统角色 Minus
class Minus {
  minus(a, b) {
    return a - b;
  }
}

// 子系统角色 Multipy
class Multipy {
  multipy(a, b) {
    return a * b;
  }
}

// 子系统角色 Divide
class Divide {
  divide(a, b) {
    return a / b;
  }
}

// 门面角色 Calculator
class Calculator {
  constructor() {
    this.sumObj = new Sum();
    this.minusObj = new Minus();
    this.multipyObj = new Multipy();
    this.divideObj = new Divide();
  }
  sum(...args) {
    return this.sumObj.sum(...args);
  }
  minus(...args) {
    return this.minusObj.minus(...args);
  }
  multipy(...args) {
    return this.multipyObj.multipy(...args);
  }
  divide(...args) {
    return this.divideObj.divide(...args);
  }
}

// 客户角色
const calculator = new Calculator();

console.log(calculator.sum(1, 2)); // 3
console.log(calculator.minus(1, 2)); // -1
console.log(calculator.multipy(1, 2)); // 2
console.log(calculator.divide(1, 2)); // 0.5

我们在上面代码中实现了一个计算器功能,计算器具备的功能为加、减、乘、除,我们把这四个功能分别拆分成为四个子系统,用门面类 Calculator 来进行连接,这样只需要调用 Calculator 的实例(客户角色)就可以调用四个子系统模块分别提供的功能,但是上面的代码实现功能比较简洁,并没达到 “外观模式” 的真正作用,就是可以随意组合各个子系统的功能。

/* 组合子系统功能 */
// 子系统角色 CPU
class CPU {
  start() {
    console.log('CPU 启动');
  }
}

// 子系统角色 Memory
class Memory {
  start() {
    console.log('内存启动');
  }
}

// 子系统角色 Disk
class Disk {
  start() {
    console.log('硬盘启动');
  }
}

// 门面角色 Computer
class Computer {
  constructor() {
    this.cpu = new CPU();
    this.memory = new Memory();
    this.disk = new Disk();
  }
  start() {
    // 组合子系统功能
    this.cpu.start();
    this.memory.start();
    this.disk.start();
  }
}

// 客户角色
const computer = new Computer();

computer.start();
// CPU 启动
// 内存启动
// 硬盘启动

上面代码打印了一个计算机的启动过程,功能来自于各个子系统,也可以通过门面角色实现子系统功能的多种组合。

总结

“外观模式” 的作用是可以对复杂功能解耦合,分散到各个子系统,使子系统与子系统互相独立,并对各个子系统提供外界访问的功能组合模块,这样既提高了子系统的维护性,又增加了外界访问功能的扩展性。

8.状态模式

状态模式简介

有些情况下一个对象的行为取决于一个或者多个动态变化的属性,这样的属性叫做状态,这个对象叫做有状态的对象,这种情况下通常有很多的判断来处理状态不同时代码的执行逻辑,执行逻辑可能会非常复杂,让代码变得难以维护,“状态模式” 就是将这些逻辑委托给外面的对象或类来单独维护,来减少状态对象的逻辑,增强代码的维护性。

状态模式的实现

下面是一个类,功能为根据电池不同状态打印当前不同的颜色,下面是正常的实现方式。

class Battery {
  constructor() {
    this.amount = 'high'; // 电量高
  }
  show() {
    if (this.amount === 'high') {
      console.log('显示绿色');
      this.amount = 'middle'; // 电量中等
    } else if (this.amount === 'middle') {
      console.log('显示黄色');
      this.amount = 'low'; // 电量低
    } else if (this.amount === 'low') {
      console.log('显示红色');
    }
  }
}

const battery = new Battery();

battery.show(); // 显示绿色
battery.show(); // 显示黄色
battery.show(); // 显示红色

上面的代码虽然实现了我们想要的功能,但是代码中 show 方法违反了开放封闭原则和单一职责原则,状态切换逻辑不明显,判断条件太多,维护性和扩展性差,下面使用状态模式进行优化。

class Battery {
  constructor() {
    this.amount = 'high'; // 电量高
    this.state = new SuccessState(); // 绿色状态
  }
  show() {
    // 把显示逻辑委托给状态对象
    this.state.show();
    if (this.amount === 'high') {
      this.amount = 'middle'; // 电量中等
      this.state = new WarningState(); // 黄色状态
    } else if (this.amount === 'middle') {
      this.amount = 'low'; // 电量低
      this.state = new ErrorState(); // 红色状态
    }
  }
}

class SuccessState {
  show() {
    console.log('显示绿色');
  }
}

class WarningState {
  show() {
    console.log('显示黄色');
  }
}

class ErrorState {
  show() {
    console.log('显示红色');
  }
}

const battery = new Battery();

battery.show(); // 显示绿色
battery.show(); // 显示黄色
battery.show(); // 显示红色

经过 “状态模式” 的优化,我们将状态拆分成三个类,无论关于状态操作的多复杂的逻辑都在拆分出的类中实现,而不再需要在状态对象 Battery 中。

状态模式的应用

点赞

点赞是我们在项目开发中经常见到的功能,点赞后也可以取消点赞,这就出现了按钮关于点赞状态的切换和按钮文案的切换,下面是使用 “状态模式” 来实现的点赞功能。

<div id="root"></div>
<script>
  // 维护点赞渲染逻辑的对象
  const likeState = {
    render(element) {
      element.innerHTML = '赞';
    }
  };

  // 维护取消点赞渲染逻辑的对象
  const likedState = {
    render(element) {
      element.innerHTML = '取消'
    }
  }

  class Button {
    constructor(container) {
      this.liked = false; // 默认为未点赞状态
      this.state = likeState; // 设置当前的状态为未点赞

      this.element = document.createElement('button');
      container.appendChild(this.element);
      this.render(); // 初始化渲染
    }
    setState(state) {
      this.state = state; // 修改渲染状态
      button.liked = !button.liked; // 修改状态属性
      this.render(); // 重新渲染
    }
    render() {
      this.state.render(this.element);
    }
  }

  // 获取按钮对象并添加点击事件
  const button = new Button(document.getElementById('root'));
  button.element.addEventListener('click', () => {
    button.setState(button.liked ? likeState : likedState);
  });
</script>

上面代码使用 “状态模式” 统一封装了按钮的类 Button,传入的参数为渲染按钮的容器元素,按钮类的内部创建按钮并添加到容器元素中,统一管理了点赞状态,点赞渲染对象,如果想要切换状态只需要执行 button 对象提供的 setState 方法通过传入的不同状态对象进行状态切换和页面渲染。

React 组件显示隐藏

在 React 中,经常会出现通过事件切换组件的显示隐藏,最简单的方式是通过类组件状态来控制,但其实也可以使用 “状态模式” 在组件外编写对状态更改的逻辑,这样可以使组件的逻辑更清晰,代码更精简。

import React from 'react';
import ReactDOM from 'react-dom';

// 状态管理对象
const States = {
  show() {
    console.log('显示 Banner');
    this.setState({ isShow: true });
  },
  hide() {
    console.log('隐藏 Banner');
    this.setState({ isShow: false });
  }
};

class Banner extends React.Component {
  state = { isShow: true }
  toggle = () => {
    const currentState = this.state.isShow ? 'hide' : 'show';
    States[currentState] && States[currentState].apply(this);
  }
  render() {
    return (
      <div>
        {
          isShow && (
            <nav>导航</nav>
          )
        }
        <button>toggle</button>
      </div>
    )
  }
}

ReactDOM.render(<Banner />, document.getElementById('root'));

在上面代码中组件外部的 States 就是管理切换状态逻辑的对象,就是说 “状态模式” 也可以在框架中单独使用。

有限状态机

其实 “状态模式” 来源一个有限状态机的概念,有限状态机是指一个事物拥有多种状态,但是同一时间只会处于一种状态,可以通过动作来改变当前的状态,在 JavaScript 中拥有第三方模块 javascript-state-machine 专门帮我们来做这件事。

javascript-state-machine 使用

javascript-state-machine 提供一个类,创建实例时传入的参数为一个 options 对象,属性 init 用来定义初始状态值,属性 transitions 用来定义属性变化,methods 用来定义属性发生变化时所触发的钩子。

// 有限状态机对象
const StateMachine = require('javascript-state-machine');

const fsm = new StateMachine({
  init: 'solid', // 初始状态(固态)
  transitions: [
    { from: 'solid', to: 'liquid', name: 'melt' },
    { from: 'liquid', to: 'solid', name: 'freeze' },
    { from: 'liquid', to: 'gas', name: 'vaporize' },
    { from: 'gas', to: 'liquid', name: 'condense' }
  ],
  methods: {
    onMelt() {
      console.log('melt');
    },
    onFreeze() {
      console.log('freeze');
    },
    onVaporize() {
      console.log('vaporize');
    },
    onCondense() {
      console.log('condense');
    }
  }
});

console.log(fsm.state); // solid
console.log(fsm.can('gas')); // false
console.log(fsm.cannot('gas')); // true
console.log(fsm.transitions()); // [ 'melt' ]

console.log(fsm.allTransitions());
// [ 'init', 'melt', 'freeze', 'vaporize', 'condense' ]

console.log(fsm.allStates()); // [ 'none', 'solid', 'liquid', 'gas' ]

fsm.melt(); // melt
console.log(fsm.state); // liquid
console.log(fsm.transitions()); // [ 'freeze', 'vaporize' ]
* fsm.state:当前状态;
* fsm.can:查看是否可直接转换到某个状态,参数为要转换的状态值;
* fsm.cannot:查看是否不能直接转换到某个状态,参数为要转换的状态值;
* fsm.transitions:返回可转换状态的方法列表;
* fsm.allTransitions:返回所有状态转换方法列表;
* fsm.allStates:返回定义的所有状态。

javascript-state-machine 原理

根据 javascript-state-machine 的用法我们模拟实现最基本的逻辑,构建一个有限状态机,代码如下:

class StateMachine {
  constructor(options) {
    const {
      init = 'none',
      transitions = [],
      methods = {}
    } = options;

    this.state = init;

    transitions.forEach(transition => {
      const { from, to, name } = transition;
      this[name] = function () {
        if (this.state === from) {
          this.state = to;
          const method = 'on' + name.slice(0, 1).toUpperCase() + name.slice(1);
          methods[method] && methods[method]();
        }
      }
    });
  }
}

总结

“状态模式” 实现的有限状态机可以更大限度的让状态变化与状态对象进行解耦,更减少了大量的判断逻辑。

9.策略模式

策略模式简介

“策略模式” 是将定义的一组算法封装起来,使其可以相互替换,封装的算法具有一定的独立性,让算法独立于客户端而变化,可以大大减少 if...else 和 switch...case 等判断。

策略模式的实现

下面是一个关于会员打折的逻辑,根据顾客身份不同输出不同的支付金额,是未使用 “策略模式” 的实现。

/* 未使用策略模式 */
class Customer {
  constructor(type) {
    this.type = type;
  }
  pay(amount) {
    if (this.type === 'member') {
      return amount * 0.9;
    } else if (this.type === 'vip') {
      return amount * 0.8;
    } else {
      return amount;
    }
  }
}

const c1 = new Customer('normal');
const c2 = new Customer('member');
const c3 = new Customer('vip');

console.log(c1.pay(100)); // 100
console.log(c2.pay(100)); // 90
console.log(c3.pay(100)); // 80

上面的代码与 状态模式 一节中的问题类似,违反开放封闭原则和单一职责原则,代码冗余且判断条件过多,“状态模式” 虽然可以解决状态不同时不同复杂逻辑的抽离和解耦,但是并不能解决过多条件判断的问题,下面就是用 “策略模式” 来对这个点进行优化。

/* 使用策略模式优化 —— 策略类 */
class Customer {
  constructor(kind) {
    this.kind = kind;
  }
  pay(amount) {
    return this.kind.pay(amount);
  }
}

// 策略类
class Normal {
  pay(amount) {
    return amount;
  }
}

class Member {
  pay(amount) {
    return amount * 0.9;
  }
}

class VIP {
  pay(amount) {
    return amount * 0.8;
  }
}

const c1 = new Customer(new Normal());
const c2 = new Customer(new Member());
const c3 = new Customer(new VIP());

console.log(c1.pay(100)); // 100
console.log(c2.pay(100)); // 90
console.log(c3.pay(100)); // 80

上面是使用策略类对复杂判断逻辑的内容进行了抽象,并将原本 if...else 中的逻辑分别放在了不同的策略类中维护,如果每一个策略类中要维护的逻辑并不是很复杂,也可以使用第二种方案,即使用策略对象维护不同的逻辑。

/* 使用策略模式优化 —— 策略对象 */
class Customer {
  constructor() {
    // 策略对象
    this.kinds = {
      normal(amount) {
        return amount;
      },
      member(amount) {
        return amount * 0.9;
      },
      vip(amount) {
        return amount * 0.8;
      }
    };
  }
  pay(kind, amount) {
    return this.kinds[kind](amount);
  }
}

const c1 = new Customer();
const c2 = new Customer();
const c3 = new Customer();

console.log(c1.pay('normal', 100)); // 100
console.log(c2.pay('member', 100)); // 90
console.log(c3.pay('vip', 100)); // 80

策略模式的应用

jQuery 的 animate 动画

在 jQuery 的源码实现中,animate 方法就用到了 “策略模式”,通过不同的状态定义了动画不同的行为,使用代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>jQuery-animate</title>
  <style>
    #content{
      width: 100px;
      height: 100px;
      background-color: pink;
    }
  </style>
</head>
<body>
  <button id="bigger">变大</button>
  <div id="content"></div>
  <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  <script>
    $('#bigger').on('click', function () {
      $('#content').animate({
        width: '200px',
        height: '200px'
      }, 1000, 'linear'); // linear 参数为动画策略的一种类型
    });
  </script>
</body>
</html>

表单校验

在 装饰器模式 一节中也有表单校验的应用,代码如下:

<!-- 表单校验应用装饰器模式 -->
<form>
  用户名:<input type="text" id="username">
  密码:<input type="text" id="password">
  <button id="submit-btn">提交</button>
<form>
<script>
  const submitBtn = document.getElementById('submit-btn');

  // 添加切面函数
  Function.prototype.before = function (beforeFn) {
    const _this = this;
    return function () {
      let result = beforeFn.apply(this, arguments);
      result && _this.apply(this, arguments);
    }
  }

  // 表单提交事件
  function submit() {
    console.log('提交表单');
  }

  // 验证用户名
  submit = submit.before(function () {
    const username = document.getElementById('username').value;
    if (!username) {
      return alert('请输入用户名');
    }
    return true;
  });

  // 验证
  submit = submit.before(function () {
    const password = document.getElementById('password').value;
    if (!password) {
      return alert('请输入密码');
    }
    return true;
  });

  submitBtn.addEventListener('click', submit);
</script>

“装饰器模式” 是将对每个表单校验逻辑,通过增加切面(AOP)的方式插入在了 submit 事件之前,如果有一个校验不通过则不会执行下一个切面的校验操作或提交表单,但是这样的表单校验有局限性,如果页面表单校验非常多需要对校验逻辑进行统一管理,并且大多数场景下是所有的表单都校验后对所有的表单进行错误提示,这是就需要 “策略模式” 的策略对象来管理所有的校验逻辑。

<!-- 表单校验应用策略模式 -->
<form id="userform">
  用户名:<input type="text" name="username">
  密码:<input type="text" name="password">
  手机号:<input type="text" name="mobile">
  <input type="submit" value="提交">
</form>
<script>
  const form = document.getElementById('userform');
  const validator = (function () {
    const rules = {
      noEmpty(val, msg) {
        if (val === '') return msg;
      },
      minLength(val, min, msg){
        if (val === '' || val.length < min) return msg;
      },
      isMobile(val, msg) {
        if (!/1\d{10}/.test(val)) return msg;
      }
    };

    // 存储
    const checks = [];

    // 增加校验的项目
    function add(element, rule) {
      checks.push(function () {
        // ['minLength', 6, '密码长度不能少于 6 位']
        const name = rule.shift();

        // [val, 6, '密码长度不能少于 6 位']
        rule.unshift(element.value);
        return rules[name] && rules[name].apply(element, rule);
      });
    }

    // 给策略对象增加新的功能
    function addRule(name, rule){
      rules[name] = rule;
    }

    // 开始校验
    function start() {
      for (let i = 0; i < checks.length; i++) {
        const msg = checks[i]();
        if (msg) return msg;
      }
    }

    return { add, addRule, start };
  })();

  // 添加自定义规则
  validator.addRule('maxLength', function (val, max, msg) {
    if (val === '' || val.length > max) return msg;
  });

  form.onsubmit = function () {
    validator.add(form.username, ['noEmpty', '用户名不能为空']);
    validator.add(form.password, ['minLength', 6, '密码长度不能少于 6 位']);
    validator.add(form.password, ['maxLength', 12, '密码长度不能大于 12 位']);
    validator.add(form.mobile, ['isMobile', '必须输入合法的手机号']);

    const msg = validator.start();
    alert(msg || '校验通过');
    return !msg;
  }
</script>

通过对比两段代码可以显而易见的看出 “策略模式” 在对于表单校验的功能上比 “装饰器模式” 更加健壮,可以在保证可维护性的基础上支持更多复杂的功能。

总结

“策略模式” 和 “状态模式” 都有上下文、策略和状态类,上下文把这些请求委托给这些类来执行,“策略模式” 中,各个类是平等的,没有关系,客户端需要知道算法主动切换,“状态模式” 中,状态的切换和行为被封装好了,客户端不需要了解细节,所以 “策略模式” 真正意义的解决了状态过多时条件判断过多的问题。