JavaScript 常見設計模式

YeeWang王大白發表於2019-03-07

前言

設計模式,這一話題一直都是程式設計師談論的"高階"話題之一。許多程式設計師從設計模式中學到了設計軟體的靈感和解決方案。

有人認為設計模式只在 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)));
複製程式碼

相關文章