我經(jīng)常使用的 3 種有用的設(shè)計(jì)模式
英文 | https://medium.com/frontend-canteen/useful-design-patterns-48ac739882a4function getPrice(originalPrice, status){// ...return price}
其實(shí),面對(duì)這樣的問題,如果不考慮任何設(shè)計(jì)模式,最直觀的寫法可能就是使用if-else通過多個(gè)判斷語句來計(jì)算價(jià)格。
有三種狀態(tài),所以我們可以快速編寫如下代碼:
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}}
有三個(gè)條件;然后,我們寫三個(gè) if 語句,這是非常直觀的代碼。
但是這段代碼并不友好。
首先,它違反了單一職責(zé)原則。主函數(shù) getPrice 做了太多的事情。這個(gè)函數(shù)不易閱讀,也容易出現(xiàn)bug。如果一個(gè)條件有bug,整個(gè)函數(shù)就會(huì)崩潰。同時(shí),這樣的代碼也不容易調(diào)試。
然后,這段代碼很難應(yīng)對(duì)變化。正如我在文章開頭所說的那樣,設(shè)計(jì)模式往往會(huì)在業(yè)務(wù)邏輯發(fā)生變化時(shí)表現(xiàn)出它的魅力。
假設(shè)我們的業(yè)務(wù)擴(kuò)大了,現(xiàn)在還有另一個(gè)折扣促銷:黑色星期五,折扣規(guī)則如下:
價(jià)格低于或等于 100 美元的產(chǎn)品以 20% 的折扣出售。
價(jià)格高于 100 美元但低于 200 美元的產(chǎn)品將減少 20 美元。
價(jià)格高于或等于 200 美元的產(chǎn)品將減少 20 美元。
這時(shí)候怎么擴(kuò)展getPrice函數(shù)呢?
看起來我們必須在 getPrice 函數(shù)中添加一個(gè)條件。
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}}
每當(dāng)我們?cè)黾踊驕p少折扣時(shí),我們都需要更改函數(shù)。這種做法違反了開閉原則。修改已有函數(shù)很容易出現(xiàn)新的錯(cuò)誤,也會(huì)讓getPrice越來越臃腫。
那么我們?nèi)绾蝺?yōu)化這段代碼呢?
首先,我們可以拆分這個(gè)函數(shù)以使 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)}}
經(jīng)過這次修改,雖然代碼行數(shù)增加了,但是可讀性有了明顯的提升。我們的main函數(shù)顯然沒有那么臃腫,寫單元測(cè)試也比較方便。
但是上面的改動(dòng)并沒有解決根本的問題:我們的代碼還是充滿了if-else,當(dāng)我們?cè)黾踊驕p少折扣規(guī)則的時(shí)候,我們?nèi)匀恍枰薷膅etPrice。
想一想,我們之前用了這么多if-else,目的是什么?
實(shí)際上,使用這些 if-else 的目的是為了對(duì)應(yīng)狀態(tài)和折扣策略。

我們可以發(fā)現(xiàn),這個(gè)邏輯本質(zhì)上是一種映射關(guān)系:產(chǎn)品狀態(tài)與折扣策略的映射關(guān)系。
我們可以使用映射而不是冗長(zhǎng)的 if-else 來存儲(chǔ)映射。比如這樣:
let priceStrategies = {'pre-sale': preSalePrice,'promotion': promotionPrice,'black-friday': blackFridayPrice,'default': defaultPrice}
我們將狀態(tài)與折扣策略結(jié)合起來。那么計(jì)算價(jià)格會(huì)很簡(jiǎn)單:
function getPrice(originalPrice, status) {return priceStrategies[status](originalPrice)}
這時(shí)候如果需要增減折扣策略,不需要修改getPrice函數(shù),我們只需在priceStrategies對(duì)象中增減一個(gè)映射關(guān)系即可。
之前的代碼邏輯如下:

現(xiàn)在代碼邏輯:

這樣是不是更簡(jiǎn)潔嗎?
其實(shí)這招就是策略模式,是不是很實(shí)用?我不會(huì)在這里談?wù)摬呗阅J降臒o聊定義。如果你想知道策略模式的官方定義,你可以自己谷歌一下。
如果您的函數(shù)具有以下特征:
判斷條件很多。
各個(gè)判斷條件下的代碼相互獨(dú)立
然后,你可以將每個(gè)判斷條件下的代碼封裝成一個(gè)獨(dú)立的函數(shù),接著,建立判斷條件和具體策略的映射關(guān)系,使用策略模式重構(gòu)你的代碼。
02、發(fā)布-訂閱模式
這是我們?cè)陧?xiàng)目中經(jīng)常使用的一種設(shè)計(jì)模式,也經(jīng)常出現(xiàn)在面試中。
現(xiàn)在,我們有一個(gè)天氣預(yù)報(bào)系統(tǒng):當(dāng)極端天氣發(fā)生時(shí),氣象站會(huì)發(fā)布天氣警報(bào)。建筑工地、船舶和游客將根據(jù)天氣數(shù)據(jù)調(diào)整他們的日程安排。
一旦氣象站發(fā)出天氣警報(bào),他們會(huì)做以下事情:
建筑工地:停工
船舶:停泊靠岸
游客:取消行程
如果,我們被要求編寫可用于通知天氣警告的代碼,你會(huì)想怎么做?
編寫天氣警告函數(shù)的常用方法可能是這樣的:
function weatherWarning(){buildingsite.stopwork()ships.mooring()tourists.canceltrip()}
這是一種非常直觀的寫法,但是這種寫法有很多不好的地方:
耦合度太高。建筑工地、船舶和游客本來應(yīng)該是分開的,但現(xiàn)在它們被置于相同的功能中。其中一個(gè)對(duì)象中的錯(cuò)誤可能會(huì)導(dǎo)致其他對(duì)象無法工作。顯然,這是不合理的。
違反開閉原則。如果有新的訂閱者加入,那么我們只能修改weatherWarning函數(shù)。
造成這種現(xiàn)象的原因是氣象站承擔(dān)了主動(dòng)告知各單位的責(zé)任。這就要求氣象站必須了解每個(gè)需要了解天氣狀況的單位。

但仔細(xì)想想,其實(shí),從邏輯上講,建筑工地、船舶、游客都應(yīng)該依靠天氣預(yù)報(bào),他們應(yīng)該是積極的一方。
我們可以將依賴項(xiàng)更改為如下所示:

氣象站發(fā)布通知,然后觸發(fā)事件,建筑工地、船舶和游客訂閱該事件。
氣象站不需要關(guān)心哪些對(duì)象關(guān)注天氣預(yù)警,只需要直接觸發(fā)事件即可。然后需要了解天氣狀況的單位主動(dòng)訂閱該事件。
這樣,氣象站與訂閱者解耦,訂閱者之間也解耦。如果有新的訂閱者,那么它只需要直接訂閱事件,而不需要修改現(xiàn)有的代碼。
當(dāng)然,為了完成這個(gè)發(fā)布-訂閱系統(tǒng),我們還需要實(shí)現(xiàn)一個(gè)事件訂閱和分發(fā)系統(tǒng)。
可以這樣寫:
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);});}};};
我們之前的代碼,重構(gòu)以后變成這樣:
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')
如果你的項(xiàng)目中存在多對(duì)一的依賴,并且每個(gè)模塊相對(duì)獨(dú)立,那么你可以考慮使用發(fā)布-訂閱模式來重構(gòu)你的代碼。
事實(shí)上,發(fā)布訂閱模式應(yīng)該是我們前端開發(fā)者最常用的設(shè)計(jì)模式。
element.addEventListener('click', function(){//...})// this is also publish-subscribe pattern
03、代理模式
現(xiàn)在我們的頁面上有一個(gè)列表:
<ul id="container"><li>Jon</li><li>Jack</li><li>bytefish</li><li>Rock Lee</li><li>Bob</li></ul>
我們想給頁面添加一個(gè)效果:每當(dāng)用戶點(diǎn)擊列表中的每個(gè)項(xiàng)目時(shí),都會(huì)彈出一條消息:Hi, I'm ${name}

我們將如何實(shí)現(xiàn)此功能?
大致思路是給每個(gè)li元素添加一個(gè)點(diǎn)擊事件。
<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>
這種方法可以滿足要求,但這樣做的缺點(diǎn)是性能開銷,因?yàn)槊總€(gè) li 標(biāo)簽都綁定到一個(gè)事件。如果列表中有數(shù)千個(gè)元素,我們是否綁定了數(shù)千個(gè)事件?
如果我們仔細(xì)看這段代碼,可以發(fā)現(xiàn)當(dāng)前的邏輯關(guān)系如下:

每個(gè) li 都有自己的事件處理機(jī)制。但是我們發(fā)現(xiàn)不管是哪個(gè)li,其實(shí)都是ul的成員。我們可以將li的事件委托給ul,讓ul成為這些 li 的事件代理。

這樣,我們只需要為這些 li 元素綁定一個(gè)事件。
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}`)}})
這實(shí)際上是代理模式。
代理模式是本體不直接出現(xiàn),而是讓代理解決問題。
在上述情況下,li 并沒有直接處理點(diǎn)擊事件,而是將其委托給 ul。
現(xiàn)實(shí)生活中,明星并不是直接出來談生意,而是交給他們的經(jīng)紀(jì)人,也就是明星的代理人。
代理模式的應(yīng)用非常廣泛,我們來看另一個(gè)使用它的案例。
假設(shè)我們現(xiàn)在有一個(gè)計(jì)算函數(shù),參數(shù)是字符串,計(jì)算比較耗時(shí)。同時(shí),這是一個(gè)純函數(shù)。如果參數(shù)相同,則函數(shù)的返回值將相同。
function compute(str) {// Suppose the calculation in the funtion is very time consumingconsole.log('2000s have passed')return 'a result'}
現(xiàn)在需要給這個(gè)函數(shù)添加一個(gè)緩存函數(shù):每次計(jì)算后,存儲(chǔ)參數(shù)和對(duì)應(yīng)的結(jié)果。在接下來的計(jì)算中,會(huì)先從緩存中查詢計(jì)算結(jié)果。
你會(huì)怎么寫代碼?
當(dāng)然,你可以直接修改這個(gè)函數(shù)的功能。但這并不好,因?yàn)榫彺娌⒉皇沁@個(gè)功能的固有特性。如果將來您不需要緩存,那么,您將不得不再次修改此功能。
更好的解決方案是使用代理模式。
const proxyCompute = (function (fn){// Create an object to store the results returned after each function execution.const cache = Object.create(null);// Returns the wrapped functionreturn function (str) {// If the cache is not hit, the function will be executedif ( !cache[str] ) {let result = fn(str);// Store the result of the function execution in the cachecache[str] = result;}return cache[str]}})(compute)
這樣,我們可以在不修改原函數(shù)技術(shù)的情況下為其擴(kuò)展計(jì)算函數(shù)。

這就是代理模式,它允許我們?cè)诓桓淖冊(cè)紝?duì)象本身的情況下添加額外的功能。
結(jié)論
這些是我在日常項(xiàng)目中使用的設(shè)計(jì)模式。設(shè)計(jì)模式不是無聊的概念,它們是使我們的代碼易于擴(kuò)展的技術(shù)解決方案。
最后,希望這些例子對(duì)你有用,感謝閱讀。
學(xué)習(xí)更多技能
請(qǐng)點(diǎn)擊下方公眾號(hào)
![]()

