
英文 | https://medium.com/frontend-canteen/useful-design-patterns-48ac739882a4
翻譯 | 楊小愛
什麼是設計模式?我們為什麼需要學習設計模式?
網上已經有很多開發者在讨論。我不知道你怎麼想,但對我來說:設計模式是我個人覺得可以更好解決問題的一種方案。
這意味着什麼?如果你開發的項目的功能是固定的,永遠不會調整業務,那麼你就不需要使用設計模式等任何技巧。您隻需要使用通常的方式編寫代碼并完成需求即可。
但是,我們的開發項目的需求是不斷變化的,這就需要我們經常修改我們的代碼。也就是說,我們現在寫代碼的時候,需要為未來業務需求可能發生的變化做好準備。
這時,你會發現使用設計模式可以讓你的代碼更具可擴充性。
經典的設計模式有 23 種,但并不是每一種設計模式都被頻繁使用。在這裡,我介紹我最常用和最實用的 3 種設計模式。
01、政策模式
假設您目前正在從事一個電子商務商店的項目。每個産品都有一個原價,我們可以稱之為 originalPrice。但并非所有産品都以原價出售,我們可能會推出允許以折扣價出售商品的促銷活動。
商家可以在背景為産品設定不同的狀态。然後實際售價将根據産品狀态和原價動态調整。
具體規則如下:
部分産品已預售。為鼓勵客戶預訂,我們将在原價基礎上享受 20% 的折扣。
部分産品處于正常促銷階段。如果原價低于或等于100,則以10%的折扣出售;如果原價高于 100,則減 10 美元。
有些産品沒有任何促銷活動。它們屬于預設狀态,以原價出售。
如果你需要寫一個getPrice函數,你應該怎麼寫呢?
function getPrice(originalPrice, status){
// ...
return price
}
其實,面對這樣的問題,如果不考慮任何設計模式,最直覺的寫法可能就是使用if-else通過多個判斷語句來計算價格。
有三種狀态,是以我們可以快速編寫如下代碼:
function getPrice(originalPrice, status) {
if (status === 'pre-sale') {
return originalPrice * 0.8
}
if (status === 'promotion') {
if (origialPrice <= 100) {
return origialPrice * 0.9
} else {
return originalPrice - 20
}
}
if (status === 'default') {
return originalPrice
}
}
有三個條件;然後,我們寫三個 if 語句,這是非常直覺的代碼。
但是這段代碼并不友好。
首先,它違反了單一職責原則。主函數 getPrice 做了太多的事情。這個函數不易閱讀,也容易出現bug。如果一個條件有bug,整個函數就會崩潰。同時,這樣的代碼也不容易調試。
然後,這段代碼很難應對變化。正如我在文章開頭所說的那樣,設計模式往往會在業務邏輯發生變化時表現出它的魅力。
假設我們的業務擴大了,現在還有另一個折扣促銷:黑色星期五,折扣規則如下:
- 價格低于或等于 100 美元的産品以 20% 的折扣出售。
- 價格高于 100 美元但低于 200 美元的産品将減少 20 美元。
- 價格高于或等于 200 美元的産品将減少 20 美元。
這時候怎麼擴充getPrice函數呢?
看起來我們必須在 getPrice 函數中添加一個條件。
function getPrice(originalPrice, status) {
if (status === 'pre-sale') {
return originalPrice * 0.8
}
if (status === 'promotion') {
if (origialPrice <= 100) {
return origialPrice * 0.9
} else {
return originalPrice - 20
}
}
if (status === 'black-friday') {
if (origialPrice >= 100 && originalPrice < 200) {
return origialPrice - 20
} else if (originalPrice >= 200) {
return originalPrice - 50
} else {
return originalPrice * 0.8
}
}
if(status === 'default'){
return originalPrice
}
}
每當我們增加或減少折扣時,我們都需要更改函數。這種做法違反了開閉原則。修改已有函數很容易出現新的錯誤,也會讓getPrice越來越臃腫。
那麼我們如何優化這段代碼呢?
首先,我們可以拆分這個函數以使 getPrice 不那麼臃腫。
function preSalePrice(origialPrice) {
return originalPrice * 0.8
}
function promotionPrice(origialPrice) {
if (origialPrice <= 100) {
return origialPrice * 0.9
} else {
return originalPrice - 20
}
}
function blackFridayPrice(origialPrice) {
if (origialPrice >= 100 && originalPrice < 200) {
return origialPrice - 20
} else if (originalPrice >= 200) {
return originalPrice - 50
} else {
return originalPrice * 0.8
}
}
function defaultPrice(origialPrice) {
return origialPrice
}
function getPrice(originalPrice, status) {
if (status === 'pre-sale') {
return preSalePrice(originalPrice)
}
if (status === 'promotion') {
return promotionPrice(originalPrice)
}
if (status === 'black-friday') {
return blackFridayPrice(originalPrice)
}
if(status === 'default'){
return defaultPrice(originalPrice)
}
}
經過這次修改,雖然代碼行數增加了,但是可讀性有了明顯的提升。我們的main函數顯然沒有那麼臃腫,寫單元測試也比較友善。
但是上面的改動并沒有解決根本的問題:我們的代碼還是充滿了if-else,當我們增加或減少折扣規則的時候,我們仍然需要修改getPrice。
想一想,我們之前用了這麼多if-else,目的是什麼?
實際上,使用這些 if-else 的目的是為了對應狀态和折扣政策。
我們可以發現,這個邏輯本質上是一種映射關系:産品狀态與折扣政策的映射關系。
我們可以使用映射而不是冗長的 if-else 來存儲映射。比如這樣:
let priceStrategies = {
'pre-sale': preSalePrice,
'promotion': promotionPrice,
'black-friday': blackFridayPrice,
'default': defaultPrice
}
我們将狀态與折扣政策結合起來。那麼計算價格會很簡單:
function getPrice(originalPrice, status) {
return priceStrategies[status](originalPrice)
}
這時候如果需要增減折扣政策,不需要修改getPrice函數,我們隻需在priceStrategies對象中增減一個映射關系即可。
之前的代碼邏輯如下:
現在代碼邏輯:
這樣是不是更簡潔嗎?
其實這招就是政策模式,是不是很實用?我不會在這裡談論政策模式的無聊定義。如果你想知道政策模式的官方定義,你可以自己谷歌一下。
如果您的函數具有以下特征:
判斷條件很多。
各個判斷條件下的代碼互相獨立
然後,你可以将每個判斷條件下的代碼封裝成一個獨立的函數,接着,建立判斷條件和具體政策的映射關系,使用政策模式重構你的代碼。
02、釋出-訂閱模式
這是我們在項目中經常使用的一種設計模式,也經常出現在面試中。
現在,我們有一個天氣預報系統:當極端天氣發生時,氣象站會釋出天氣警報。建築工地、船舶和遊客将根據天氣資料調整他們的日程安排。
一旦氣象站發出天氣警報,他們會做以下事情:
- 建築工地:停工
- 船舶:停泊靠岸
- 遊客:取消行程
如果,我們被要求編寫可用于通知天氣警告的代碼,你會想怎麼做?
編寫天氣警告函數的常用方法可能是這樣的:
function weatherWarning(){
buildingsite.stopwork()
ships.mooring()
tourists.canceltrip()
}
這是一種非常直覺的寫法,但是這種寫法有很多不好的地方:
- 耦合度太高。建築工地、船舶和遊客本來應該是分開的,但現在它們被置于相同的功能中。其中一個對象中的錯誤可能會導緻其他對象無法工作。顯然,這是不合理的。
- 違反開閉原則。如果有新的訂閱者加入,那麼我們隻能修改weatherWarning函數。
造成這種現象的原因是氣象站承擔了主動告知各機關的責任。這就要求氣象站必須了解每個需要了解天氣狀況的機關。
但仔細想想,其實,從邏輯上講,建築工地、船舶、遊客都應該依靠天氣預報,他們應該是積極的一方。
我們可以将依賴項更改為如下所示:
氣象站釋出通知,然後觸發事件,建築工地、船舶和遊客訂閱該事件。
氣象站不需要關心哪些對象關注天氣預警,隻需要直接觸發事件即可。然後需要了解天氣狀況的機關主動訂閱該事件。
這樣,氣象站與訂閱者解耦,訂閱者之間也解耦。如果有新的訂閱者,那麼它隻需要直接訂閱事件,而不需要修改現有的代碼。
當然,為了完成這個釋出-訂閱系統,我們還需要實作一個事件訂閱和分發系統。
可以這樣寫:
const EventEmit = function() {
this.events = {};
this.on = function(name, cb) {
if (this.events[name]) {
this.events[name].push(cb);
} else {
this.events[name] = [cb];
}
};
this.trigger = function(name, ...arg) {
if (this.events[name]) {
this.events[name].forEach(eventListener => {
eventListener(...arg);
});
}
};
};
我們之前的代碼,重構以後變成這樣:
let weatherEvent = new EventEmit()
weatherEvent.on('warning', function () {
// buildingsite.stopwork()
console.log('buildingsite.stopwork()')
})
weatherEvent.on('warning', function () {
// ships.mooring()
console.log('ships.mooring()')
})
weatherEvent.on('warning', function () {
// tourists.canceltrip()
console.log('tourists.canceltrip()')
})
weatherEvent.trigger('warning')
如果你的項目中存在多對一的依賴,并且每個子產品相對獨立,那麼你可以考慮使用釋出-訂閱模式來重構你的代碼。
事實上,釋出訂閱模式應該是我們前端開發者最常用的設計模式。
element.addEventListener('click', function(){
//...
})
// this is also publish-subscribe pattern
03、代理模式
現在我們的頁面上有一個清單:
<ul id="container">
<li>Jon</li>
<li>Jack</li>
<li>bytefish</li>
<li>Rock Lee</li>
<li>Bob</li>
</ul>
我們想給頁面添加一個效果:每當使用者點選清單中的每個項目時,都會彈出一條消息:Hi, I'm ${name}
我們将如何實作此功能?
大緻思路是給每個li元素添加一個點選事件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Proxy Pattern</title>
</head>
<body>
<ul id="container">
<li>Jon</li>
<li>Jack</li>
<li>bytefish</li>
<li>Rock Lee</li>
<li>Bob</li>
</ul>
<script>
let container = document.getElementById('container')
Array.prototype.forEach.call(container.children, node => {
node.addEventListener('click', function(e){
e.preventDefault()
alert(`Hi, I'm ${e.target.innerText}`)
})
})
</script>
</body>
</html>
這種方法可以滿足要求,但這樣做的缺點是性能開銷,因為每個 li 标簽都綁定到一個事件。如果清單中有數千個元素,我們是否綁定了數千個事件?
如果我們仔細看這段代碼,可以發現目前的邏輯關系如下:
每個 li 都有自己的事件處理機制。但是我們發現不管是哪個li,其實都是ul的成員。我們可以将li的事件委托給ul,讓ul成為這些 li 的事件代理。
這樣,我們隻需要為這些 li 元素綁定一個事件。
let container = document.getElementById('container')
container.addEventListener('click', function (e) {
console.log(e)
if (e.target.nodeName === 'LI') {
e.preventDefault()
alert(`Hi, I'm ${e.target.innerText}`)
}
})
這實際上是代理模式。
代理模式是本體不直接出現,而是讓代了解決問題。
在上述情況下,li 并沒有直接處理點選事件,而是将其委托給 ul。
現實生活中,明星并不是直接出來談生意,而是交給他們的經紀人,也就是明星的代理人。
代理模式的應用非常廣泛,我們來看另一個使用它的案例。
假設我們現在有一個計算函數,參數是字元串,計算比較耗時。同時,這是一個純函數。如果參數相同,則函數的傳回值将相同。
function compute(str) {
// Suppose the calculation in the funtion is very time consuming
console.log('2000s have passed')
return 'a result'
}
現在需要給這個函數添加一個緩存函數:每次計算後,存儲參數和對應的結果。在接下來的計算中,會先從緩存中查詢計算結果。
你會怎麼寫代碼?
當然,你可以直接修改這個函數的功能。但這并不好,因為緩存并不是這個功能的固有特性。如果将來您不需要緩存,那麼,您将不得不再次修改此功能。
更好的解決方案是使用代理模式。
const proxyCompute = (function (fn){
// Create an object to store the results returned after each function execution.
const cache = Object.create(null);
// Returns the wrapped function
return function (str) {
// If the cache is not hit, the function will be executed
if ( !cache[str] ) {
let result = fn(str);
// Store the result of the function execution in the cache
cache[str] = result;
}
return cache[str]
}
})(compute)
這樣,我們可以在不修改原函數技術的情況下為其擴充計算函數。
這就是代理模式,它允許我們在不改變原始對象本身的情況下添加額外的功能。
結論
這些是我在日常項目中使用的設計模式。設計模式不是無聊的概念,它們是使我們的代碼易于擴充的技術解決方案。
最後,希望這些例子對你有用,感謝閱讀。