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

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

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


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

就是這些細節讓這個遊戲世界顯的無比真實又有趣。

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

作爲程序猿,玩遊戲之餘不禁會思考,這樣的遊戲代碼應該如何設計編寫?
比如“攀爬”這個動作,需要判斷攀爬的位置,林克的裝備(有些裝備能讓你爬的更快),當時的天氣,林克的體力等等衆多條件,裏面肯定參雜的無數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中改變一個對象再簡單不過了。

得力於JS是一門基於原型的弱類型語言,給對象添加或修改功能都十分容易,因此傳統的面向對象中的裝飾器模式在JS中的應用並不太多(ES6正式提出class以後場景有所增加)。
我們先簡單模擬一下面向對象中的裝飾器模式。
假設我們要開發一個飛機大戰的遊戲,飛機可以切換裝備的武器,發射不同的子彈。

我們先實現一個飛機的類,並實現一個fire方法。
接着,我們實現一個發射導彈的裝飾器類

這個類接收一個飛機實例,並且重新實現了fire方法,在方法內部先調用原來實例的fire方法,接着擴展此方法,增加了發射導彈的功能。
類似的我們再實現一個發射原子彈的裝飾器。

最後我們看一下應該如何使用這兩個裝飾器。

可以看到,經過兩個裝飾器裝飾後的plane實例,再調用fire方法時,就可以同時發射三種子彈了。而裝飾器本身並沒有直接改寫Plane類,只是增強了它的fire方法,對plane實例的使用者也是透明的。
接下來我們看一看如何應用裝飾器在JS中實現AOP編程。
首先我們擴展一下函數的原型,讓每個函數都可以被裝飾。我們給函數增加一個before和after方法,這兩個方法各自接收一個新的函數,並保證新函數在原函數之前(before)或之後(after)執行。

這裏需要注意的是新函數和原函數具有相同this和參數。
有了兩個方法,以前很多複雜的需求就變得很簡單了。

栗子一:掛載多個onload函數

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

栗子二:日誌上報

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

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

原理就是before函數和原函數接收相同的this和參數,並且before會在原函數之前執行。
其實AOP在前端項目中的應用場景還很多,比如校驗表單參數,異常處理,數據緩存,本地持久化等,這裏不在一一舉例了。
有些同學對直接改寫函數的原型比較抵觸,這裏我們也給出函數式的before實現。

六.ES7-@decorator語法

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

fluent函數接收一個函數fn作爲參數,返回一個新的函數,在新函數內部通過apply調用fn,並最終返回上下文this。有了這個函數,我們就可以很方便的給任意對象的方法添加鏈式調用。

接下來,我們看看如何使用ES7的@decorator語法來簡化上面的代碼,先來看一下結果。

熟悉JAVA的同學一眼就看出這不是註解寫法麼,沒錯,ES7中的@decorator正是參考了Python和JAVA語法設計出來的。@後面的fluentDecorate是一個裝飾器函數,這個函數接收三個參數,分別是target,name和descriptor,這三個參數和Object.defineProperty方法的參數完全相同,實際上@decorator也正是這個方法的語法糖而已。
值得注意的是@decorator不止可以作用在對象或類的方法上面,還可以直接作用在類(class)上,區別是裝飾函數的第一個參數target不同,當作用在方法上時,target指向對象本身,而當作用在類時target指向類(class),並且name和descriptor都是undefined。
以下給出fluentDecorate函數的完整實現。

通常我們可以把這個裝飾函數再抽象一下,讓他成爲一個高階函數,可以接收我們最開始定義的fluent函數或者其他函數(比如截流函數等),然後返回一個用這個函數裝飾的新裝飾函數,更具有通用型。


@decorator到目前爲止還只是個提案,沒有任何瀏覽器支持了這個語法,但是好在可以使用Babel以插件(transform-decorators-legacy)的形式在自己的項目中體驗。
注意,@decorator只能作用於類和類的方法上,不能用於普通函數,因爲函數存在變量提升,而類是不會提升的。

七.組件-裝飾器在React項目中的應用

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

組件調用:

實現結果如下,是不是很抽象?哈哈!

接下來我們想要模擬遊戲中的天氣和溫度變化,需要實現一個“自然環境”的組件Natural,這個組件自身有天氣(wat)和溫度(tep)兩個狀態(state),並且可以通過輸入改變這兩個狀態,我們之前創建的Person組件作爲後代插入這個組件中,並且接收Natural的wat和tep狀態作爲屬性。

好了,我們的實驗頁面就完成了,最終效果如下,上面可以通過進度條和單選按鈕改變天氣和溫度,改變後的結果通過props傳遞給遊戲主角。

但是現在改變溫度和天氣對主角並不會造成任何影響,接下來我們想在不改變原有Person組件的前提下,實現兩個功能:第一,當溫度大於50度或者小於10度的時候,主角生命值慢慢下降;第二當天氣是雨天的時候,主角每跳躍3次就失敗1次。
先來實現第一個功能,溫度過高和過低時,主角生命值慢慢減少。我們的思路是實現一個裝飾器,用這個裝飾器在外部裝飾Person組件,使得這個組件可以感知溫度變化。先給出實現:

仔細觀察decorateTep函數,它接收一個組件(A)作爲參數,返回一個新的React組件(B),在B內部維護了一個hp和tep狀態 ,在tep處於臨界值時,改變B的hp,最後render時用B的hp代替原來的hp屬性傳遞給A組件。
這不是就是高階組件(HOC)麼?!沒錯,當裝飾器去裝飾一個組件時,它的實現和高階組件完全一致。通過返回一個新組件的方式去增強原有組件的能力,這也符合React提倡的組件組合的設計模式(注意不是mixin或者繼承),decorateTep的使用方法很簡單,一行代碼搞定:

接下來我們來實現第二個功能,下雨時跳躍會偶爾失敗,這裏我們換一個策略,不再裝飾Person組件,而是裝飾組件內部的onJump跳躍方法。代碼如下:

區別之前的decorateTep,這個decorateWat裝飾器的重點是第三個參數descriptor,之前提到,descriptor參數是被裝飾方法的描述對象,它的value屬性指向的就是原方法(onJump),這裏我們用變量method保存原方法,同時使用i記錄點擊次數,通過閉包延長這兩個變量的生命週期,最後實現一個新的方法代替原方法,在新方法內部通過apply調用原方法並重置變量i,注意decorateWat最後返回的是改變以後的descriptor對象。
經過裝飾器裝飾過的onJump方法如下:

好了,接下來就是見證奇蹟的時刻!

八.輪子-常用裝飾器庫

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

九.參考-相關資料閱讀

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章