設計模式大冒險第二關:裝飾者模式,煎餅果子的主場

dreamapplehappy發表於2020-11-02

封面圖

這是關於設計模式系列的第二篇文章,在這個系列中,我們儘量不使用那些讓你一聽起來就感覺頭大的解釋設計模式的術語,那樣相當於給大家帶去了新的理解難度。我們會使用生活中的場景以及一些通俗易懂的小例子來給大家展示每一個設計模式使用的場景以及要解決的問題。

這篇文章我們來講解裝飾者模式,那麼什麼是裝飾者模式呢?對於名字來說你可能會感到比較陌生,但是你在生活中肯定經常使用這個模式去解決生活中的一些問題。只是你並不知道它原來是裝飾者模式而已

生活中的裝飾者模式

想象一下,夏天到了,你家住在比較低的樓層,一到晚上許多的蚊子就到你家裡做客,它們對你的身體進行大快朵頤讓你很煩惱。你這時才發現家裡的窗戶上沒有裝上窗紗,所以一到晚上如果不及時關閉窗戶的話,那麼就會有很多蚊子來拜訪你。但是你想晚上感受一下微風徐來,又不想被蚊子拜訪,那麼你要做的就是給窗戶裝上窗紗。

對,給窗戶裝上窗紗就是使用了裝飾者模式。我們沒有對原來的窗戶做任何的更改,它還是那個窗戶,可以開啟和關閉,可以透過它觀看風景,可以感受微風徐來。增加了窗紗之後,我們的窗戶有了新的功能,那就是可以阻止蚊子進入室內了。這樣我們就擴充了窗戶的功能,但是沒有對原來的窗戶做什麼改變

生活中還有很多這樣的例子,這裡就不一一列舉了,相信你看完這篇文章之後,會對這個設計模式有更深一步的理解。然後能夠發現生活中更多這樣的例子,進而加強你對這個設計模式的理解與掌握。

那麼在開發中我們需要使用這個設計模式來解決什麼問題呢?我們要解決的是這樣的問題:在不改變已有物件的屬性和方法的前提下,對已有物件的屬性和功能進行擴充

你會好奇為什麼要這樣做呢?首先已有的物件可能是你不能夠修改的,為什麼不能夠修改?可能因為這個物件是第三方庫引入的,或者是程式碼中全域性使用的,或者是一個你還不是很熟悉和了解的物件。這些情況下,你是不能夠輕易在這些物件上新增新的功能的。但是你又不得不對這個物件增加一些新的功能來滿足當下的開發需求。所以這時候,使用裝飾者模式就可以很好地解決這個問題。我們趕緊來學習一下吧~

通過一個例子來實戰裝飾者模式

樓下賣煎餅果子的老闆知道你會編寫程式,所以想讓你來幫忙寫一個點餐的小程式,來方便他給買煎餅果子的客戶點餐。報酬就是以後你來買煎餅果子給你打88折,你一聽感覺還不錯,所以就答應了下來。

當你準備開始的時候,老闆告訴你說他的點餐系統已經有一部分程式碼了,並且希望你不要修改這些程式碼,因為他不確定這些程式碼在他的點餐系統中是否有用過。修改之後可能會導致一些問題,所以你只能在之前的基礎上新增新的功能。老闆給的程式碼如下:

// 煎餅果子
class Pancake {
  constructor() {
    this.name = "煎餅果子";
  }

  //獲取煎餅果子的名字
  getName() {
    return this.name;
  }

  // 獲取價格
  getPrice() {
    return 5;
  }
}

老闆要求如下:

  • 不能夠修改之前的程式碼
  • 煎餅果子可以隨意搭配雞蛋,香腸,和培根,並且每一種的數量沒有限制
  • 點餐完成之後能夠展示當前煎餅果子包含搭配的配料,以及價格

你現在不可以修改已有的程式碼,但是卻要增加新的功能,這對你來說還是有一點點難度的。但是好巧的是你剛剛學習完裝飾者模式,使用這個設計模式就可以很好地解決這個問題。而且是在不修改原來的程式碼的情況下。你馬上回到家中開始為你的88折優惠努力開發起來。

對原有物件的基本裝飾

在開始對原來的物件進行具體的裝飾之前,我們需要寫一個基本的裝飾類,如下所示:

// 裝飾器需要跟被裝飾的物件具有同樣的介面
class PancakeDecorator {
  // 需要傳入一個煎餅果子的例項
  constructor(pancake) {
    this.pancake = pancake;
  }
  // 獲取煎餅果子的名字
  getName() {
    return `${this.pancake.getName()}`;
  }
  // 獲取煎餅果子的價格
  getPrice() {
    return this.pancake.getPrice();
  }
}

我們看一下上面的程式碼,你會發現PancakeDecorator除了構造器需要傳遞一個Pancake的例項之外,其他的方法跟Pancake是保持一致的。

這個基本裝飾類的目的是為了讓我們後面開發的具體的裝飾類跟被裝飾的物件具有相同的介面,為了後面的組合和委託功能做好鋪墊

開發具體的裝飾類

我們知道老闆的配料有雞蛋培根,還有香腸。所以我們接下來需要開發三個具體的裝飾類,程式碼如下所示:

// 煎餅果子加雞蛋
class PancakeDecoratorWithEgg extends PancakeDecorator {
  // 獲取煎餅果子加雞蛋的名字
  getName() {
    return `${this.pancake.getName()}➕雞蛋`;
  }

  getPrice() {
    return this.pancake.getPrice() + 2;
  }
}

// 加香腸
class PancakeDecoratorWithSausage extends PancakeDecorator {
  // 加香腸
  getName() {
    return `${this.pancake.getName()}➕香腸`;
  }

  getPrice() {
    return this.pancake.getPrice() + 1.5;
  }
}

// 加培根
class PancakeDecoratorWithBacon extends PancakeDecorator {
  // 加培根
  getName() {
    return `${this.pancake.getName()}➕培根`;
  }

  getPrice() {
    return this.pancake.getPrice() + 3;
  }
}

從上面的程式碼我們可以看到,每一個具體的裝飾類都只對應一種配料,然後每一個具體的裝飾類因為繼承自PancakeDecorator,所以跟被裝飾類保持相同的介面。在方法getName中,我們首先先獲取當前傳入進來的pancake的名字,然後在後面新增上當前裝飾器對應的配料的名字。在getPrice方法中,我們使用同樣的方法,獲取新增這個裝飾器指定的配料後的價格。

寫完了上面的具體的裝飾器之後,我們的工作就基本完成啦。我們來寫一些測試程式碼,來驗證一下我們的功能是否滿足需求。測試的程式碼如下:

let pancake = new Pancake();
// 加雞蛋
pancake = new PancakeDecoratorWithEgg(pancake);
console.log(pancake.getName(), pancake.getPrice());
// 加香腸
pancake = new PancakeDecoratorWithSausage(pancake);
console.log(pancake.getName(), pancake.getPrice());
// 加培根
pancake = new PancakeDecoratorWithBacon(pancake);
console.log(pancake.getName(), pancake.getPrice());

輸出的結果如下:

煎餅果子➕雞蛋 7
煎餅果子➕雞蛋➕香腸 8.5
煎餅果子➕雞蛋➕香腸➕培根 11.5

結果跟我們的預期是一致的,所以我們上面的程式碼已經很好地完成了老闆的需求。可以馬上交給老闆去使用了。

裝飾者模式的組合和委託

也許通過上面的程式碼你還沒有能夠完全理解這樣做的目的,沒關係,我來給大家再展示一個關於這個模式的示例圖,相信看過這個例項圖你肯定會理解得很深刻的。

裝飾者模式圖解

  • 第一步:呼叫PancakeDecoratorWithSausage例項的getPrice方法。
  • 第二步:因為PancakeDecoratorWithSausage例項的getPrice方法需要訪問PancakeDecoratorWithEgg的例項,所以進入第三步。
  • 第三步:因為PancakeDecoratorWithEgg例項的getPrice方法需要訪問PancakeDecorator的例項,所以進入第四步。
  • 第四步:因為PancakeDecorator例項的getPrice方法需要訪問Pancake的例項,進入第五步。
  • 第五步:通過Pancake的例項返回不加料的煎餅果子的價格是5元。
  • 第六步:PancakeDecorator例項獲取原始的煎餅果子的價格,返回這個價格。
  • 第七步:PancakeDecoratorWithEgg例項獲取到PancakeDecorator返回的價格5元,再加上配料雞蛋的價格2元,所以返回7元。
  • 第八步:PancakeDecoratorWithSausage例項獲取到PancakeDecoratorWithEgg例項返回的價格7元,再加上配料香腸的價格1.5元,返回價格8.5元。

從上面的這幅圖我們可以清楚地看到這個過程的變化,我們通過組合和委託實現了新增不同配料的價格計算。所謂的委託就是指我們沒有直接計算出當前的價格,而是需要委託方法中另外一個例項的方法去獲取相應的價格,直到訪問到最原始不加料的煎餅果子的價格,再逐次返回委託得到的結果。最終算出加料後的價格。有沒有感覺這個過程跟DOM事件的捕獲冒泡很相似。

所謂的組合,就是指,我們不需要知道當前的煎餅果子的狀態,只需要把這個煎餅果子的例項當做我們具體裝飾類的建構函式的引數,然後生成一個新的煎餅果子的例項,這樣就可以給傳入進來的煎餅果子新增相應的配料

怎麼樣,是不是感覺裝飾者模式還挺簡單的,而且也很有用。好了,我們需要把這些程式碼交給煎餅果子的老闆了,讓他去試用一下,看看怎麼樣。大家可以在這裡體驗一下這個不完善的煎餅果子點餐系統,下面的動圖是一個簡單的操作演示,大家可以提前感受一下。

操作演示

對裝飾者模式的一些思考

每當學習完一個新的知識之後,我們要學著把這個知識點納入我們已有的知識系統中;比如學習完了裝飾者模式,你可能會想到我應該在什麼情況下使用這種設計模式?我現在已經掌握的知識中有沒有跟這個相關聯的?這種設計模式有沒有什麼弊端?等等,需要你自己深入的思考沉澱一下。

裝飾者模式的一些延伸

經常使用React來開發應用的小夥伴這個時候是不是想到了React的高階元件?我們看看React的文件中是如何描述高階元件的:

A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature.

React通過高階元件,可以使用組合的方式複用元件的邏輯,這是一種高階的技巧?。你現在已經掌握這種高階的技巧了。

如果你對JavaScript的未來發展比較關注的話,那麼你肯定知道在以後的JavaScript版本中,可能會在語言的原生層面增加對裝飾器的支援。更多詳細的資料大家可以在tc39/proposal-decorators這裡獲取。

比如如果在語言的原生層面支援裝飾器的話,我們可以寫出下面的程式碼:

@annotation
class MyClass { }

function annotation(target) {
   target.annotated = true;
}

上面的程式碼來自babel-plugin-proposal-decorators的示例。

在上面的程式碼中,@annotation是類MyClass的裝飾器,這個裝飾器給我們的MyClass類新增了一個屬性annotated,並且把這個屬性的值設定為true。這個過程不需要我們對原來的類MyClass做任何修改,就實現了給這個類新增一個屬性的功能。是不是很棒~

我們也可以驗證一下:

console.log(MyClass.annotated);  # true

裝飾者模式適用的場景以及可能存在的問題

裝飾者模式利用組合和委託的特性,能夠讓我們在不改變原來已有物件的功能和屬性的情況下增加新的功能和屬性,讓我們能夠保持程式碼的低耦合和可擴充套件性。是一種很不錯的設計模式。

但是使用裝飾者模式也有潛在的問題,因為隨著裝飾者的增多,程式碼的複雜性也隨之增加了,所以要確保在合適的場景下使用裝飾者模式。

文章到這裡就結束了,如果你有什麼意見和建議歡迎給我留言。你還可以關注我的公眾號關山不難越,獲取更多關於設計模式的文章講解以及好玩有趣的前端知識。

相關閱讀推薦:

相關文章