JavaScript 常见设计模式

3,521 阅读12分钟

前言

设计模式,这一话题一直都是程序员谈论的"高端"话题之一。许多程序员从设计模式中学到了设计软件的灵感和解决方案。

有人认为设计模式只在 C++或者 Java 中有用武之地,JavaScript 这种动态语言根本就没有设计模式一说。

那么,什么是设计模式?

设计模式:在面向对象软件设计过程中,针对特定问题的简洁而优雅的解决方案。

通俗一点讲,设计模式就是在某种场合下对某个问题的一种解决方案。如果再通俗一点说,设计模式就是给面向对象软件开发中的一些好的方法,抽象、总结、整理后取了个漂亮,专业的名字

其实很多设计模式在我们日常的开发过程中已经有使用到,只是差一步来真正意识、明确到:"哦!我用 xx 设计模式来完成了这项业务"!

而下次在遇到同样问题时,便可以快速在脑海里确定,要使用 xx 设计模式完成任务。

对此,我整理了一些前端常用到的一些设计模式。

单例模式

单例模式,也叫单子模式,是一种常用的软件设计模式。 在应用这个模式时,单例对象的类必须保证只有一个实例存在。 许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。

单例模式作为各端语言一个比较常见的设计模式,一般用于处理在一个生命周期中仅需要存在一次即可完成任务的内容来提升性能及可用性。非常常见的用于后端开发中,如连接 Redis、创建数据库连接池等。

在 JavaScript 中的应当如何应用呢?

在 JavaScript 中什么情况下会用到单例模式呢?

import Router from "vue-router";

export default new Router({
  mode: "hash",
  routes: [
    {
      path: "/home",
      name: "Home",
      component: Home,
      children: []
    }
  ]
});

这就是在日常开发中最常用到的单例模式,在整个页面的生命周期中,只需要有一个Router来管理整个路由状态,所以在route中直接export已经实例化后的对象,那么在任何模块中,只要引入这个模块都可以改变整个路由状态。

通过这种方式引入有一个小的问题就是:所用到的单例内容,全部是在调用方引入过程中就已经完成实例化的,一般来说调用方的引入也都是非动态引入,所以页面一开始加载的时候便已经加载完毕。

上述这种用法是属于利用 JS 模块化,完成的一种变异单例,那么一个标准的单例写法应该是什么样的呢?

export default class LoginDialog {
  private static _instance: LoginDialog;
  private component: VueComponent;

  public static getInstance() {
    if (!this._instance) {
      this._instance = new LoginDialog();
    }

    return this._instance;
  }

  private constructor() {
    // 创建登录组件Dom
    this.component = createLoginComponent();
  }

  public show() {
    this.component.show();
  }

  public hide() {
    this.component.hide();
  }
}

// 调用处
const loginDialog = LoginDialog.getInstance();
loginDialog.show();

以上是一个简单的登录弹窗组件的单例实现,这样实现后有以下几个好处:

  • 避免多次创建页面 Dom 节点
  • 隐藏、重新打开保存上次输入结果
  • 调用简单,随处可调
  • 按需创建,第一次调用才被创建

常见坑点

在单例的实例化过程中,假若需要异步调用后才能创建实例结果,如:

export default class LoginDialog {
  private static _instance: LoginDialog;
  private component: VueComponent;
  private loginType: any;

  public static async getInstance() {
    if (!this._instance) {
      const loginData = await axios.get(url);
      this._instance = new LoginDialog(loginData);
    }

    return this._instance;
  }

  private constructor(loginType) {
    this.loginType = loginType;
    // 创建登录组件Dom
    this.component = createLoginComponent();
  }
}

// 调用方1
(async () => {
  await LoginDialog.getInstance();
})();

// 调用方2
(async () => {
  await LoginDialog.getInstance();
})();

像这样的代码中,返回的结果将会是LoginDialog被实例化两次。所以遇到异步调用这样的异步单例,属于 Js 的一种比较特殊的实现方式。

应该尽量的避免异步单例的情况发生,但若一定需要这样调用,可以这样写。

export default class LoginDialog {
  private static _instance: LoginDialog;
  private static _instancePromise: Promise;

  private component: VueComponent;
  private loginType: any;

  public static async getInstance() {
    if (!this._instancePromise) {
      this._instancePromise = axios.get(url);
    }

    const loginData = await this._instancePromise;

    if (!this._instance) {
      this._instance = new LoginDialog(loginData);
    }

    return this._instance;
  }

  private constructor(loginType) {
    this.loginType = loginType;
    // 创建登录组件Dom
    this.component = createLoginComponent();
  }
}

策略模式

策略模式,定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

简单来讲,就是完成一个方法过程中,可能会用到一系列的工具,通过外部传入区分类别的参数来达到使用不同方法的封装。

举一个老例子,公司的年终奖计算,A 为 3 月薪,B 为 2 月薪,C 为 1 月薪:

const calculateBouns = function(salary, level) {
  if (level === "A") {
    return salary * 3;
  }
  if (level === "B") {
    return salary * 2;
  }
  if (level === "C") {
    return salary * 1;
  }
};

// 调用如下:
console.log(calculateBouns(4000, "A")); // 16000
console.log(calculateBouns(2500, "B")); // 7500

上述代码中有几个明显的问题:

  • calculateBouns函数内容集中
  • calculateBouns函数扩展性低
  • 算法复用性差,如果在其他的地方也有类似这样的算法的话,但是规则不一样,我们这些代码不能通用

一个基于策略模式的程序至少由 2 部分组成.

  1. 一组策略类,策略类封装了具体的算法,并负责具体的计算过程。
  2. 环境类 Context,该 Context 接收客户端的请求,随后把请求委托给某一个策略类。
class Bouns {
  salary: number = null; // 原始工资
  levelObj: IPerformance = null; // 绩效等级对应的策略对象

  constructor(salary: number, performanceMethod: IPerformance) {
    this.setSalary(salary);
    this.setLevelObj(performanceMethod);
  }

  setSalary(salary) {
    this.salary = salary; // 保存员工的原始工资
  }
  setLevelObj(levelObj) {
    this.levelObj = levelObj; // 设置员工绩效等级对应的策略对象
  }
  getResult(): number {
    if (!this.levelObj || !this.salary) {
      throw new Error("Necessary parameter missing");
    }
    return this.levelObj.calculate(this.salary);
  }
}
interface IPerformance {
  calculate(salary: number): number;
}

class PerformanceA implements IPerformance {
  calculate(salary) {
    return salary * 3;
  }
}

class PerformanceB implements IPerformance {
  calculate(salary) {
    return salary * 2;
  }
}

class PerformanceC implements IPerformance {
  calculate(salary) {
    return salary * 1;
  }
}

console.log(new Bouns(4000, new PerformanceA()).getResult());
console.log(new Bouns(2500, new PerformanceB()).getResult());

这种做法能够具有非常高的可复用性及扩展性。写过 ng 的读者,看到这里是否觉得非常眼熟?

没错,ng 所提倡的依赖注入就是使用了策略模式的设计思路。

迭代器模式

迭代器模式:提供一种方法顺序一个聚合对象中各个元素,而又不暴露该对象内部表示。

迭代器模式其实在前端编码中非常常见,因为在 JS 的Array中已经提供了许多迭代器方法如:map,reduce,some,every,find,forEach等。

那是否能理解为,迭代器模式的作用就是为了让我们减少 for 循环呢?

来先看一个面试题:

const removeCharacter = str => str.replace(/[^\w\s]/g, " ");
const toUpper = str => str.toUpperCase();
const split = str => str.split(" ");
const filterEmpty = arr => arr.filter(str => !!str.trim().length);

const fn = compose(
  removeCharacter,
  toUpper,
  split,
  filterEmpty
);

fn("Hello, to8to World!"); // => ["HELLO","TO8TO","WORLD"]

// 请实现`compose`方法来达到效果

这道题的内容虽然是在考察函数式编程的理解,但却蕴含着迭代器模式的设计思路,利用迭代器模式,将一个个的方法融合成为一个新的方法。其中的融合方法又可以作为参数替换,来达到不同效果。

那么除了这种用法,有没有日常项目中 "更常用" 的场景或用途呢?

常见的,如验证器:

// 将数组中的every方法重新写一下,让读者更清晰
const every = (...args: Array<(args: any) => boolean>) => {
  return (str: string) => {
    for (const fn of args) {
      if (!fn(str)) {
        return false;
      }
    }

    return true;
  };
};

const isString = (str: string): boolean => typeof str === "string";
const isEmpty = (str: string): boolean => !!`${str}`.trim().length;
const isEmail = (str: string): boolean =>
  /^[\w.\-]+@(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,3}$/.test(str);
const isPhone = (str: string): boolean => /^1\d{10}$/.test(str);
const minLength = (num: number): ((str: string) => boolean) => {
  return str => `${str}`.trim().length > num;
};

const validatorEmail = every(isString, isEmpty, minLength(5), isEmail);
const validatorPhone = every(isString, isEmpty, minLength(5), isPhone);

console.log(validatorEmail("wyy.xb@qq.com"));
console.log(validatorPhone("13388888888"));

可以看到,不同的验证类型可以相互组合,可添可删可自定义。

以上是一个简单的对字符串的验证应用,同样的迭代设计可以应用在更复杂的场景中,如在游戏应用中:

  • 对一个实体墙体绘制过程中,是否合法(是否穿过门窗,是否穿过弧形墙,是否过短,是否夹角过小)
  • 移动物体时,对物体模型做碰撞吸附过程计算位移(与附近物体、墙体吸附位移,与墙体碰撞位移,与其他物体叠放位移)

发布-订阅模式

发布-订阅模式,他定义了一种一对多的依赖关系,即当一个对象的状态发生改变的时候,所有依赖他的对象都会得到通知。

发布-订阅模式(观察者模式),在编程生涯中是非常常见并且出色的设计模式,不论前端、后端掌握好了这一设计模式,将会为你的职业生涯增加一大助力。

我们常常听说的各种 Hook,各种事件纷发,其实都是在使用这一设计模式。

作为一名前端开发人员,给 DOM 节点绑定事件可是再频繁不过的事情。比如如下代码

document.body.addEventListener(
  "click",
  function() {
    alert(2333);
  },
  false
);
document.body.click();

这里我们订阅了 document.body 的 click 事件,当 body 被点击的时候,他就向订阅者发布这个消息,弹出 2333。当消息一发布,所有的订阅者都会收到消息。

那么内部到底发生了什么?来看看一个简单的观察者模式的实现过程:

const event = {
  peopleList: [],
  addEventListener: function(eventName, fn) {
    if (!this.peopleList[eventName]) {
      //如果没有订阅过此类消息,创建一个缓存列表
      this.peopleList[eventName] = [];
    }
    this.peopleList[eventName].push(fn);
  },
  dispatch: function() {
    let eventName = Array.prototype.shift.call(arguments);
    let fns = this.peopleList[eventName];
    if (!fns || fns.length === 0) {
      return false;
    }
    for (let i = 0, fn; (fn = fns[i++]); ) {
      fn.apply(this, arguments);
    }
  }
};

了解到实现的原理后,那么在日常的开发过程中,要如何真正利用发布-订阅模式处理业务功能呢?

首先来说实现过程,在日常开发中,不会直接去书写这样一大堆代码来实现一个简单的观察者模式,而是直接会借助一些库来方便实现功能。

import EventEmitter3 from "EventEmitter3";

export default class Wall extends EventEmitter3 {}

const wall = new Wall();

wall.addEventListener("visibleChange", () => {});
wall.on("visibleChange", () => {}); // addEventListener 别名

// 一次时间后释放监听
wall.once("visibleChange", () => {});

wall.removeEventListener("visibleChange", () => {});
wall.off("visibleChange", () => {}); // removeEventListener 别名

wall.emit("visibleChange");

常见坑点

发布-订阅模式是在编程过程中非常出色的设计模式,在日常业务开发中方便高效的帮我们解决问题的同时,也存着这一些坑点,需要格外注意:

import EventEmitter3 from "EventEmitter3";

export default class Wall extends EventEmitter3 {}
export default class Hole extends EventEmitter3 {
  public relatedWall(wall: Wall) {
    wall.on("visibleChange", wall => (this.visible = wall.visible));
  }
}

const wall = new Wall();
let hole = new Hole();
hole.relatedWall(wall);

// hole.destroy();
hole = null;

如上,我实现了一个简单的功能,当墙体隐藏时,墙体上的洞也通过观察者模式跟随隐藏。

后来,我想要删除这个 墙洞。按照 Js 的常规用法,不用特意处理释放内存,Js 的垃圾回收机制会帮我们处理好内存。

但是,这里虽然设置了 hole 为null,hole 却在内存中依旧存在!

企业微信20190304064031.png

因为垃圾回收机制中,不论是 引用计数垃圾收集 还是 标记-清除 都是采用引用来判断是否对变量内存销毁。

而上述代码中,wall 自身原型链中的events已经有对 hole 有所引用。如果不清除他们之间的引用关系,hole 在内存中就不会被销毁。

如何做到既优雅又快速的清除引用呢?

import EventEmitter3 from "EventEmitter3";

/**
 * 抽象工厂方法,执行on,并返回对应off事件
 * @param eventEmit
 * @param type
 * @param fn
 */
const observe = (
  eventEmit: EventEmitter3,
  type: string,
  fn: (...args) => any
): (() => void) => {
  eventEmitter.on(type, fn);
  return () => eventEmitter.off(type, fn);
};

export default class Wall extends EventEmitter3 {}
export default class Hole extends EventEmitter3 {
  private disposeArr: Array<() => void> = [];

  public relatedWall(wall: Wall) {
    this.disposeArr.push(
      observe(wall, "visibleChange", wall => (this.visible = wall.visible))
    );
  }

  public destroy() {
    while (this.disposeArr.length) {
      this.disposeArr.pop()();
    }
  }
}

const wall = new Wall();
let hole = new Hole();
hole.relatedWall(wall);

hole.destroy();
hole = null;

如上,在 hole 对 wall 进行订阅时,利用封装的工厂类方法,同时返回了这个方法的释放订阅方法

并加入到了当前类的释放数组中,当 hole 需要销毁时,只需简单调用hole.destroy(),hole 在实例化过程中的所有订阅事件将全部会被释放。 Bingo!

适配器模式

适配器模式:是将一个类(对象)的接口(方法或属性)转化成客户希望的另外一个接口(方法或属性),适配器模式使得原本由于接口不兼容而不能一起工作的那些类(对象)可以一些工作。

适配器模式在前端项目中一般会用于做数据接口的转换处理,比如把一个有序的数组转化成我们需要的对象格式:

const arr = ["Javascript", "book", "前端编程语言", "8月1日"];
function arr2objAdapter(arr) {
  // 转化成我们需要的数据结构
  return {
    name: arr[0],
    type: arr[1],
    title: arr[2],
    time: arr[3]
  };
}

const adapterData = arr2objAdapter(arr);

在前后端的数据传递的时候会经常使用到适配器模式,如果后端的数据经常变化,比如在某些网站拉取的数据,后端有时无法控制数据的格式。

所以在使用数据前,最好能够定义前端数据模型通过适配器解析数据接口。 Vmo就是一个我用于做这类工作的数据模型所开发的微型框架。

另外,对于一些面向对象的复杂类处理时,为了使方法复用,同样可能会使用到适配器模式。

// 正常模型
class Model {
  public position: Vector3;
  public rotation: number;
  public scale: Vector3;
}

// 横梁立柱
class CubeBox {
  public position: Vector2;
  public rotation: number;
  public scale: Vector3;
  public heightToTop: number;
  public heightToBottom: number;
}

const makeVirtualModel = (cube: CubeBox): Model => {
  const model = new Model();
  model.position = new Vector3(
    cube.position.x,
    cube.heightToBottom,
    cube.position.y
  );
  model.rotation = cube.rotation;
  model.scale = cube.scale.clone();

  return model;
};

const adsorbModel = (model: Model): Vector3 => {};

const model = new Model();
const cube = new CubeBox();

// 模型吸附偏移向量
const modelOffset = adsorbModel(model);

// 如果CubeBox,立柱同样需要使用吸附功能,但成员变量类型不同,就需要先适配后再计算
const cubeOffset = adsorbModel(makeVirtualModel(cube));

附录

迭代器模式中面试题参考答案

const compose = (...args) => {
  return str => args.reduce((prev, next) => next.call(null, prev), str);
};
const compose = (...funcs) =>
  funcs.reduce((prev, next) => (...args) => next(prev(...args)));