JavaScript 常见设计模式

前言

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

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

那么,什么是设计模式?vue

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

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

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

而下次在遇到一样问题时,即可以快速在脑海里肯定,要使用 xx 设计模式完成任务。程序员

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

单例模式

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

单例模式做为各端语言一个比较常见的设计模式,通常用于处理在一个生命周期中仅须要存在一次便可完成任务的内容来提高性能及可用性。很是常见的用于后端开发中,如链接 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)));
复制代码
相关文章
相关标签/搜索