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)));
複製代碼
相關文章
相關標籤/搜索