天天看點

我經常使用的 3 種有用的設計模式

我經常使用的 3 種有用的設計模式

英文 | 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 的目的是為了對應狀态和折扣政策。

我經常使用的 3 種有用的設計模式

我們可以發現,這個邏輯本質上是一種映射關系:産品狀态與折扣政策的映射關系。

我們可以使用映射而不是冗長的 if-else 來存儲映射。比如這樣:

let priceStrategies = {
  'pre-sale': preSalePrice,
  'promotion': promotionPrice,
  'black-friday': blackFridayPrice,
  'default': defaultPrice
}      

我們将狀态與折扣政策結合起來。那麼計算價格會很簡單:

function getPrice(originalPrice, status) {
  return priceStrategies[status](originalPrice)
}      

這時候如果需要增減折扣政策,不需要修改getPrice函數,我們隻需在priceStrategies對象中增減一個映射關系即可。

之前的代碼邏輯如下:

我經常使用的 3 種有用的設計模式

現在代碼邏輯:

我經常使用的 3 種有用的設計模式

這樣是不是更簡潔嗎?

其實這招就是政策模式,是不是很實用?我不會在這裡談論政策模式的無聊定義。如果你想知道政策模式的官方定義,你可以自己谷歌一下。

如果您的函數具有以下特征:

判斷條件很多。

各個判斷條件下的代碼互相獨立

然後,你可以将每個判斷條件下的代碼封裝成一個獨立的函數,接着,建立判斷條件和具體政策的映射關系,使用政策模式重構你的代碼。

02、釋出-訂閱模式

這是我們在項目中經常使用的一種設計模式,也經常出現在面試中。

現在,我們有一個天氣預報系統:當極端天氣發生時,氣象站會釋出天氣警報。建築工地、船舶和遊客将根據天氣資料調整他們的日程安排。

一旦氣象站發出天氣警報,他們會做以下事情:

  • 建築工地:停工
  • 船舶:停泊靠岸
  • 遊客:取消行程

如果,我們被要求編寫可用于通知天氣警告的代碼,你會想怎麼做?

編寫天氣警告函數的常用方法可能是這樣的:

function weatherWarning(){
  buildingsite.stopwork()
  ships.mooring()
  tourists.canceltrip()
}      

這是一種非常直覺的寫法,但是這種寫法有很多不好的地方:

  • 耦合度太高。建築工地、船舶和遊客本來應該是分開的,但現在它們被置于相同的功能中。其中一個對象中的錯誤可能會導緻其他對象無法工作。顯然,這是不合理的。
  • 違反開閉原則。如果有新的訂閱者加入,那麼我們隻能修改weatherWarning函數。

造成這種現象的原因是氣象站承擔了主動告知各機關的責任。這就要求氣象站必須了解每個需要了解天氣狀況的機關。

我經常使用的 3 種有用的設計模式

但仔細想想,其實,從邏輯上講,建築工地、船舶、遊客都應該依靠天氣預報,他們應該是積極的一方。

我們可以将依賴項更改為如下所示:

我經常使用的 3 種有用的設計模式

氣象站釋出通知,然後觸發事件,建築工地、船舶和遊客訂閱該事件。

氣象站不需要關心哪些對象關注天氣預警,隻需要直接觸發事件即可。然後需要了解天氣狀況的機關主動訂閱該事件。

這樣,氣象站與訂閱者解耦,訂閱者之間也解耦。如果有新的訂閱者,那麼它隻需要直接訂閱事件,而不需要修改現有的代碼。

當然,為了完成這個釋出-訂閱系統,我們還需要實作一個事件訂閱和分發系統。

可以這樣寫:

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}

我經常使用的 3 種有用的設計模式

我們将如何實作此功能?

大緻思路是給每個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 标簽都綁定到一個事件。如果清單中有數千個元素,我們是否綁定了數千個事件?

如果我們仔細看這段代碼,可以發現目前的邏輯關系如下:

我經常使用的 3 種有用的設計模式

每個 li 都有自己的事件處理機制。但是我們發現不管是哪個li,其實都是ul的成員。我們可以将li的事件委托給ul,讓ul成為這些 li 的事件代理。

我經常使用的 3 種有用的設計模式

這樣,我們隻需要為這些 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)      

這樣,我們可以在不修改原函數技術的情況下為其擴充計算函數。

我經常使用的 3 種有用的設計模式

這就是代理模式,它允許我們在不改變原始對象本身的情況下添加額外的功能。

結論

這些是我在日常項目中使用的設計模式。設計模式不是無聊的概念,它們是使我們的代碼易于擴充的技術解決方案。

最後,希望這些例子對你有用,感謝閱讀。

我經常使用的 3 種有用的設計模式

繼續閱讀