前端常用設計模式(1)--裝飾器(decorator)

TNFE發表於2019-04-15

一.引子-先來安利一款好遊戲

《塞爾達傳說-荒野之息》,這款於2017年3月3日由任天堂(“民間高手”)發售在自家主機平臺WIIU和SWITCH上的單機RPG遊戲,可謂是跨時代的“神作”了。第一次製作“開放類”遊戲的任天堂就教科書般的定義了這類遊戲應該如何製作。

前端常用設計模式(1)--裝飾器(decorator)
前端常用設計模式(1)--裝飾器(decorator)
而這個遊戲真正吸引我的地方是他的細節,舉個栗子,《荒野之息》中的世界有天氣和溫度兩個概念,會下雨打雷,有嚴寒酷暑,但是這些天氣不想大多數遊戲一樣,只是簡單的背景,而是實實在在會影響主角林克(Link)每一個操作。比如,下雨天去爬山會打滑;打雷天如果身上有金屬裝備會被雷劈(木製裝備則沒事!);嚴寒中會慢慢流失體力(穿上一件保暖衣就解決了);酷暑中使用爆炸箭則會原地爆炸!等等;
前端常用設計模式(1)--裝飾器(decorator)
就是這些細節讓這個遊戲世界顯的無比真實又有趣。

二.問題-如何設計這樣的遊戲程式碼?

作為程式猿,玩遊戲之餘不禁會思考,這樣的遊戲程式碼應該如何設計編寫? 比如“攀爬”這個動作,需要判斷攀爬的位置,林克的裝備(有些裝備能讓你爬的更快),當時的天氣,林克的體力等等眾多條件,裡面肯定參雜的無數if else,更何況這只是其中一個簡單的操作,擴充到全部遊戲,其複雜的不可想象。 顯然這樣的設計是不行的。 那我們假設“攀爬”的方法只專心處理攀爬這件事(有體力就能成功,反之失敗),其他判斷在方法外部執行,比如判斷天氣,裝備,位置等等,這樣就符合了程式設計的單一職責和低耦合等原則,並且判斷天氣的方法還可以拿去別的地方複用,增強了程式碼的複用度和可測試度,似乎可行! 那應該如何設計這樣的程式碼呢?這就引出了我們今天的主角-裝飾器模式。

三.主角-裝飾器模式(decorator)

根據GoF在《設計模式:可複用物件導向軟體的基礎》(以下簡稱《設計模式》)一書中對裝飾器模式定義:裝飾器模式又稱包裝模式(“wrapper”),目的是以對使用者透明的方式擴充套件物件的功能,是繼承的一種代替方案。 一起劃重點:

  1. 對使用者透明:一般指被裝飾過的物件的對外介面不變,“攀爬”被怎麼裝飾都還是“攀爬”。
  2. 擴充套件物件的功能:一般指修改或新增物件功能,比如林克在雪地就可以用盾牌滑雪,平地則沒有這個能力。
  3. 繼承的一種代替方案:熟悉物件導向的同學一定對繼承並不陌生,這裡我們重點談談繼承本身的一些缺點:1)繼承中子類和超類存在強耦合性,超類的修改會影響全部子類;2)超類對子類是“白盒複用”,子類必須瞭解超類的全部實現,破壞了封裝性。3)當專案龐大時,繼承會使得子類爆發性增長,比如《荒野之息》中存在料理系統,任意兩種食材均可以搭配出一款料理,假定有10中可以使用食材,使用繼承的方式就要構建10*10=100個子類表示料理結果,而裝飾器模式僅僅使用10+1=11個子類就可以完成以上工作。(還包括了任意種食材的混合,事實上游戲中的確可以。) 最後,總結一下裝飾器模式的特點:不改變物件自身的基礎上,在程式執行時給物件新增某種功能,一句話:錦上添花。(想想《王者榮耀》中最賺錢的皮膚,怎麼全是遊戲,喂!)

四.場景-面向切片程式設計(AOP)

說到裝飾器,最經典的應用場景就是面向切片程式設計(Aspect Oriented Programming,以下簡稱AOP),AOP適合某些具有橫向邏輯(可切片)的應用,比如提交表單,點選提交按鈕以後執行的邏輯是:上報點選 -> 校驗資料 -> 提交資料 -> 上報結果 。可以看到,首尾的上報日誌功能和核心業務邏輯並沒有直接關係,並且幾乎所有表單提交都需要上報日誌的功能,因此,上報日誌,這個功能就可以單獨抽象出來,最後在程式執行(或編譯)時動態織入業務邏輯中。類似的功能還有:資料校驗,許可權控制,異常處理,快取管理等等。 AOP的優點是可以保持業務邏輯模組的純淨和高內聚,同時方便功能複用,通過裝飾器就可以很方便的把功能模組裝飾到主業務邏輯中去。

五.應用-前端開發中的應用

接下來我們一起看看具體裝飾器模式是如何在前端開發中應用的。 Talk is cheap, show me the code! (屁話少說,放碼過來!) 在JS中改變一個物件再簡單不過了。

前端常用設計模式(1)--裝飾器(decorator)
得力於JS是一門基於原型的弱型別語言,給物件新增或修改功能都十分容易,因此傳統的物件導向中的裝飾器模式在JS中的應用並不太多(ES6正式提出class以後場景有所增加)。 我們先簡單模擬一下物件導向中的裝飾器模式。 假設我們要開發一個飛機大戰的遊戲,飛機可以切換裝備的武器,發射不同的子彈。
前端常用設計模式(1)--裝飾器(decorator)
我們先實現一個飛機的類,並實現一個fire方法。 接著,我們實現一個發射導彈的裝飾器類
前端常用設計模式(1)--裝飾器(decorator)
這個類接收一個飛機例項,並且重新實現了fire方法,在方法內部先呼叫原來例項的fire方法,接著擴充套件此方法,增加了發射導彈的功能。 類似的我們再實現一個發射原子彈的裝飾器。
前端常用設計模式(1)--裝飾器(decorator)
最後我們看一下應該如何使用這兩個裝飾器。
前端常用設計模式(1)--裝飾器(decorator)
可以看到,經過兩個裝飾器裝飾後的plane例項,再呼叫fire方法時,就可以同時發射三種子彈了。而裝飾器本身並沒有直接改寫Plane類,只是增強了它的fire方法,對plane例項的使用者也是透明的。 接下來我們看一看如何應用裝飾器在JS中實現AOP程式設計。 首先我們擴充套件一下函式的原型,讓每個函式都可以被裝飾。我們給函式增加一個before和after方法,這兩個方法各自接收一個新的函式,並保證新函式在原函式之前(before)或之後(after)執行。
前端常用設計模式(1)--裝飾器(decorator)
這裡需要注意的是新函式和原函式具有相同this和引數。 有了兩個方法,以前很多複雜的需求就變得很簡單了。

栗子一:掛載多個onload函式

通常情況下,window.onload只能掛載一個回撥函式,重複宣告回撥函式,後面的會把之前宣告的覆蓋掉,有了after以後,這個麻煩解決了。

前端常用設計模式(1)--裝飾器(decorator)

栗子二:日誌上報

前端常用設計模式(1)--裝飾器(decorator)

栗子三:追加(改變)引數

比如,為了增加安全性,給所有介面都增加一個token引數,如果不實用AOP,我們只能改ajax方法了。但是有了AOP,就可以像下面這樣操作。

前端常用設計模式(1)--裝飾器(decorator)
原理就是before函式和原函式接收相同的this和引數,並且before會在原函式之前執行。 其實AOP在前端專案中的應用場景還很多,比如校驗表單引數,異常處理,資料快取,本地持久化等,這裡不在一一舉例了。 有些同學對直接改寫函式的原型比較牴觸,這裡我們也給出函式式的before實現。
前端常用設計模式(1)--裝飾器(decorator)

六.ES7-@decorator語法

在JS未來的標準(ES7)中,裝飾器也已被加入到了提案中。 前端同學都知道jQuery最大的特點就是它鏈式呼叫的API設計,其核心是每個方法都返回this,也就是jQuery物件例項,我們不妨先實現一個高階函式,用於實現鏈式呼叫。

前端常用設計模式(1)--裝飾器(decorator)
fluent函式接收一個函式fn作為引數,返回一個新的函式,在新函式內部通過apply呼叫fn,並最終返回上下文this。有了這個函式,我們就可以很方便的給任意物件的方法新增鏈式呼叫。
前端常用設計模式(1)--裝飾器(decorator)
接下來,我們看看如何使用ES7的@decorator語法來簡化上面的程式碼,先來看一下結果。
前端常用設計模式(1)--裝飾器(decorator)
熟悉JAVA的同學一眼就看出這不是註解寫法麼,沒錯,ES7中的@decorator正是參考了Python和JAVA語法設計出來的。@後面的fluentDecorate是一個裝飾器函式,這個函式接收三個引數,分別是target,name和descriptor,這三個引數和Object.defineProperty方法的引數完全相同,實際上@decorator也正是這個方法的語法糖而已。 值得注意的是@decorator不止可以作用在物件或類的方法上面,還可以直接作用在類(class)上,區別是裝飾函式的第一個引數target不同,當作用在方法上時,target指向物件本身,而當作用在類時target指向類(class),並且name和descriptor都是undefined。 以下給出fluentDecorate函式的完整實現。
前端常用設計模式(1)--裝飾器(decorator)
通常我們可以把這個裝飾函式再抽象一下,讓他成為一個高階函式,可以接收我們最開始定義的fluent函式或者其他函式(比如截流函式等),然後返回一個用這個函式裝飾的新裝飾函式,更具有通用型。
前端常用設計模式(1)--裝飾器(decorator)
前端常用設計模式(1)--裝飾器(decorator)
@decorator到目前為止還只是個提案,沒有任何瀏覽器支援了這個語法,但是好在可以使用Babel以外掛(transform-decorators-legacy)的形式在自己的專案中體驗。 注意,@decorator只能作用於類和類的方法上,不能用於普通函式,因為函式存在變數提升,而類是不會提升的。

七.元件-裝飾器在React專案中的應用

最後結合目前前端最火的框架React,來看看裝飾器是如何在元件上使用的。 回到最開始的假設,如何開發出《荒野之息》這樣細節豐富的遊戲,下面我們就使用React搭配裝飾器來模擬一下游戲中的細節實現。 我們先實現一個Person元件,用來代指遊戲的主角,這個元件可以接收名字,生命值,攻擊類等初始化引數,並在一個卡片中展示這些引數,當生命值為0時,會提示“遊戲結束”。並且在卡片中放置一個“JUMP”按鈕,用點選按鈕模擬主角跳躍的互動。

前端常用設計模式(1)--裝飾器(decorator)
元件呼叫:
前端常用設計模式(1)--裝飾器(decorator)
實現結果如下,是不是很抽象?哈哈!
前端常用設計模式(1)--裝飾器(decorator)
接下來我們想要模擬遊戲中的天氣和溫度變化,需要實現一個“自然環境”的元件Natural,這個元件自身有天氣(wat)和溫度(tep)兩個狀態(state),並且可以通過輸入改變這兩個狀態,我們之前建立的Person元件作為後代插入這個元件中,並且接收Natural的wat和tep狀態作為屬性。
前端常用設計模式(1)--裝飾器(decorator)
好了,我們的實驗頁面就完成了,最終效果如下,上面可以通過進度條和單選按鈕改變天氣和溫度,改變後的結果通過props傳遞給遊戲主角。
前端常用設計模式(1)--裝飾器(decorator)
但是現在改變溫度和天氣對主角並不會造成任何影響,接下來我們想在不改變原有Person元件的前提下,實現兩個功能:第一,當溫度大於50度或者小於10度的時候,主角生命值慢慢下降;第二當天氣是雨天的時候,主角每跳躍3次就失敗1次。 先來實現第一個功能,溫度過高和過低時,主角生命值慢慢減少。我們的思路是實現一個裝飾器,用這個裝飾器在外部裝飾Person元件,使得這個元件可以感知溫度變化。先給出實現:
前端常用設計模式(1)--裝飾器(decorator)
仔細觀察decorateTep函式,它接收一個元件(A)作為引數,返回一個新的React元件(B),在B內部維護了一個hp和tep狀態 ,在tep處於臨界值時,改變B的hp,最後render時用B的hp代替原來的hp屬性傳遞給A元件。 這不是就是高階元件(HOC)麼?!沒錯,當裝飾器去裝飾一個元件時,它的實現和高階元件完全一致。通過返回一個新元件的方式去增強原有元件的能力,這也符合React提倡的元件組合的設計模式(注意不是mixin或者繼承),decorateTep的使用方法很簡單,一行程式碼搞定:
前端常用設計模式(1)--裝飾器(decorator)
接下來我們來實現第二個功能,下雨時跳躍會偶爾失敗,這裡我們換一個策略,不再裝飾Person元件,而是裝飾元件內部的onJump跳躍方法。程式碼如下:
前端常用設計模式(1)--裝飾器(decorator)
區別之前的decorateTep,這個decorateWat裝飾器的重點是第三個引數descriptor,之前提到,descriptor引數是被裝飾方法的描述物件,它的value屬性指向的就是原方法(onJump),這裡我們用變數method儲存原方法,同時使用i記錄點選次數,通過閉包延長這兩個變數的生命週期,最後實現一個新的方法代替原方法,在新方法內部通過apply呼叫原方法並重置變數i,注意decorateWat最後返回的是改變以後的descriptor物件。 經過裝飾器裝飾過的onJump方法如下:
前端常用設計模式(1)--裝飾器(decorator)
好了,接下來就是見證奇蹟的時刻!
前端常用設計模式(1)--裝飾器(decorator)

八.輪子-常用裝飾器庫

事實上現在已經有很多開源裝飾器的庫可以拿來使用,以下是質量較好的輪子,希望可以給大家提供幫助。 core-decorators lodash-decorators react-decoration

九.參考-相關資料閱讀

全部演示原始碼 五分鐘讓你明白為什麼塞爾達可以奪得年度遊戲 《荒野之息》中46個精彩的小細節 日亞上一位玩家對《荒野之息》的評價 面向切片程式設計 《JavaScript 設計模式與開發實踐》曾探;人民郵電出版社 《JavaScript 高階程式設計(第三版)》Zakas;人民郵電出版社 《ES 6 標準入門(第二版)》阮一峰;電子工業出版社 最後,如有不對的地方,歡迎各位小夥伴留言拍磚,你們的支援是我繼續的最大動力! 謝謝大家!

作者:TNFE 朱雀

前端常用設計模式(1)--裝飾器(decorator)

相關文章