【JavaScript】常用設計模式及程式設計技巧(ES6描述)
平時的開發中可能不太需要用到設計模式,但是 JS 用上設計模式對於效能優化和專案工程化也是很有幫助的,下面就對常用的設計模式進行簡單的介紹與總結。
1. 單例模式
定義:保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點。
class Singleton { constructor(age) { this.age = age; } static getInstance(age) { const instance = Symbol.for('instance'); // 防止被覆蓋 if (!Singleton[instance]) { Singleton[instance] = new Singleton(age); } return Singleton[instance]; } } const singleton = Singleton.getInstance(30); const singleton2 = Singleton.getInstance(20); console.log(singleton === singleton2); // true 複製程式碼
2. 策略模式
定義:定義一系列的演算法,把它們一個個封裝起來,並且使它們可以相互替換。
策略模式的核心是整個分為兩個部分:
-
第一部分是策略類,封裝具體的演算法;
-
第二部分是環境類,負責接收客戶的請求並派發到策略類。
現在我們假定有這樣一個需求,需要對錶現為S、A、B的同事進行年終獎的計算,分別對應為4倍、3倍、2倍工資,常見的寫法如下:
const calculateBonus = function (performanceLevel, salary) { if (performanceLevel === 'S') { return salary * 4; } if (performanceLevel === 'A') { return salary * 3; } if (performanceLevel === 'B') { return salary * 2; } }; calculateBonus('B', 20000); // 40000 複製程式碼
可以看到,程式碼裡面有較多的 if else 判斷語句,如果對應計算方式改變或者新增等級,我們都需要對函式內部進行調整,且薪資演算法重用性差,於是我們可以通過策略模式來進行重構,程式碼如下:
// 解決魔術字串 const strategyTypes = { S: Symbol.for('S'), A: Symbol.for('A'), B: Symbol.for('B'), }; // 策略類 const strategies = { [strategyTypes.S](salary) { return salary * 4; }, [strategyTypes.A](salary) { return salary * 3; }, [strategyTypes.B](salary) { return salary * 2; } }; // 環境類 const calculateBonus = function (level, salary) { return strategies[level](salary); }; calculateBonus(strategyTypes.S, 300); // 1200 複製程式碼
策略模式的優點:
-
利用組合、委託、多型等技術和思想,有效地避免了多重 if-else 語句;
-
提供了對開放-封閉原則的完美支援,將演算法封裝在獨立的 strategy 中,使得它們易於切換、理解、擴充套件;
-
strategy 中的演算法也可以用在別處,避免許多複製貼上;
缺點:
-
增加許多策略類或策略物件;
-
違反知識最少原則;
3. 代理模式
定義:為一個物件提供一個代用品或佔位符,以便控制對它的訪問。
3.1 虛擬代理
在程式世界裡,操作可能是昂貴的,這時候 B 通過監聽 C 的狀態來將 A 的請求傳送過去,減少開銷。
代理的意義
單一職責: 就一個類(通常也包括物件和函式等)而言,應該僅有一個引起它變化的原因。如果一個物件承擔了多項職責,就意味著這個物件將變得巨大,引起它變化的原因可能會有多個。
例子:圖片預載入。
const myImage = (function () { const imgNode = document.createElement('img'); document.body.appendChild(imgNode); return function (src) { imgNode.src = src; } })(); const proxyImage = (function () { const img = new Image; img.onload = function () { myImage(this.src); } return function (src) { myImage('./loading.gif'); img.src = src; } })(); proxyImage('./test.jpg'); 複製程式碼
這裡的 myImage 只進行圖片 src 的設定,其他代理的工作交給了 proxyImage 方法,符合單一職責原則。此外,也保證了代理和本體介面的一致性。
3.2 快取代理
快取代理可以為一些開銷大的運算結果提供暫時的儲存,在下次運算時,如果傳遞進來的引數跟之前一致,則可以直接返回前面儲存的運算結果。
例子:計算乘積,快取 ajax 資料。
const mult = function () { let a = 1; for (let i = 0, l = arguments.length; i < l; i++) { a = a * arguments[i]; } return a; } const proxyMult = (function () { const cache = {}; return function () { const args = Array.prototype.join.call(arguments, ','); if (args in cache) { return cache[args]; } return cache[args] = mult.apply(this, arguments); } })(); const a = proxyMult(1, 2, 3, 4); // 輸出:24 const b = proxyMult(1, 2, 3, 4); // 輸出:24 複製程式碼
4. 觀察者模式
觀察者模式又叫釋出—訂閱模式,它定義物件間的一種一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都將得到通知。在 JavaScript 開發中,我們一般用事件模型來替代傳統的觀察者模式。
4.1 DOM 事件
最早接觸到的觀察者模式大概就是 DOM 事件了,比如使用者的點選操作。我們沒辦法知道使用者什麼時候點選,但是當用戶點選時,被點選的節點就會向訂閱者釋出訊息。
document.body.addEventListener('click', function() { alert('我被點選啦!~'); }); 複製程式碼
4.2 自定義事件
要實現自定義事件,需要進行三步:
- 指定釋出者;
- 給釋出者新增一個快取列表,用以通知訂閱者;
- 遍歷快取列表依次觸發存放在裡面的訂閱者的回撥函式;
class Event { constructor() { this.eventListObj = {}; } static getInstance() { const instance = Symbol.for('instance'); if (!Event[instance]) { Event[instance] = new Event(); } return Event[instance]; } listen(key, fn) { if (!this.eventListObj[key]) { this.eventListObj[key] = []; } // 訂閱訊息新增進快取列表 this.eventListObj[key].push(fn); } trigger(key, ...args) { const fns = this.eventListObj[key]; if (!fns || fns.length === 0) { return false; } fns.forEach((fn) => { fn.apply(this, args); }); } remove(key, fn) { let fns = this.eventListObj[key]; // 如果沒有被訂閱過 if (!fns) { return false; } // 根據 fn 引數來判斷是全部移除還是指定移除 if (!fn) { fns && (fns.length = 0); } else { for (let i = 0; i < fn.length; i++) { const f = fns[i]; if (f === fn) { fns.splice(i, 1); } } } } } const event = Event.getInstance(); // 全域性釋出者 const add = function (a, b) { console.log(a + b); } const minus = function (a, b) { console.log(a - b); } event.listen('add', add); // 訂閱加法訊息 event.listen('minus', minus); // 訂閱減法訊息 event.trigger('add', 1, 3); // 觸發加法訂閱訊息 event.trigger('minus', 3, 1); // 觸發減法訂閱訊息 console.log('------- before remove add function:'); console.log(event); event.remove('add', add); // 取消加法訂閱事件 console.log('------- after remove add function:'); console.log(event); 複製程式碼
執行結果:
例子:ajax 請求登入後進行多種操作,以及在 vue 中 emit 和 on,node.js 中的 events
5. 模板方法模式
模板方法模式是一種只需使用繼承就可以實現的非常簡單的模式。
模板方法模式由兩部分結構組成,第一部分是抽象父類,第二部分是具體的實現子類。
通常在抽象父類中封裝了子類的演算法框架,包括實現一些公共方法以及封裝子類中所有方法的執行順序。
子類通過繼承這個抽象類,也繼承了整個演算法結構,並且可以選擇重寫父類的方法。
下面我們來舉個例子——假如我們要泡一杯茶和一杯咖啡步驟如下:
- 把水煮沸
- 用沸水 ( 沖泡咖啡 / 浸泡茶葉 )
- 把 ( 咖啡 / 茶水 ) 倒進杯子
- 加糖和牛奶 / 加檸檬
很容易發現其中第一步是共有的,其他步驟大體一致,那麼我們就可以使用模板方法來實現它。( 假如有人不想加糖和牛奶怎麼辦呢? )
// 抽象出飲料類用來表示咖啡和茶 class Beverage { init() { this.boilWater(); this.brew(); this.pourInCup(); if (this.customerWantsCondiments()) { this.addCondiments(); } } // 第一步:把水煮沸 boilWater(){ console.log('把水煮沸'); } // 第二步:沖泡飲料,在子類中重寫 brew(){ throw new Error('brew function must override in child'); } // 第三步:倒出飲料,在子類中重寫 pourInCup(){ throw new Error('pourInCup function must override in child'); } // 第四步:個性化飲料,在子類中重寫 addCondiments(){ throw new Error('addCondiments function must override in child'); } // 鉤子方法: 解決了有人不想加糖和牛奶的問題 customerWantsCondiments() { return true; } } class Coffee extends Beverage { brew(){ console.log('用沸水沖泡咖啡'); } pourInCup(){ console.log('把咖啡倒進杯子'); } addCondiments(){ console.log('加糖和牛奶'); } //不想個性化 customerWantsCondiments() { return false; } } classTea extends Beverage { brew(){ console.log('用沸水浸泡茶葉'); } pourInCup(){ console.log('把茶水倒進杯子'); } addCondiments(){ console.log('加檸檬'); } } new Coffee().init(); new Tea().init(); 複製程式碼
6. 職責鏈模式
職責連模式:通過把物件連成一條鏈,讓請求沿著這條鏈傳遞,直到有一個物件能處理為止,解決了傳送者和接收者之間的耦合。
A --> B --> C --> ... --> N,中間有一個物件能處理 A 物件的請求,如果沒有需要在最後處理異常。
現實中的例子:早高峰擠公交的時候遞公交卡,只需要往前遞,總會遞到售票員手裡刷卡,而不用管遞給了誰。
下面舉一個實際的例子來看看——假如現在有個電商定金優惠券功能,付 500 元定金可以獲得 100 元優惠券且一定能買到商品;付 200 元定金可以獲得 50 元優惠券且一定能買到商品,如果付定金只能進入普通購買,需要在庫存足夠的時候才可以買到商品。我們頂一個一個函式,接收三個引數:
- orderType:1、2、3 分表代表 500 元定金, 200 元定金和無定金模式;
- pay:true、false 代表拍下訂單是否付款;
- stock:number 代表庫存餘量;
const order = function (orderType, pay, stock) { if (orderType === 1) { if (pay === true) { console.log('獲得 100 元優惠券'); } else { if (stock > 0) { console.log('普通購買, 無優惠券'); } else { console.log('庫存不足'); } } } else if (orderType === 2) { if (pay === true) { console.log('獲得 50 元優惠券'); } else { if (stock > 0) { console.log('普通購買, 無優惠券'); } else { console.log('庫存不足'); } } } else if (orderType === 3) { if (stock > 0) { console.log('普通購買, 無優惠券'); } else { console.log('庫存不足'); } } } order(1, true, 20); // 獲得 100 元優惠券 複製程式碼
這顯然不是一段好程式碼,大量的 if else 條件分支,如果業務再複雜一點,最後根本就沒法看了。
那麼我們通過 AOP 實現職責鏈:
const order500 = function (orderType, pay, stock) { if (orderType === 1 && pay === true) { return console.log('已支付定金,獲得100元優惠券'); } return 'NEXT'; } const order200 = function (orderType, pay, stock) { if (orderType === 2 && pay === true) { return console.log('已支付定金,獲得50元優惠券'); } return 'NEXT'; } const orderNormal = function (orderType, pay, stock) { if (stock > 0) { console.log('普通購買,無優惠券'); } else { console.log('庫存不足'); } } Function.prototype.after = function (fn) { const self = this; return function (...args) { const result = self.apply(this, args); if (result === 'NEXT') { return fn.apply(this, args); } return result; } } const order = order500.after(order200).after(orderNormal); order(1, false, 10); 複製程式碼
通過分解成三個獨立的函式,返回處理不了的結果'NEXT',交給下一個節點處理。通過 after 來進行繫結,最後我們在新增需求的時候可以在 after 中間插入即可,耦合度大大降低,但是這樣也有一個不好的地方,職責鏈過長增加了函式的作用域。
7. 中介者模式
在程式裡,物件經常會和其他物件進行通訊,當專案比較大,物件很多的時候,這種通訊就會形成一個通訊網,當我們想要修改某一個物件時,需要十分小心,以免這些改動牽一髮而動全身,導致出現BUG,非常的複雜。
中介者模式就是用來解除這些物件間的耦合,形成簡單的物件到中介者到物件的操作。
下面以現實中的機場指揮塔為例說明。
- 如果沒有指揮塔的情況,每一架飛機都需要和其他飛機進行通訊,確保航線的安全,我們假設目的地相同就為航線不安全:
// 飛機類 class Plane { constructor(name, to) { this.name = name; this.to = to; this.otherPlanes = []; } success() { console.log(`${this.name} 可以正常飛行`); } fail(plane) { console.log(`${this.name} 與 ${plane.name} 航線衝突,請調整`); } fly() { let normal = true; let targetPlane = {}; for (let i = 0; i < this.otherPlanes.length; i++) { if (this.otherPlanes[i].to === this.to) { normal = false; targetPlane = this.otherPlanes[i]; break; } } if (normal === true) { this.success(); } else { this.fail(targetPlane); } } } // 飛機工廠 class PlaneFactory { constructor() { this.planes = []; } static getInstance() { const instance = Symbol.for('instance'); // 防止被覆蓋 if (!PlaneFactory[instance]) { PlaneFactory[instance] = new PlaneFactory(); } return PlaneFactory[instance]; } plane(name, to) { const plane = new Plane(name, to); this.planes.push(plane); for (let i = 0; i < this.planes.length; i++) { if (plane.name !== this.planes[i].name) { plane.otherPlanes.push(this.planes[i]); } } return plane; } } const planeFactory = PlaneFactory.getInstance(); const planeA = planeFactory.plane('planeA', 1); const planeB = planeFactory.plane('planeB', 2); const planeC = planeFactory.plane('planeC', 3); const planeD = planeFactory.plane('planeD', 2); planeA.fly(); // planeA 可以正常飛行 planeB.fly(); // planeB 可以正常飛行 planeC.fly(); // planeC 可以正常飛行 planeD.fly(); // planeD 與 planeB 航線衝突,請調整 複製程式碼
當飛機足夠多時,這樣的方式就會變得非常複雜,而且某一天有飛機出故障維修不參與飛行,那麼改動也是麻煩的。
- 存在指揮塔的情況,飛機不需要知道其他飛機的存在,只需要向指揮塔通訊即可,而且添加了移除故障飛機的方法。
// 指揮塔 class Tower { constructor() { this.planes = []; this.operations = { add: this.add, remove: this.remove, fly: this.fly, }; } static getInstance() { const instance = Symbol.for('instance'); // 防止被覆蓋 if (!Tower[instance]) { Tower[instance] = new Tower(); } return Tower[instance]; } receiveMessage(msg, ...args) { this.operations[msg].apply(this, args); } add(plane) { this.planes.push(plane); } remove(plane) { for (let i = 0; i < this.planes.length; i++) { if (this.planes[i].name === plane.name) { this.planes.splice(i, 1); } } } fly(plane) { let normal = true; let targetPlane = {}; for (let i = 0; i < this.planes.length; i++) { if (this.planes[i].name !== plane.name && this.planes[i].to === plane.to) { normal = false; targetPlane = this.planes[i]; break; } } if (normal === true) { plane.success(); } else { plane.fail(targetPlane); } } } // 獲得指揮塔例項 const tower = Tower.getInstance(); // 飛機類 class Plane { constructor(name, to) { this.name = name; this.to = to; } success() { console.log(`${this.name} 可以正常飛行`); } fail(plane) { console.log(`${this.name} 與 ${plane.name} 航線衝突,請調整`); } remove() { tower.receiveMessage('remove', this); } fly() { tower.receiveMessage('fly', this); } } // 飛機工廠 class PlaneFactory { static plane(name, to) { const plane = new Plane(name, to); tower.receiveMessage('add', plane); return plane; } } const planeA = PlaneFactory.plane('planeA', 1); const planeB = PlaneFactory.plane('planeB', 2); const planeC = PlaneFactory.plane('planeC', 3); const planeD = PlaneFactory.plane('planeD', 2); planeA.fly(); // planeA 可以正常飛行 planeB.fly(); // planeB 與 planeD 航線衝突,請調整 planeC.fly(); // planeC 可以正常飛行 planeD.fly(); // planeD 與 planeB 航線衝突,請調整 planeD.remove(); // 假如 planeD 出故障了,進行移除 planeB.fly(); // planeB 可以正常飛行 複製程式碼
中介者模式是知識最少原則的一種實現,是指一個物件儘可能少的瞭解其他的物件,如果物件之間的耦合度過高,一個物件發生改變之後,難免會影響到其他物件,在中介者模式中,物件幾乎不知道其他物件的存在,它們只能通過中介者物件來通訊。但是這樣的結果就是中介者物件難免會變的臃腫。
8. 裝飾者模式
裝飾者(decorator)模式:給物件動態地增加職責的方式。
我們在開發中經常會使用到,因為在 JavaScript 中對物件動態操作是一件再簡單不過的事情了。
const person = { name: 'shelly', age: 18, } person.job = 'student'; 複製程式碼
裝飾函式
給物件擴充套件屬性和方法相對簡單,但是在改寫函式時卻不是那麼容易,尤其是儘量保證開放-封閉原則的前提下。我們可以通過使用 AOP 裝飾函式來達到理想的效果。
let add = function (a, b) { console.log(a + b); } // 在函式執行之前執行 Function.prototype.before = function (beforeFn) { const self = this; return function (...args) { beforeFn.apply(this, args); return self.apply(this, args); } } // 在函式執行之後執行 Function.prototype.after = function (afterFn) { const self = this; return function (...args) { const result = self.apply(this, args); afterFn.apply(this, args); return result; } } // 裝飾 add 函式 add = add .before(function () { console.log('before add'); }) .after(function () { console.log('after add'); }); add(1, 2); 複製程式碼
9. 設計原則和程式設計技巧
9.1 單一職責原則
單一職責原則(SRP):一個物件(方法)只做一件事情。如果一個方法承擔了過多的職責,將來改寫它的可能性就越大。
這一原則在單例模式、代理模式中都有廣泛的應用。
何時該分離?
這是很難把控的一個點,比如 ajax 請求,建立 xhr 物件和傳送請求雖然是兩個職責,但是他們是一起變化,可以不用分離;像 jQuery 的 attr 方法,既賦值,又取值,理論上應該分離,卻方便了使用者。所以需要我們在實際上拿捏。
9.2 最少知識原則
最少知識原則(LKP):一個軟體實體應當儘可能地少於其他實體發生相互作用。這裡的實體包括了物件、類、模組、函式等。
常見的做法是引入第三方物件來承擔多個物件間的通訊,例如中介者模式、封裝。
9.3 開放 - 封閉原則
開放 - 封閉原則(OCP):軟體實體(類、模組、函式)等應該是可以擴充套件的,但是不可修改。
OCP 在幾乎所有的設計模式中得到了很好的表現。
9.3.1 擴充套件
假如我們要修改一個函式,業務邏輯極其複雜,那麼我們遵守開放 - 封閉原則在原來的基礎繫結一個 after 方法,傳入回撥函式實現我們新的需求而不用去改變之前的程式碼。
let theMostComplicatedFn = function (a, b) { console.log('我是極其複雜的函式'); console.log(a + b); } theMostComplicatedFn.after = function (afterFn) { const self = this; return function (...args) { afterFn.apply(this, args); const result = self.apply(this, args); return result; } } theMostComplicatedFn = theMostComplicatedFn.after(function (a, b) { console.log(a, b); }); theMostComplicatedFn(1, 2); // 1 2 // 我是極其複雜的函式 // 3 複製程式碼
9.3.2 多型
利用物件的多型性也可以讓程式遵循開放 - 封閉原則,這是一個常用的技巧。
我們都知道貓吃魚,狗吃肉,那麼我們用程式碼來表達一下。
const food = function (animal) { if (animal instanceof Cat) { console.log('貓吃魚'); } else if (animal instanceof Dog) { console.log('狗吃肉'); } } class Dog {} class Cat {} food(new Dog()); // 狗吃肉 food(new Cat()); // 貓吃魚 複製程式碼
有一天加入了羊,又得再加一個 else if 來判斷,如果很多呢?那麼我們就要一直去改變 food 函式,這顯然不是一種好的方法。我們現在可以利用多型性,將共同的 food 抽取出來。
const food = function (animal) { animal.food(); } class Dog { food() { console.log('狗吃肉'); } } class Cat { food() { console.log('貓吃魚'); } } class Sheep { food() { console.log('羊吃草'); } } food(new Dog()); // 狗吃肉 food(new Cat()); // 貓吃魚 food(new Sheep()); // 羊吃草 複製程式碼
這樣,當我們以後要增加新的動物時,就不需要每次都去改變 food 函數了。
9.3.3 其他方式
鉤子函式、回撥函式。
9.4 程式碼重構
9.4.1 提煉函式
把一段程式碼提煉成函式的好處是:
- 避免出現超大函式
- 獨立出來的函式有利於程式碼複用
- 獨立出來的函式更容易被覆寫
- 獨立出來的函式如果有一個好的命名,它本身就起到了註釋的作用
9.4.2 合併重複的條件片段
如果一個函式體內有一些條件分支語句,而這些條件分支語句的內部散佈了一些重複的程式碼,那麼就有必要進行合併去重工作。
9.4.3 把條件分支語句提煉成函式
下面是一個例子:
const getPrice = function (price) { const date = new Date(); if (date.getMonth() >= 6 && date.getMonth() <=9) { // 夏天 return price * 0.8; } return price; } 複製程式碼
條件語句乍一看需要理解一會兒,那麼此處可以做一下調整:
// 通過函式名也起到了註釋作用 const isSummer = function () { const month = new Date().getMonth(); return month >= 6 && month <=9; } const getPrice = function (price) { const date = new Date(); if (isSummer()) { return price * 0.8; } return price; } 複製程式碼
9.4.4 合理使用迴圈
在函式體內,如果有些程式碼實際上負責的是一些重複性的工作,那麼合理利用迴圈不僅可以完成同樣的功能,還可以使程式碼量更少。我們以建立 xhr 物件為例:
const createXHR = function () { let xhr; try { xhr = new ActiveXObject('MSXML2.XMLHttp.6.0'); } catch (e) { try { xhr = new ActiveXObject('MSXML2.XMLHttp.3.0'); } catch (e) { xhr = new ActiveXObject('MSXML2.XMLHttp'); } } return xhr; }; const xhr = createXHR(); 複製程式碼
下面我們通過迴圈,可以達到和上面一樣的效果:
const createXHR = function () { const versions = ['MSXML2.XMLHttp.6.0ddd', 'MSXML2.XMLHttp.3.0', 'MSXML2.XMLHttp']; for (let i = 0, version; version = versions[i++];) { try { return new ActiveXObject(version); } catch (e) { } } }; const xhr = createXHR(); 複製程式碼
9.4.5 提前讓函式退出代替巢狀條件分支
在多層條件分支語句中,我們可以挑選一些分支,在進入這些分支後,就立即讓函式退出,減少非關鍵程式碼的混淆。
9.4.6 傳遞物件引數代替過長的引數列表
函式引數過長過多會引起呼叫呼叫者的不適,可能出現傳少或傳反的情況。如果有這種情況,我們可以通過將引數包裝成一個物件傳入函式,然後在函式體內進行取值就可以了。