什么是设计模式?


设计模式是对软件设计开发过程中反复出现的某类问题的通用解决方案。设计模式更多的是指导思想和方法论,而不是现成的代码,当然每种设计模式都有每种语言中的具体实现方式。学习设计模式更多的是理解各种模式的内在思想和解决的问题,毕竟这是前人无数经验总结成的最佳实践,而代码实现则是对加深理解的辅助。

设计模式的类型


设计模式可以分为三大类:

  1. 结构型模式(Structural Patterns): 通过识别系统中组件间的简单关系来简化系统的设计。

  2. 创建型模式(Creational Patterns): 处理对象的创建,根据实际情况使用合适的方式创建对象。常规的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。创建型模式通过以某种方式控制对象的创建来解决问题。

  3. 行为型模式(Behavioral Patterns): 用于识别对象之间常见的交互模式并加以实现,如此,增加了这些交互的灵活性。

以上定义非常的抽象和晦涩,对于我们初学者并没有太多帮助,要了解这些设计模式真正的作用和价值还是需要通过实践去加以理解。这三大类设计模式又可以分成更多的小类,如下图:

alt 属性文本

下面我们选择一些在前端开发过程中常见的模式进行一一讲解。

一. 结构型模式(Structural Patterns)


1. 外观模式(Facade Pattern)

alt 属性文本

外观模式是最常见的设计模式之一,它为子系统中的一组接口提供一个统一的高层接口,使子系统更容易使用。简而言之外观设计模式就是把多个子系统中复杂逻辑进行抽象,从而提供一个更统一、更简洁、更易用的API。很多我们常用的框架和库基本都遵循了外观设计模式,比如JQuery就把复杂的原生DOM操作进行了抽象和封装,并消除了浏览器之间的兼容问题,从而提供了一个更高级更易用的版本。其实在平时工作中我们也会经常用到外观模式进行开发,只是我们不自知而已。

比如,我们可以应用外观模式封装一个统一的DOM元素事件绑定/取消方法,用于兼容不同版本的浏览器和更方便的调用:

// 绑定事件
function addEvent(element, event, handler) {
  if (element.addEventListener) {
    element.addEventListener(event, handler, false);
  } else if (element.attachEvent) {
    element.attachEvent('on' + event, handler);
  } else {
    element['on' + event] = fn;
  }
}

// 取消绑定
function removeEvent(element, event, handler) {
  if (element.removeEventListener) {
    element.removeEventListener(event, handler, false);
  } else if (element.detachEvent) {
    element.detachEvent('on' + event, handler);
  } else {
    element['on' + event] = null;
  }
}
2. 代理模式(Proxy Pattern)

alt 属性文本

首先,一切皆可代理,不管是在实现世界还是计算机世界。现实世界中买房有中介、打官司有律师、投资有经纪人,他们都是代理,由他们帮你处理由于你缺少时间或者专业技能而无法完成的事务。类比到计算机领域,代理也是一样的作用,当访问一个对象本身的代价太高(比如太占内存、初始化时间太长等)或者需要增加额外的逻辑又不修改对象本身时便可以使用代理。ES6中也增加了 Proxy 的功能。

归纳一下,代理模式可以解决以下的问题:

  1. 增加对一个对象的访问控制

  2. 当访问一个对象的过程中需要增加额外的逻辑

要实现代理模式需要三部分:

Real Subject:真实对象

Proxy:代理对象

Subject接口:Real Subject 和 Proxy都需要实现的接口,这样Proxy才能被当成Real Subject的“替身”使用

比如有一个股票价格查询接口,调用这个接口需要比较久的时间(用 setTimeout模拟2s的调用时间):

StockPriceAPI:

function StockPriceAPI() {
  // Subject Interface实现
  this.getValue = function (stock, callback) {
    console.log('Calling external API ... ');
    setTimeout(() => {
      switch (stock) {
        case 'GOOGL':
          callback('$1265.23');
          break;
        case 'AAPL':
          callback('$287.05');
          break;
        case 'MSFT':
          callback('$173.70');
          break;
        default:
          callback('');
      }
    }, 2000);
  }
}

我们不希望每次都去请求远程接口,而是增加缓存机制,当有缓存的时候就直接从缓存中获取,否则再去请求远程接口。我们可以通过一个proxy来实现:

StockPriceAPIProxy:

function StockPriceAPIProxy() {
  // 缓存对象
  this.cache = {};
  // 真实API对象
  this.realAPI = new StockPriceAPI();
  // Subject Interface实现
  this.getValue = function (stock, callback) {
    const cachedPrice = this.cache[stock];
    if (cachedPrice) {
      console.log('Got price from cache');
      callback(cachedPrice);
    } else {
      this.realAPI.getValue(stock, (price) => {
        this.cache[stock] = price;
        callback(price);
      });
    }
  }
}

注意,Proxy需要和真实对象一样实现 getValue() 方法,getValue()就属于 Subject 接口。

测试一下:

const api = new StockPriceAPIProxy();
api.getValue('GOOGL', (price) => { console.log(price) });
api.getValue('AAPL', (price) => { console.log(price) });
api.getValue('MSFT', (price) => { console.log(price) });

setTimeout(() => {
  api.getValue('GOOGL', (price) => { console.log(price) });
  api.getValue('AAPL', (price) => { console.log(price) });
  api.getValue('MSFT', (price) => { console.log(price) });
}, 3000)

输出:

Calling external API ...
Calling external API ...
Calling external API ...
$1265.23
$287.05
$173.70
Got price from cache
$1265.23
Got price from cache
$287.05
Got price from cache
$173.70

二. 创建型模式(Creational Patterns)


1. 工厂模式(Factory Pattern)

alt 属性文本

现实生活中的工厂按照既定程序制造产品,随着生产原料和流程不同生产出来的产品也会有区别。应用到软件工程的领域,工厂可以看成是一个制造其他对象的对象,制造出的对象也会随着传入工厂对象参数的不同而有所区别。

什么场景适合应用工厂模式而不是直接 new一个对象呢?当构造函数过多不方便管理,且需要创建的对象之间存在某些关联(有同一个父类、实现同一个接口等)时,不妨使用工厂模式。工厂模式提供一种集中化、统一化的方式,避免了分散创建对象导致的代码重复、灵活性差的问题。

以上图为例,我们构造一个简单的汽车工厂来生产汽车:

// 汽车构造函数
function SuzukiCar(color) {
  this.color = color;
  this.brand = 'Suzuki';
}

// 汽车构造函数
function HondaCar(color) {
  this.color = color;
  this.brand = 'Honda';
}

// 汽车构造函数
function BMWCar(color) {
  this.color = color;
  this.brand = 'BMW';
}

// 汽车品牌枚举
const BRANDS = {
  suzuki: 1,
  honda: 2,
  bmw: 3
}

/**
 * 汽车工厂
 */
function CarFactory() {
  this.create = function (brand, color) {
    switch (brand) {
      case BRANDS.suzuki:
        return new SuzukiCar(color);
      case BRANDS.honda:
        return new HondaCar(color);
      case BRANDS.bmw:
        return new BMWCar(color);
      default:
        break;
    }
  }
}

测试一下:

const carFactory = new CarFactory();
const cars = [];

cars.push(carFactory.create(BRANDS.suzuki, 'brown'));
cars.push(carFactory.create(BRANDS.honda, 'grey'));
cars.push(carFactory.create(BRANDS.bmw, 'red'));

function say() {
  console.log(`Hi, I am a ${this.color} ${this.brand} car`);
}

for (const car of cars) {
  say.call(car);
}

输出:

Hi, I am a brown Suzuki car
Hi, I am a grey Honda car
Hi, I am a red BMW car

使用工厂模式之后,不再需要重复引入一个个构造函数,只需要引入工厂对象就可以方便的创建各类对象。

2. 单例模式(Singleton Pattern)

alt 属性文本

顾名思义,单例模式中Class的实例个数最多为1。当需要一个对象去贯穿整个系统执行某些任务时,单例模式就派上了用场。而除此之外的场景尽量避免单例模式的使用,因为单例模式会引入全局状态,而一个健康的系统应该避免引入过多的全局状态。

实现单例模式需要解决以下几个问题:

  1. 如何确定Class只有一个实例?

  2. 如何简便的访问Class的唯一实例?

  3. Class如何控制实例化的过程?

  4. 如何将Class的实例个数限制为1?

我们一般通过实现以下两点来解决上述问题:

  1. 隐藏Class的构造函数,避免多次实例化

  2. 通过暴露一个 getInstance() 方法来创建/获取唯一实例

Javascript中单例模式可以通过以下方式实现:

// 单例构造器
const FooServiceSingleton = (function () {
  // 隐藏的Class的构造函数
  function FooService() {}

  // 未初始化的单例对象
  let fooService;

  return {
    // 创建/获取单例对象的函数
    getInstance: function () {
      if (!fooService) {
        fooService = new FooService();
      }
      return fooService;
    }
  }
})();
实现的关键点有:
1. 使用 IIFE创建局部作用域并即时执行;
2. **getInstance()** 为一个 闭包 ,使用闭包保存局部作用域中的单例对象并返回。

我们可以验证下单例对象是否创建成功:

const fooService1 = FooServiceSingleton.getInstance();
const fooService2 = FooServiceSingleton.getInstance();

console.log(fooService1 === fooService2); // true

三. 行为型模式(Behavioral Patterns)


1. 策略模式(Strategy Pattern)

策略模式简单描述就是:对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。比如每个人都要“交个人所得税”,但是“在美国交个人所得税”和“在中国交个人所得税”就有不同的算税方法。最常见的使用策略模式的场景如登录鉴权,鉴权算法取决于用户的登录方式是手机、邮箱或者第三方的微信登录等等,而且登录方式也只有在运行时才能获取,获取到登录方式后再动态的配置鉴权策略。所有这些策略应该实现统一的接口,或者说有统一的行为模式。Node 生态里著名的鉴权库 Passport.js API的设计就应用了策略模式。

还是以登录鉴权的例子我们仿照 passport.js 的思路通过代码来理解策略模式:

/**
 * 登录控制器
 */
function LoginController() {
  this.strategy = undefined;
  this.setStrategy = function (strategy) {
    this.strategy = strategy;
    this.login = this.strategy.login;
  }
}

/**
 * 用户名、密码登录策略
 */
function LocalStragegy() {
  this.login = ({ username, password }) => {
    console.log(username, password);
    // authenticating with username and password...
  }
}

/**
 * 手机号、验证码登录策略
 */
function PhoneStragety() {
  this.login = ({ phone, verifyCode }) => {
    console.log(phone, verifyCode);
    // authenticating with hone and verifyCode...
  }
}

/**
 * 第三方社交登录策略
 */
function SocialStragety() {
  this.login = ({ id, secret }) => {
    console.log(id, secret);
    // authenticating with id and secret...
  }
}

const loginController = new LoginController();

// 调用用户名、密码登录接口,使用LocalStrategy
app.use('/login/local', function (req, res) {
  loginController.setStrategy(new LocalStragegy());
  loginController.login(req.body);
});

// 调用手机、验证码登录接口,使用PhoneStrategy
app.use('/login/phone', function (req, res) {
  loginController.setStrategy(new PhoneStragety());
  loginController.login(req.body);
});

// 调用社交登录接口,使用SocialStrategy
app.use('/login/social', function (req, res) {
  loginController.setStrategy(new SocialStragety());
  loginController.login(req.body);
});

从以上示例可以得出使用策略模式有以下优势:

  1. 方便在运行时切换算法和策略

  2. 代码更简洁,避免使用大量的条件判断

  3. 关注分离,每个strategy类控制自己的算法逻辑,strategy和其使用者之间也相互独立

2. 迭代器模式(Iterator Pattern)