設計模式
設計模式總共有 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
中自定義的內容:
實現一個單例模式
這裡舉個封裝 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
結構型設計模式
裝飾器模式
核心是在不改變原 物件/方法 的基礎上,透過對其進行包裝擴充,使原有 物件/方法 可以滿足更復雜的需求.
裝飾器本質
裝飾器模式本質上就是 函式的傳參和呼叫,透過函式為已有 物件/方法 進行擴充套件,而不用修改原物件/方法,滿足 開放封閉原則.
透過配置 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 高階元件
高階元件 是引數為 元件,返回值為新元件的 函式,在 React 中 HOC 通常用於複用元件公共邏輯.
// 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 的目錄結構,很容就發現其使用了介面卡模式:
其實 Axios
中的 adapters
主要目的是根據當前執行時環境,向外返回對應的介面卡 adapter
,而這個介面卡要做的其實就是相容 web
瀏覽器環境和 node
環境的 http
請求,保證對外暴露的仍然是統一的 API
介面
代理模式
代理模式顧名思義就是 不能直接訪問目標物件,需要透過代理器來實現訪問,通常是為了提升效能、保證安全等.
事件代理
事件代理是很常見的效能最佳化手段之一,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
中的 track
和 trigger
行為型設計模式
策略模式
策略模式實際上就是定義一系列的演算法,將單個功能封裝起來,並且對擴充套件開放.
舉個例子
假如我們需要為某個遊樂場的門票價格做差異化詢價,主要人員型別分為 兒童、成年人、老年人 三種,其對應的門票折扣為 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,並且能夠根據剩餘票數決定是否能夠繼續購買.
透過策略模式實現核心程式碼邏輯
有了上面的 策略模式 的思想,立馬就可以設計出如下的程式碼:
缺點:沒有根據剩餘票數決定是否可以繼續售賣,主要原因就在於抽離的 ticketTypeMap
和 TicketMachine
之間的狀態沒有關聯
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 中的觀察者模式
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)
進行非同步佇列重新整理操作
- 進而執行
以上過程中,顯然 Observe
和 Watcher
就是 被觀察者 和 觀察者 ,因為 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
看起來很難有一種方法能夠相容以上幾種資料結構的遍歷方式,即不需要考慮資料結構本身就能實現遍歷的目的,但我們可以基於 ES6
的 Symbol.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);
}
}
最後
大前端的各種新技術層出不窮,很容易忽視如資料結構、設計模式等基礎內容,其實看很多設計模式相關的內容,很少有講得簡單易懂的,終歸是沒有結合現有的框架去學習到底是如何使用起來。