常見 JavaScript 設計模式 — 原來這麼簡單

熊的貓發表於2022-11-22

設計模式

設計模式總共有 23 種,但在前端領域其實沒必要全部都去學習,畢竟大部分的設計模式是在 JavaScript 中佔的比重並不是那麼大,本文會列舉出一些 JavaScript 常見的、容易被忽視的設計模式,不過還是有必要先簡單瞭解一下設計模式相關的概念.

設計模式是什麼?

先舉個形象的例子,比如現在正在考試而且恰好在考數學,實際上每道數學題目都對應著一種或多種解決公式(如和三角形相關的勾股定理),而這些解決公式是經過數學家研究、推導、總結好的,我們只需要把 題目已有公式 對應上就很容易解決問題,而 設計模式 也是如此,只不過是它是相對於 軟體設計領域 而言的.

設計模式(Design pattern) 是一套被反覆使用、經過分類、程式碼設計經驗的總結,簡單來說設計模式就是為了解決 軟體設計領域 不同場景下相應問題的 解決方案.

設計原則(SOLID)

SOLID 實際上指的是五個基本原則,但在前端領域涉及到最多的是仍然是前面兩條:

  • 單一功能原則(Single Responsibility Principle)
  • 開放封閉原則(Opened Closed Principle)
  • 裡式替換原則(Liskov Substitution Principle)
  • 介面隔離原則(Interface Segregation Principle)
  • 依賴反轉原則(Dependency Inversion Principle)

設計模式的型別

主要分為三個型別:

  • 建立型

    • 主要用於解耦 物件的例項化 過程,即用於建立物件,如物件例項化
    • 本文主要包含:簡單工廠模式、抽象工廠模式、單例模式、原型模式
  • 行為型

    • 主要用於最佳化不同 物件介面 間的結構關係,如把 物件 結合在一起形成一個更大的結構
    • 本文主要包含:裝飾器模式、介面卡模式、代理模式
  • 結構型

    • 主要用於定義 物件 如何互動、劃分責任、設計演算法
    • 本文主要包含:策略模式、狀態模式、觀察者模式、釋出訂閱模式、迭代器模式

    建立型設計模式

    設計模式的核心是區分邏輯中的 可變部分不變部分,並使它們進行分離,從而達到使變化的部分易擴充套件、不變的部分穩定.

    工廠模式

    簡單工廠模式

    核心就是建立一個物件,這裡的 可變部分引數不變部分共有屬性.

舉例:透過不同職級的員工建立員工相關資訊,需要包含 name、age、position、job 等資訊.

實現方式一:

  • 核心就是 可變部分 預設 引數化

    function Staff(name, age, position, job) {
      this.name = name;
      this.age = age;
      this.position = position;
      this.job = job;
    }
    
    const developer = new Staff('zs', 18, 'develoment', ['寫 bug', '改 bug', '摸魚']);
    const productManager = new Staff('ls', 30, 'manager', ['提需求', '改需求', '面向 PPT 開發']);

    實現方式二:

  • 實際上在實現方式一中的 job 部分是和 position 是相互關聯的,可以認為 job 部分是 不變的,因此可以根據 position 內容的內容來自動匹配 job

    function Staff(name, age, position, job) {
      this.name = name;
      this.age = age;
      this.position = position;
      this.job = job;
    }
    
    function StaffFactory(name, age, position){
      let job = []
      switch (position) {
          case 'develoment':
              job = ['寫 bug', '改 bug', '摸魚'];
              break;
          case 'manager':
              job = ['提需求', '改需求', '面向 PPT 開發'];
              break;
          ...
      }
    
      return new Staff(name, age, position, job);
    }
    
    const developer = StaffFactory('zs', 18, 'developer');
    const productManager = StaffFactory('ls', 30, 'manager');

    抽象工廠模式

    這個模式最顯眼的就是 抽象 兩個字了,在如 Java 語言當中存在所謂的 抽象類,這個抽象類裡面的所有屬性和方法都沒有具體實現,只有單純的定義,而繼承這個抽象類的子類必須要實現其對應的抽象屬性和抽象方法.

JavaScript 中沒有這樣的直接定義,不過根據上面的描述其實我們可以把它對映到 typescript 中的 interface 介面,理解到這其實讓我聯想到了 vue.js 中的 自定義渲染器,預留的自定義渲染器的各個方法目的就是實現跨平臺的渲染方式

// 檔案位置:packages\runtime-core\src\renderer.ts
export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

// 檔案位置:packages\runtime-core\src\renderer.ts
// RendererOptions 就是一個 Interface 介面
export interface RendererOptions<
  HostNode = RendererNode,
  HostElement = RendererElement
> {
  patchProp(
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
    isSVG?: boolean,
    prevChildren?: VNode<HostNode, HostElement>[],
    parentComponent?: ComponentInternalInstance | null,
    parentSuspense?: SuspenseBoundary | null,
    unmountChildren?: UnmountChildrenFn
  ): void
  insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
  remove(el: HostNode): void
  createElement(
    type: string,
    isSVG?: boolean,
    isCustomizedBuiltIn?: string,
    vnodeProps?: (VNodeProps & { [key: string]: any }) | null
  ): HostElement
  createText(text: string): HostNode
  createComment(text: string): HostNode
  setText(node: HostNode, text: string): void
  setElementText(node: HostElement, text: string): void
  parentNode(node: HostNode): HostElement | null
  nextSibling(node: HostNode): HostNode | null
  querySelector?(selector: string): HostElement | null
  setScopeId?(el: HostElement, id: string): void
  cloneNode?(node: HostNode): HostNode
  insertStaticContent?(
    content: string,
    parent: HostElement,
    anchor: HostNode | null,
    isSVG: boolean,
    start?: HostNode | null,
    end?: HostNode | null
  ): [HostNode, HostNode]
}

接下來我們將以上的 typescript 的形式轉變成 JavaScript 形式的抽象模式:

// 抽象 Render 類
class Renderer {
  patchProp(
    el,
    key,
    prevValue,
    nextValue,
    isSVG,
    prevChildren,
    parentComponent,
    parentSuspense,
    unmountChildren
  ) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  insert(el, parent, anchor) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  remove(el) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  createElement(type, isSVG, isCustomizedBuiltIn, vnodeProps) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  createText(text) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  createComment(text) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  setText(node, text) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  setElementText(node, text) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  parentNode(node) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  nextSibling(node) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  querySelector(selector) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  setScopeId(el, id) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  cloneNode(node) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
  insertStaticContent(content, parent, anchor, isSVG, start, end) {
    throw Error('抽象工廠方法不能直接使用,你需要將我重寫!!!');
  }
}

// 具體渲染函式的實現
class createRenderer extends Renderer{
    // 待實現的渲染器方法
    ...
}

單例模式

核心就是透過多次 new 操作進行例項化時,能夠保證建立 例項物件唯一性.

vuex 中的單例模式

其實,vuex 中就使用到了 單例模式,程式碼本身比較簡單,當 install 方法被多次呼叫時,就會得到一個錯誤資訊,並不會多次向 Vue 中混入 vuex 中自定義的內容:

image.png

image.png

實現一個單例模式

這裡舉個封裝 localStorage 方法的例子,並提供給外部對應的建立方法,如下:

let storageInstance = null;

class Storage {
    getItem(key) {
        let value = localStorage.getItem(key);
        try {
            return JSON.parse(value);
        } catch (error) {
            return value;
        }
    }

    setItem(key, value) {
        try {
            localStorage.setItem(JSON.stringify(value));
        } catch (error) {
            // do something
            console.error(error);
        }
    }
}

// 單例模式
export default function createStorage(){
    if(!storageInstance){
        storageInstance = new Storage();
    }
    return storageInstance;
}

原型模式

JavaScript 中原型模式是很常見的,JavaScript 中實現的 繼承 或者叫 委託 也許更合適,因為它不等同於如 Java 等語言中的繼承,畢竟 JavaScript繼承 是基於原型(prototype)來實現.

class Person {
    say() {
        console.log(`hello, my name is ${this.name}!`);
    }

    eat(foodName) {
        console.log(`eating ${foodName}`);
    }
}

class Student extends Person {
    constructor(name) {
        super();
        this.name = name;
    }
}

const zs = new Student('zs');
const ls = new Student('ls');

console.log(zs.say === ls.say);// Java 中是不相等的, JavaScript 中是相等的
console.log(zs.eat === ls.eat);// Java 中是不相等的, JavaScript 中是相等的

vue2 中的原型模式

檔案位置:\src\core\instance\lifecycle.js

image.png

結構型設計模式

裝飾器模式

核心是在不改變原 物件/方法 的基礎上,透過對其進行包裝擴充,使原有 物件/方法 可以滿足更復雜的需求.

裝飾器本質

裝飾器模式本質上就是 函式的傳參和呼叫,透過函式為已有 物件/方法 進行擴充套件,而不用修改原物件/方法,滿足 開放封閉原則.

透過配置 babel 透過將 test.js 轉為為 bable_test.js 用來檢視裝飾器的本質:

babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ]
  ],
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
    ["@babel/plugin-proposal-class-properties", { "loose": true }]
  ]
}

test.js

// 定義裝飾器
function decoratorTest(target) {
  console.log(target);
}

// 使用裝飾器,裝飾 Person 類
@decoratorTest
class Person {
  say() {}
  eat() {}
}

執行 babel test.js --out-file babel_test.js 命令是生成 babel_test.js

"use strict";

var _class;

function decoratorTest(target) {
  console.log(target);
}

let Person = decoratorTest(_class = class Person {
  say() {}

  eat() {}

}) || _class;

React 中的裝飾器模式 —— HOC 高階元件

高階元件 是引數為 元件,返回值為新元件的 函式,在 ReactHOC 通常用於複用元件公共邏輯.

// TodoList 元件
class TodoList extends React.Component {}

// HOC 函式
function WrapContainer(Comp) {
  return (
    <div style={{ border: "1px solid red", padding: 10 }}>
      <Comp title="todo" />
    </div>
  );
}

// HOC 裝飾 TodoList 元件,為 TodoList 元件包裹紅色邊框
const newTodoList = WrapContainer(TodoList);

介面卡模式

介面卡模式本質就是 讓原本不相容的功能能夠生效,避免大規模修改程式碼,對外提供統一使用.

Axios 中的介面卡

透過觀察 Axios 的目錄結構,很容就發現其使用了介面卡模式:

image.png

其實 Axios 中的 adapters 主要目的是根據當前執行時環境,向外返回對應的介面卡 adapter,而這個介面卡要做的其實就是相容 web 瀏覽器環境和 node 環境的 http 請求,保證對外暴露的仍然是統一的 API 介面

image.png

代理模式

代理模式顧名思義就是 不能直接訪問目標物件,需要透過代理器來實現訪問,通常是為了提升效能、保證安全等.

事件代理

事件代理是很常見的效能最佳化手段之一,react 的事件機制也採用了事件代理的方式(篇幅有限可自行了解),這裡演示簡單的 JavaScript 事件代理:

<div id="container">
  <p>this number is 1</p>
  <p>this number is 2</p>
  <p>this number is 3</p>
  <p>this number is 4</p>
  <p>this number is 5</p>
</div>

<script>
  const container = document.querySelector("#container");
  container.addEventListener("click", function (e) {
    alert(e.target.textContent);
  });
</script>

Vue 中的代理 Proxy

Vue.js 3 中透過 Proxy 實現了對資料的代理,任何讀取、設定的操作都會被 代理物件handlers 攔截到,從而實現 Vue 中的 tracktrigger

image.png
image.png

行為型設計模式

策略模式

策略模式實際上就是定義一系列的演算法,將單個功能封裝起來,並且對擴充套件開放.

舉個例子

假如我們需要為某個遊樂場的門票價格做差異化詢價,主要人員型別分為 兒童、成年人、老年人 三種,其對應的門票折扣為 8折、9折、8.5折

if-else 程式碼一把梭

缺點:無論哪種人員型別的折扣變動,都需要修改 finalPrice 函式,不符合對 對修改封閉

function finalPrice(type, price) {
  if (type === "child") {
    // do other thing
    return price * 0.8;
  }

  if (type === "adult") {
    // do other thing
    return price * 0.9;
  }

  if (type === "aged") {
    // do other thing
    return price * 0.85;
  }
}

單一功能封裝

缺點:若人員型別增加婦女型別,仍然需要修改 finalPrice 函式,且不符合 對擴充套件開放

function childPrice(price) {
  // do other thing
  return price * 0.8;
}

function adultPrice(price) {
  // do other thing
  return price * 0.9;
}

function agedPrice(price) {
  // do other thing
  return price * 0.85;
}

function finalPrice(type, price) {
  if (type === "child") {
    return childPrice(price);
  }

  if (type === "adult") {
    return adultPrice(price);
  }

  if (type === "aged") {
    return agedPrice(price);
  }
}

建立對映關係

透過對映關係,很好的將 finalPrice 和 具體的計算邏輯進行分離,在需要擴充套件型別時,只需要修改 priceTypeMap 物件而不用修改對外暴露的 finalPrice 函式.

const priceTypeMap = {
  child: function (price) {
    // do other thing
    return price * 0.8;
  },
  adult: function (price) {
    // do other thing
    return price * 0.9;
  },
  aged: function (price) {
    // do other thing
    return price * 0.85;
  },
};

function finalPrice(type, price) {
    return priceTypeMap[type](price);
}

狀態模式

狀態模式允許一個物件在其內部狀態發生改變時,能夠改變原本的行為.

舉例子

假如現在我們需要設計一個售票機器,主要出售 巴士、火車、飛機票等,價格分別為 50、150、1000,並且能夠根據剩餘票數決定是否能夠繼續購買.

透過策略模式實現核心程式碼邏輯

有了上面的 策略模式 的思想,立馬就可以設計出如下的程式碼:

缺點:沒有根據剩餘票數決定是否可以繼續售賣,主要原因就在於抽離的 ticketTypeMapTicketMachine 之間的狀態沒有關聯

const ticketTypeMap = {
  bus() {
    // do other thing
    return 50;
  },
  train() {
    // do other thing
    return 150;
  },
  plane() {
    // do other thing
    return 1000;
  },
};

class TicketMachine {
  constructor() {
    // 剩餘票數
    this.remain = {
      bus: 100,
      train: 150,
      plane: 200,
    };
  }

  selling(type) {
    return ticketTypeMap[type]();
  }
}

關聯物件狀態 — 函式傳參

透過函式傳參的方式將物件傳遞給目標函式,讓目標函式透過該物件訪問和修改物件內部的狀態.

const ticketTypeMap = {
  bus(remain) {
    if (remain.bus <= 0) return Error("抱歉,巴士票已售完");
    remain.bus--;
    return 50;
  },
  train(remain) {
    if (remain.train <= 0) return Error("抱歉,火車票已售完");
    remain.train--;
    return 150;
  },
  plane(remain) {
    if (remain.plane <= 0) return Error("抱歉,飛機票已售完");
    remain.plane--;
    return 1000;
  },
};

class TicketMachine {
  constructor() {
    // 剩餘票數
    this.remain = {
      bus: 100,
      train: 150,
      plane: 200,
    };
  }

  selling(type) {
    return ticketTypeMap[type](this.remain);
  }
}

關聯物件狀態 — 整合方法

實際上 ticketTypeMap 對映的方法和 TicketMachine 有較強的關聯性,不應該單獨存在,因此,可以將這個對映物件整合進 TicketMachine 當中

class TicketMachine {
  constructor() {
    // 剩餘票數
    this.remain = {
      bus: 100,
      train: 150,
      plane: 200,
    };
  }

  ticketTypeMap = {
    that: this,
    bus() {
      const { remain } = this.that;
      if (remain.bus <= 0) return Error("抱歉,巴士票已售完");
      remain.bus--;
      return 50;
    },
    train() {
      const { remain } = this.that;
      if (remain.train <= 0) return Error("抱歉,火車票已售完");
      remain.train--;
      return 150;
    },
    plane() {
      const { remain } = this.that;
      if (remain.plane <= 0) return Error("抱歉,飛機票已售完");
      remain.plane--;
      return 1000;
    },
  };

  selling(type) {
    return this.ticketTypeMap[type]();
  }
}

觀察者模式

觀察者模式定義了一種一對多的依賴關係,讓多個觀察者物件同時監聽某一個目標物件,當這個目標物件的狀態發生變化時,會通知所有觀察者物件,使它們能夠自動更新.

vue 中的觀察者模式

image.png

vue 中的響應式原理就使用了 觀察者模式,我們簡單回顧一下其工作流程:

  • compile:將模板內容編譯得到對應的 render 渲染函式
  • render:渲染函式執行生成 VNode,透過 patch 函式初始化檢視 view

    • Observe:負責將 data 中返回的物件進行資料劫持(getter/setter),且其中會使用 Dep 來實現 watcher 的儲存,相當於 被觀察者
    • Dep:在觸發 getter 時執行 dep.depend() 實際上執行的是 watcher.addDep(),該方法會將當前的 dep 物件儲存到 Watcher,同時將當前的 watcher 透過 dep.addSub() 新增到 Dep
    • Watcher:相當於 觀察者,提供統一的 update() 方法供 Dep 呼叫
  • data changed:響應式資料發生變更,觸發資料劫持操作 setter

    • 進而執行 dep.notify() 方法,透過迴圈去執行 watcher.update() 方法,即執行 queueWatcher()watcher 新增到 queue 佇列中
    • 最後由 scheduler 排程器 中執行 nextTick(flushSchedulerQueue) 進行非同步佇列重新整理操作

以上過程中,顯然 ObserveWatcher 就是 被觀察者觀察者 ,因為 Observe 中實現了對 Watcher 的收集和監聽到資料狀態發生變化時通知 Watcher 更新的處理,可以認為 Dep 只是 Observe 中使用到的一個儲存和派發 Watcher 的工具.

釋出訂閱模式

釋出訂閱模式有三個核心:釋出者、事件中心、訂閱者,且釋出訂閱模式中的 釋出者訂閱者 不能直接進行通訊,必須要經過 事件中心 來統一排程.

與觀察者模式的區別

實際上,釋出訂閱模式和觀察者模式在概念上非常相似,做的事情也都一致,主要區別在於:

  • 釋出訂閱模式依賴於 事件中心 統一排程 釋出者訂閱者釋出者訂閱者 不直接進行通訊
  • 觀察者模式中的 被觀察者觀察者 是直接建立連線的,被觀察者 需要儲存 觀察者 的資訊,觀察者 需要提供統一的 方法 供觀察者進行使用

實現釋出訂閱模式

vue 中的 全域性事件匯流排(Event Bus)和 node 中的 Event Emitter,甚至是瀏覽器中的事件註冊(addEventListener)和執行,它們都屬於釋出訂閱模式.

下面實現一個簡單的釋出訂閱模式:

class EventEmitter {
  constructor() {
    this.handlers = {};
  }

  on(name, handle) {
    if (!this.handlers[name]) {
      this.handlers[name] = [];
    }

    this.handlers[name].push(handle);
  }

  emit(name, ...args) {
    if (this.handlers[name]) {
      this.handlers[name].forEach((handle) => {
        handle(...args);
      });
    }
  }

  off(name, handle) {
    if (this.handlers[name]) {
      this.handlers[name] = this.handlers[name].filter((h) => {
        if (handle) return h !== handle;
        return false;
      });
    }
  }

  once(name, handle) {
    const onceHandle = (...args) => {
      handle(...args);
      this.off(name, onceHandle);
    };

    this.on(name, onceHandle);
  }
}

迭代器模式

迭代器模式是指提供一種方法順序訪問一個聚合物件中的各個元素,而又不需要暴露該物件的內部表示,核心目的就是 遍歷.

JavaScript 中的遍歷方式

  • Array :for...of、for...in、forEach、map、filter
  • Object :for...in
  • Map :for...of、forEach
  • Set :for...of、forEach

看起來很難有一種方法能夠相容以上幾種資料結構的遍歷方式,即不需要考慮資料結構本身就能實現遍歷的目的,但我們可以基於 ES6Symbol.iterator 實現自定義迭代器.

Symbol.iterator 實現通用迭代

Symbol.iterator 為每一個物件定義了預設的迭代器,擁有該迭代器後就可以被 for...of 迴圈使用.

function $each(data, handle) {
  if (typeof data !== "object") throw TypeError("data should be object!");

  if (!data[Symbol.iterator]) {
    Object.prototype[Symbol.iterator] = function () {
      let i = 0;
      let keys = Reflect.ownKeys(this);
      return {
        next() {
          const done = i >= keys.length;
          return {
            value: done ? undefined : keys[i++],
            done,
          };
        },
      };
    };
  }

  for (const item of data) {
    handle(item);
  }
}

最後

大前端的各種新技術層出不窮,很容易忽視如資料結構、設計模式等基礎內容,其實看很多設計模式相關的內容,很少有講得簡單易懂的,終歸是沒有結合現有的框架去學習到底是如何使用起來。

相關文章