设计模式之美-前端

675 阅读16分钟

原文,zhuanlan.zhihu.com/p/111553641

最近看完了《JavaScript 设计模式与开发实践》,也在学习极客时间的专栏《设计模式之美》,想着整理一下常用的设计模式。设计模式不仅受用于面试,更能增进代码水平,是每个程序员必须掌握的。

GoF 在《设计模式》一种归纳了 23 种设计模式,而它们又属于三种类型的模式,分别是创建型模式、结构型模式和行为型模式。正如书中所说,其实每一种设计模式都来自真实项目,作者只是将它们取一个好听的名字,方便记忆与传播。所以设计模式可比数据结构算法上手要简单一点,因为很多设计模式你可能都似曾相似,学习的过程不过是一个接一个的顿悟。

本文中每一种设计模式,我都会搭配一个简单的例子,我会尽量用 JS 实现,如果 JS 不能很好的表达,就用 Java(已替换成TS) 实现。毕竟《设计模式》一书的子标题是“可复用面向对象软件的基础”,它是为面向对象总结的设计模式。

大分类

  • 创建型(5)

工厂方法,抽象工厂,单例,建造者,原型。

  • 结构型(7)

适配器,装饰器,代理,外观,桥接,组合,享元。

  • 行为型(11)

策略,模板,观察者,迭代器,中介者,状态,职责链,命令,访问者,备忘录,解释器。

创建型设计模式主要解决“对象的创建”问题,结构型设计模式主要解决“类或对象的组合或组装”问题,那行为型设计模式主要解决的就是“类或对象之间的交互”问题。

具体一点,设计模式要干的事情就是解耦,创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦。


创建型

工厂方法

在 JS 中,工厂方法是创建对象的一种方式。它像工厂一样,生产出来的函数都是标准件(拥有相同的属性)。它和单例模式有一点像,缓存了对象,避免重复重新结构相同的对象。下面是创建不同角色的工厂类。

function createPeopleFactory(id, name, age) {
  const obj = new Object();
  obj.id = id;
  obj.name = name;
  obj.age = age;
  return obj;
}

const child = createPeopleFactory(1, 'baby', 1);
const father = createPeopleFactory(2, 'peter', 25);

抽象工厂

在工厂方法的基础上再抽象一层,用来管理多个工厂类。平时使用场景很少。

abstract class AbstractFactory {
  public abstract getColor(color: string);
  public abstract getShape(shape: string);
}

// 通过传递形状或颜色信息来获取工厂
class FactoryProducer {
  public static getFactory(choice: string) {
    if (choice === 'SHAPE') {
      return new ShapeFactory();
    } else if (choice === 'COLOR') {
      return new ColorFactory();
    }
    return null;
  }
}

class ColorFactory extends AbstractFactory {
  public getColor(color) {
    // do something
  }

  public getShape() {
    return null;
  }
}

class ShapeFactory extends AbstractFactory {
  public getColor() {
    return null;
  }

  public getShape(shape) {
    // do something
  }
}

const shape = FactoryProducer.getFactory('SHAPE');
shape.getShape('CIRCLE');
const color = FactoryProducer.getFactory('COLOR');
color.getColor('RED');

单例

保证一个类仅有一个实例,并提供一个访问它的全局访问点 。

const singleton = function(fn) {
  let result = null;
  return function() {
    return result || (result = fn.apply(this, arguments));
  };
};

const getScript = singleton(function() {
  return document.createElement('script');
});

const script1 = getScript();
const script2 = getScript();
console.log(script1 === script2); // true

建造者

用来参数需要在构造函数中初始化,但是参数又过多的场景。

未使用建造者模式

class Shape {
  constructor(width, height, color, opacity, borderWidth, boxShadow) {
    if (typeof width !== 'number') {
      throw new Error('width should be a number');
    }
    this.width = width;

    if (typeof height !== 'number') {
      throw new Error('width should be a number');
    }
    this.height = height;

    this.color = color;
    this.opacity = opacity;

    if (width < borderWidth) {
      throw new Error('width should be greater than borderWidth');
    }
    this.borderWidth = borderWidth;
  }
}

const shape = new Shape(10, 10, 'red', 1, 10);

使用建造者模式改造后

class ShapeConfig {
  constructor(builder) {
    this.width = builder.width;
    this.height = builder.height;
    this.color = builder.color;
    this.opacity = builder.opacity;
    this.borderWidth = builder.borderWidth;
  }
}

class Shape {
  constructor() {
    this.width = 0;
    this.height = 0;
    this.color = '';
    this.opacity = 1;
    this.borderWidth = 0;
  }

  // 可以把多个值的比较逻辑都放在构建函数中
  build() {
    if (this.width < this.borderWidth) {
      throw new Error('width should be greater than borderWidth');
    }
    return new ShapeConfig(this);
  }
  setWidth(width) {
    if (typeof width !== 'number') {
      throw new Error('width should be a number');
    }
    this.width = width;
    return this;
  }
  setHeight(height) {
    if (typeof height !== 'number') {
      throw new Error('height should be a number');
    }
    this.height = height;
    return this;
  }
  setColor(color) {
    this.color = color;
    return this;
  }
  setOpacity(opacity) {
    this.opacity = opacity;
    return this;
  }
  setBorderWidth(borderWidth) {
    this.borderWidth = borderWidth;
    return this;
  }
}

const shape = new Shape();
// 可以通过return this方便的实现链式调用
shape
  .setWidth(10)
  .setHeight(10)
  .setColor('red')
  .setOpacity(1)
  .setBorderWidth(11)
  .build();
console.log(shape);

原型

原型模式是用于创建对象的一种模式,通过克隆来创建对象的。JavaScript 就是一种基于原型的语言。但就 JavaScript 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。我们可以通过Object.create克隆对象。

const Plane = function() {
  this.blood = 100;
  this.attackLevel = 1;
  this.defenseLevel = 1;
};

const plane = new Plane();
plane.blood = 500;
plane.attackLevel = 10;
plane.defenseLevel = 7;
const clonePlane = Object.create(plane);
console.log(clonePlane);

结构型

适配器

适配器英文是 Adapter。顾名思义它就是做适配用的,将一个不可用的接口转成可用的接口。适配器模式是一种 “亡羊补牢”的模式,没有人会在程序的设计之初就使用它。最近前端比较典型的应用是跨端框架,mpvue 和 taro,它们都是在应用和各个小程序以及终端之间建立了一层适配器。

下面举一个支付的例子,我们只需要调用 pay 函数,适配器帮我们平台之间的差异

function pay(id, price) {
  const platform = window.platform;
  switch (platform) {
    case 'wechat':
      wx.pay({ id, price });
      break;
    case 'alipay':
      alipay.pay({ id, price });
      break;
    case 'jd':
      jd.pay({ id, price });
      break;
    case 'xxx':
      xxx.toPay({ goodsId: id, price });
      break;
  }
}

pay(101, 1000);

装饰者

写代码的时候,我们总遵循“组合优于继承”,而装饰者模式就是一种用组合关系的来组织代码。而我们平时所说的装饰器就是装饰者的一种应用。

这个人原先普普通通,经过装饰者模式改造,瞬间变成人见人爱的帅哥。

function people(height, weight, character) {
  this.height = 170;
  this.weight = 80;
  this.character = 'normal';
}

const xiaowang = people();
console.log(xiaowang.character);

function decorate(ctr) {
  ctr.height = 180;
  ctr.weight = 70;
  ctr.character = 'handsome';
  return ctr;
}

const wang = decorate(people);
console.log(wang.character);

代理

它在不改变原始类代码的情况下,通过引入代理类来给原始类附加功能。前端最常听到的代理就是 nginx 代理,它其实是代理的一个应用,把自身作为代理服务器将资源请求转发到终端服务器。在 JS 中比较典型的代理有图片懒加载,合并 http 请求,以及缓存计算乘积。

下面是一个图片懒加载的例子,我们加先加载默认图片,等真实图片加载完之后再替换默认图片。

const createImage = (function() {
  const img = document.createElement('img');
  document.body.appendChild(img);

  return function(src) {
    img.src = src;
  };
})();

const proxyImage = function(fn) {
  const image = new Image();
  const defaultImg = 'https://rs.vip.miui.com/vip-resource/prod/mio/v136/static/media/lazyLoad.a10ffbd7.png';

  return function(src) {
    fn(defaultImg);

    // 这里加一个延迟,可以更好的看到图片替换的过程。
    setTimeout(function() {
      image.src = src;
      image.onload = function() {
        fn(src);
      };
    }, 2000);
  };
};

const proxy = proxyImage(createImage);
proxy('https://pic1.zhimg.com/80/v2-ec33fcec249a9cabab61b14436432bf0_r.jpg');

外观

也叫门面模式,GoF上的定义是,外观模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用。它和适配器模式类似,配器是做接口转换,解决的是原接口和目标接口不匹配的问题,而外观模式做接口整合,解决多接口带来的调用问题。

比如下面的获取用户信息的接口。

getUserBaseInfo() {
  return API.getUserBaseInfo();
}

getUserPriority() {
  return API.getUserPriority();
}

getUserCustomContent() {
  return API.getUserCustomContent();
}

// 对外提供一个总的接口
async getUserInfo() {
  const baseInfo = await getUserBaseInfo();
  const priority = await getUserPriority();
  const customContent = await getUserCustomContent();
  return { ...baseInfo, ...priority, ...customContent };
}

const userInfo = getUserInfo();

桥接

在《设计模式》中解释为将抽象和实现解耦,让它们可以独立变化。JS中天生就带了这个隐形的模式,一个方法一般会调用多个其他方法,这种将实现抽象出去,就是桥接模式。这里我就不贴代码了。

组合

不是我们平时说的组合关系。它规定了数据类型必须是树型结构,并且表示“部分-整体”的层次结构,是用来处理单个对象和组合对象之间的关系。

我们通过一个扫描文件的例子来说明。

class Folder {
  constructor(name) {
    this.name = name;
    this.files = [];
  }

  add(file) {
    this.files.push(file);
  }

  scan() {
    console.log('开始扫描文件夹: ' + this.name);
    this.files.forEach(file => {
      file.scan();
    })
  }
};


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

  add(file) {
    throw new Error('不能往文件里添加东西,' + file);
  }

  scan() {
    console.log('开始扫描文件: ' + this.name);
  }
}

const rootFolder = new Folder('根目录');
const folder1 = new Folder('一级目录');
const folder2 = new Folder('二级目录');

const file = new File('根文件');
const file1 = new File('一级文件');
const file2 = new File('二级文件');
const file3 = new File('也是二级文件');

rootFolder.add(folder1);
folder1.add(folder2);

rootFolder.add(file);
folder1.add(file1);
folder2.add(file2);
folder2.add(file3);

rootFolder.scan();

享元

应用于大量相似对象的系统。一般是借用工厂模式,新建一个对象,然后其他对象共享这个工厂对象,避免新建对象。享元模式是一种用时间换空间的优化模式,避免性能损耗。

享元模式的代码比较好理解,因为衣服的型号就那么几种,我们可以通过身高判断衣服类型(忽略贾玲和宋一茜),所以衣服类型就可以作为一个共享对象。

const selectClothes = (function() {
  const clothesType = {
    160: 's',
    170: 'l',
    175: 'xl',
    180: 'xxl',
    181: 'xxxl'
  };

  return function(height) {
    if (height < 170) {
      return clothesType[160];
    } else if () {
      // 后面的代码省略
    }
  }
});

class People {
  constructor(height, weight) {
    this.height = height;
    this.weight = weight;
    this.clothesType = '';
  }
}

const people1 = new People(160, 100);
const people2 = new People(170, 150);
people1.clothesType = selectClothes(people1.height);
people2.clothesType = selectClothes(people2.height);

行为型

策略

定义一系列的算法,把它们一个个封装起来,并且可以相互替换,这就是策略模式。要使用策略模式,必须了解所有的 strategy,必须了解各个 strategy 之间的不同点, 这样才能选择一个合适的 strategy。比如,我们要选择一种合适的旅游出行路线,必须先了解选择飞机、火车、自行车等方案的细节。此时 strategy 要向客户暴露它的所有实现,这是违反最少知识原则的。

我直接使用《JavaScript 设计模式和开发实战》中的一个例子。这是一个计算不同绩效的人对应不同的奖金(奖金 = 工资 * 对应的绩效算法)。

const strategies = {
  S: function(salary) {
    return salary * 4;
  },
  A: function(salary) {
    return salary * 3;
  },
  B: function(salary) {
    return salary * 2
  }
}

const calculateBonus = function(level, salary) {
  return strategies[level][salary];
}

const staff1 = calculateBonus('S', 10000);
const staff2 = calculateBonus('A', 20000);

模板

将公共的代码抽成一个抽象类,子类继承抽象类,并重写相应的方法。模板方法模式是为数不多的基于继承的设计模式。

因为JS中无法实现抽象类,我直接用TS实现这个例子。

如果你对TS还不了解,可以看这篇文章。

Peter Cheng:《TypeScript开发实战》总结​
zhuanlan.zhihu.com图标
abstract class Beverage {
  init() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }
  boilWater() {
    console.log('把自来水煮沸');
  }
  abstract brew(): void
  abstract pourInCup(): void
  abstract addCondiments(): void
}

class Tea extends Beverage {
  brew() {
    console.log('用沸水浸泡茶叶');
  }
  pourInCup() {
    console.log('把茶倒进杯子里');
  }
  addCondiments() {
    console.log('加点糖');
  }
}

class Coffee extends Beverage {
   brew() {
    console.log('用沸水浸泡咖啡');
  }
  pourInCup() {
    console.log('把咖啡倒进杯子里');
  }
  addCondiments() {
    console.log('加点牛奶');
  }
}

const tea = new Tea();
tea.init();

const coffee = new Coffee();
coffee.init();

观察者

又称发布-订阅模式,它定义对象间的一种一对多的依赖关系。主要用于异步编程。JavaScript 本身也是一门基于事件驱动的语言,也利用了发布订阅模式。

下面是JS中自定义事件,它就是一个典型的观察者模式。

const msg = new Event('message');
window.addEventListener('message', function() {
  console.log('我接收到了消息');
});
window.dispatchEvent(msg);

我们来手写一个观察者模式,下面这个模式比较简陋,边界处理很粗糙。

class Event {
  constructor() {
    this.events = [];
  }

  on(fn) {
    this.events.push(fn);
  }

  emit(data) {
    this.events.forEach(fn => {
      fn(data);
    })
  }

  // off方法我这里就不实现了,也比较简单
  off () {}
}

const event = new Event();
event.on(function(data) {
  console.log('我是第一个注册事件', data);
});
event.on(function(data) {
  console.log('我是第二个注册事件', data);
});
event.emit('已发送');

迭代器

是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。分为内部迭代器和外部迭代器。

像我们大部分迭代器都是内部迭代器。比如 forEach,map ,filter 等。而外部迭代器,迭代的控制权在外部。

const Iterator = function(obj){
  let current = 0;

  const next = function(){
    console.log(obj[current]);
    current++;
  };

  const isDone = function(){
    return current >= obj.length;
  };

  const getCurItem = function(){
    return obj[current];
  };

  return {
    next: next,
    isDone: isDone,
    getCurItem: getCurItem
  }
};

const iterator = new Iterator([1, 2, 3]);
iterator.next(); // 1
iterator.next(); // 2
iterator.next(); // 3

中介者

定义一个中介对象来封装一系列对象之间的交互,使原有对象之间的耦合松散,且可以独立地改变它们之间的交互。主要解决各个类之间关系复杂,且每个类都需要知道它要交互的类。这个时候就可以引入中介者,把脏活累活,耦合关系全放到中介者类中,我们其他类负责貌美如花。

下面是一个机场指挥塔的例子,它就是一个中介者,所有飞机想要获取航道信息都需要向指挥塔请求。

class PlaneCommandTower {
  constructor(channel) {
    this.channel = new Array(channel).fill(false);
  }

  enter(i) {
    if (this.channel[i] === true || (i + 1 > this.channel.length)) {
      throw new Error('这个航道已经被占用了');
    }
    console.log('航道预占成功');
    this.channel[i] = true;
  }

  leave(i) {
    this.channel[i] = false;
  }

  isBusy(i) {
    return this.channel[i];
  }

  getChannelCnt() {
    return this.channel.length;
  }
}

class Plane {
  constructor(commander) {
    this.commander = commander;
    this.channelCnt = this.commander.getChannelCnt();
  }

  land() {
    let i = 0;
    while(this.commander.isBusy(i)) {
      ++i;
      if (i > this.channelCnt) {
        break;
      }
    }
    if (i < this.channelCnt) {
      this.commander.enter(i);
    } else {
      console.log('航道已经被占满了');
      return 'wait';
    }
  }
}

const commander = new PlaneCommandTower(3);

const plane1 = new Plane(commander);
const plane2 = new Plane(commander);
const plane3 = new Plane(commander);
const plane4 = new Plane(commander);

plane1.land();
plane2.land();
plane3.land();
plane4.land();

状态

状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。举个例子,有一个电灯,电灯上面只有一个开关。当电灯开着的时候,此时按下开关,电灯会切换到关闭状态;再按一次开关,电灯又将被打开。同一个开关按钮,在不同的状态下,表现出来的行为是不一样的。

我们以灯的例子,扩展一下,比较高级的灯,不止开关两种状态,它有弱光,正常光,环保光,关四种状态。

class Light {
  constructor() {
    this.status = ['opened', 'closed'];
    this.curStatus = -1;
  }

  setStatus(status) {
    this.status = status;
  }

  press() {
    this.curStatus = (this.curStatus + 1 === this.status.length) ? 1 : this.curStatus + 1;
    console.log(this.status[this.curStatus]);
  }
}

const light = new Light();
light.setStatus(['weak', 'common', 'environmental', 'closed']);
light.press(); // weak
light.press(); // common
light.press(); // environmental
light.press(); // closed

职责链

定义:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。举个例子,如果早高峰能顺利挤上公交车的话,那么估计这一天都会过得很开心。因为公交车上人 实在太多了,经常上车后却找不到售票员在哪,所以只好把两块钱硬币往前面递。除非 你运气够好,站在你前面的第一个人就是售票员,否则,你的硬币通常要在 N 个人手上 传递,才能最终到达售票员的手里。

在JS中,无论是作用域链、原型链,还是 DOM 节点中的事件冒泡,我们都能从中找到职责链模式的影子。

假设我们负责一个售卖手机的电商网站,经过分别交纳 500 元定金和 200 元定金的两轮预定后(订单已在此时生成),现在已经到了正式购买的阶段。公司针对支付过定金的用户有一定的优惠政策。在正式购买后,已经支付过 500 元定金的用 户会收到 100 元的商城优惠券,200 元定金的用户可以收到 50 元的优惠券,而之前没有支付定金 的用户只能进入普通购买模式。

class Chain {
  constructor(fn) {
    this.fn = fn;
    this.successor = null;
  }

  setNextSuccessor(successor) {
    return this.successor = successor;
  }

  passRequest() {
    const ret = this.fn.apply(this, arguments);
    if (ret === 'nextSuccessor'){
      return this.successor && this.successor.passRequest.apply(this.successor, arguments);
    }
    return ret;
  }
}

const chainOrder500 = new Chain(order500);
const chainOrder200 = new Chain(order200);
const chainOrderNormal = new Chain(orderNormal);

chainOrder500.setNextSuccessor(chainOrder200);

chainOrder200.setNextSuccessor(chainOrderNormal);

chainOrder500.passRequest(1, true, 500); 
chainOrder500.passRequest(2, true, 500); 
chainOrder500.passRequest(3, true, 500); 
chainOrder500.passRequest(1, false, 0);

命令

命令模式中的命令指的是一个执行某些特定事情的指令。命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。

下面通过一个按钮的点击事件,触发页面的刷新事件。

const button1 = document.createElement('button');
document.body.appendChild(button1);

const setCommand = function(button, func) {
  button.onclick = function() {
    func();
  };
};
const MenuBar = {
  refresh: function() {
    console.log("刷新菜单界面");
  }
};
const RefreshMenuBarCommand = function(receiver) {
  return function() {
    receiver.refresh();
  };
};

const refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
setCommand(button1, refreshMenuBarCommand);

button1.click();

访问者

在访问者模式(Visitor Pattern)中,我们使用了一个访问者类,它改变了元素类的执行算法。通过这种方式,元素的执行算法可以随着访问者改变而改变。其次,必须定义一个访问者类,并且内部有visit方法。元素的执行算法实现accept方法,而内部通常都是visitor.visit(this);

function Visitor() {
  this.visit = function (v) {
    console.log('the computer type is ' + v.type);
  }
}

function ComputerTypeVisitor(type) {
  this.type = type;
  this.accept = function (visitor) {
    visitor.visit(this);
  }
}

const visitor = new ComputerTypeVisitor('dell');
visitor.accept(new Visitor()); // the computer type is dell

备忘录

定义:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样就可以将该对象恢复到原先保存的状态

const Page = function () {
  let page = 1,
    cache = {},
    data;
  return function (page) {
    if (cache[page]) {
      data = cache[page];
      render(data);
    } else {
      Ajax.send('cgi.xx.com/xxx', function (data) {
        cache[page] = data;
        render(data);
      });
    }
  }
}();

解释器

它是一种特殊的设计模式,它建立一个解释器,对于特定的计算机程序设计语言,用来解释预先定义的文法。通俗点,你通过函数名的定义就能知道程序即将要执行的过程。

function Context() {
  let sum;
  let list = [];
  this.getSum = function () {
    return sum;
  }
  this.setSum = function (_sum) {
    sum = _sum;
  }
  this.add = function (eps) {
    list.push(eps);
  }
  this.getList = function () {
    return list;
  }
}

function PlusExpression() {
  this.interpret = function (context) {
    let sum = context.getSum();
    sum++;
    context.setSum(sum);
  }
}

function MinusExpression() {
  this.interpret = function (context) {
    let sum = context.getSum();
    sum--;
    context.setSum(sum);
  }
}

const context = new Context();
context.setSum(20);
//运行加法三次
context.add(new PlusExpression());
context.add(new PlusExpression());
context.add(new PlusExpression());
//运行减法两次
context.add(new MinusExpression());
context.add(new MinusExpression());
const list = context.getList();
for (let i = 0; i < list.length; i++) {
  const expression = list[i];
  expression.interpret(context);
}
console.log("打印输出结果:" + context.getSum()); // 打印输出结果:21

结束语

文章中的内容如果有错误,请及时指出。毕竟将面向对象标准的设计模式迁移到JS中,可能会存在使用不当的情况。文章中的好几个例子都来自《JavaScript 设计模式与开发实战》。等“设计模式之美-前端”篇章彻底结束,我会更新“数据结构和算法-前端”篇,敬请期待吧。

参考资料:《JavaScript 设计模式与开发实战》,《设计模式之美》专栏,《设计模式:可复用面向对象软件的基础》

【JS设计模式】解释器模式代码示例

js设计模式-备忘录模式

如果你是前端小白,可以通过这篇文章找到前端之路,目测今年点赞能破1000。

Peter Cheng:对前端小白的告白​

zhuanlan.zhihu.com图标