前端常用設計模式初探
設計模式一直是程序員談論的“高端”話題之一,總有一種敬而遠之的心態(tài)。在了解后才知道在將函數(shù)作為一等對象的語言中,有許多需要利用對象多態(tài)性的設計模式,比如單例模式、 策略模式等,這些模式的結構與傳統(tǒng)面向對象語言的結構大相徑庭,實際上已經(jīng)融入到了語言之中,我們可能經(jīng)常使用它們,只是不知道它們的名字而已。
設計模式
相信了解的,都知道有 20 多種...
其中按類型分有三種。為“創(chuàng)建型”封裝了創(chuàng)建對象的變化過程,“結構型”將對象之間組合的變化封裝,“行為型”則是抽離對象的變化行為。
接下來,本文將以常用原則中從“單一功能”和“開放封閉”這兩大原則為主線,分別介紹“創(chuàng)建型”、“結構型”和“行為型”中最具代表性的單例、策略、代理、觀察者這幾大設計模式。
1 常用原則
這些設計原則通常指的是單一職責原則、里氏替換原則、依賴倒置原則、接口隔離原則、合成復用原則和最少知識原則。因為案例中涉及單一職責原則和開放-封閉原則,所以只介紹這兩部分。
1.1 單一職責原則(SRP)
一個對象(方法)只做一件事情
如果我們有兩個動機去改寫一個方法,那么這個方法就具有兩個職責。每個職責都是變化的一個軸線,如果一個方法承擔了過多的職責,那么在需求的變遷過程中,需要改寫這個方法的可能性就越大。
SRP 原則的優(yōu)點是降低了單個類或者對象的復雜度,按照職責把對象分解成更小的粒度, 這有助于代碼的復用,也有利于進行單元測試。當一個職責需要變更的時候,不會影響到其他的職責。
但 SRP 原則也有一些缺點,最明顯的是會增加編寫代碼的復雜度。當我們按照職責把對象分解成更小的粒度之后,實際上也增大了這些對象之間相互聯(lián)系的難度。
1.2 開放-封閉原則
對象(類、模塊、函數(shù)等)應該是可擴展但不可修改的。
就算我們作為維護者,拿到的是一份混淆壓縮過的代碼也沒有關系。只要它從前是個穩(wěn)定運行的函數(shù),那么以后也不會因為我們的新增需求而產(chǎn)生錯誤。新增的代碼和原有的代碼可以井水不犯河水。
2 單例模式
單例模式 (Singleton Pattern)又稱為單體模式,保證一個類只有一個實例,并提供一個訪問它的全局訪問點。也就是說,第二次使用同一個類創(chuàng)建新對象的時候,應該得到與第一次創(chuàng)建的對象完全相同的對象。
2.1 舉個 登錄彈窗
我們正在開發(fā)一個網(wǎng)站,網(wǎng)站類型是一個視頻網(wǎng)站,網(wǎng)站有個登錄按鈕,點擊登錄會彈出一個登錄框進行登錄,你現(xiàn)在可能已經(jīng)聯(lián)想到,這個登錄框一定是頁面唯一的一個 dom 節(jié)點,一個頁面存在兩個登錄框是不存在的!
如果要實現(xiàn)這種效果第一種解決方案就是在頁面加載的時候就已經(jīng)創(chuàng)建好 dom 節(jié)點,并且設置樣式為 display 為 none,當點擊登錄時修改為 block 顯示。
這種方式有一個問題,也許我們進入當前網(wǎng)站只是玩玩游戲或者看看天氣,根本不需要進行登錄操作,因為登錄浮窗總是一開始就被創(chuàng)建好,那么很有可能將白白浪費一些 DOM 節(jié)點。所以開始改進,當我們每次點擊登錄按鈕的時候,再創(chuàng)建一個新的登錄浮窗 div。
雖然我們可以在點擊浮窗上的關閉按鈕時(此處未實現(xiàn))把這個浮窗從頁面中刪除掉,但這樣頻繁地創(chuàng)建和刪除節(jié)點明顯是不合理的,也是不必要的。所以我們再次進行改進,用一個變量來判斷是否已經(jīng)創(chuàng)建過登錄浮窗。
這段代碼仍然是違反單一職責原則(就一個類(通常也包括對象和函數(shù)等)而言,應該僅有一個引起它變化的原因)的,創(chuàng)建對象和管理單例的邏輯都放在 createLoginLayer 對象內(nèi)部。所以將管理單例的邏輯單獨提出來。
用于創(chuàng)建登錄浮窗的方法用參數(shù) fn 的形式傳入 getSingle,我們不僅可以傳入 createLoginLayer,還能傳入 createScript、createIframe、createXhr 等。之后再讓 getSingle 返回 一個新的函數(shù),并且用一個變量 result 來保存 fn 的計算結果。result 變量因為身在閉包中,它永遠不會被銷毀。在將來的請求中,如果 result 已經(jīng)被賦值,那么它將返回這個值。
創(chuàng)建實例對象的職責和管理單例的職責分別放置在兩個方法里,這兩個方法可以獨立變化而互不影響。
所以,在適合的時候才創(chuàng)建對象,并且只創(chuàng)建唯一的一個,如果創(chuàng)建對象和管理創(chuàng)建單例職責分布在兩個不同的方法當中,解耦性的加持會讓這個模式威力大大增加,這是能提高性能的一個突破口。
2.2 其他場景
使用場景:Redux、Vuex 等狀態(tài)管理工具,還有我們常用的 window 對象、全局緩存等。
- 多次引用只會使用一個庫引用,如 jQuery,lodash,moment 等。
- Vuex / Redux。Vuex 和 Redux 數(shù)據(jù)保存在單一 store 中,Mobx 將數(shù)據(jù)保存在分散的多個 store 中。
2.3 小結
在 getSinge 函數(shù)中,實際上也提到了閉包和高階函數(shù)的概念。單例模式是一種簡單但非常實用的模式,考慮在合適的時候才創(chuàng)建對象,并且只創(chuàng)建唯一的一個。創(chuàng)建實例對象的職責和管理單例的職責分別放置在兩個方法里,這兩個方法可以獨立變化而互不影響。
3 代理模式
代理模式的定義:代理模式給某一個對象提供一個代理對象,并由代理對象控制對原對象的引用。通俗的來講代理模式就是我們生活中常見的中介。
為什么要使用代理模式
中介隔離作用:在某些情況下,一個客戶類不想或者不能直接引用一個委托對象,而代理類對象可以在客戶類和委托對象之間起到中介的作用,其特征是代理類和委托類實現(xiàn)相同的接口。
開閉原則,增加功能:代理類除了是客戶類和委托類的中介之外,我們還可以通過給代理類增加額外的功能來擴展委托類的功能,這樣做我們只需要修改代理類而不需要再修改委托類,符合代碼設計的開閉原則。
3.1 舉個 圖片預加載
圖片預加載是一種常用的技術,如果直接給某個 img 標簽節(jié)點設置 src 屬性, 由于圖片過大或者網(wǎng)絡不佳,圖片的位置往往有段時間會是一片空白。常見的做法是先用一張 loading 圖片占位,然后用異步的方式加載圖片,等圖片加載好了再把它填充到 img 節(jié)點里,這種場景就很適合使用虛擬代理。
但這里可以看到 MyImage 這個對象承擔了多項職責,就意味著這個對象將變得巨大,引起它變化的原因可能會有 2 個。當系統(tǒng)需求發(fā)生改變時,盡量不修改系統(tǒng)原有代碼功能,應該擴展模塊的功能,來實現(xiàn)新的需求。
所以 5 年后的網(wǎng)速快到根本不再需要預加載,我們可能希望把預加載圖片的這段代碼從 MyImage 對象里刪掉。這時候就不得不改動 MyImage 對象了。所以對于上述代碼進行優(yōu)化。
這里通過 proxyImage 間接地訪問 MyImage。proxyImage 控制了客戶對 MyImage 的訪問,并且在此過程中加入一些額外的操作,比如在真正的圖片加載好之前,先把 img 節(jié)點的 src 設置為 一張本地的 loading 圖片,避免了在圖片被加載好之前,頁面中有一段長長的空白時間。
實際上,我們需要的只是給 img 節(jié)點設置 src,預加載圖片只是一個錦上添花的功能。如果 能把這個操作放在另一個對象里面,自然是一個非常好的方法。于是代理的作用在這里就體現(xiàn)出 來了,代理負責預加載圖片,預加載的操作完成之后,把請求重新交給本體 MyImage。既滿足了單一職責原則,又滿足了開放封閉原則。
3.2 再舉個 合并請求
在 Web 開發(fā)中,也許最大的開銷就是網(wǎng)絡請求。假設我們在做一個文件同步的功能,當我們選中一個 checkbox 的時候,它對應的文件就會被同步到另外一臺備用服務器上面,如圖所示。
我們先在頁面中放置好這些 checkbox 節(jié)點,接下來,給這些 checkbox 綁定點擊事件,并且在點擊的同時往另一臺服務器同步文件。
當我們選中 3 個 checkbox 的時候,依次往服務器發(fā)送了 3 次同步文件的請求。而點擊一個 checkbox 并不是很復雜的操作,但如果有 100 個文件,1w 個文件,可以預見,如此頻繁的網(wǎng)絡請求將會帶來相當大的開銷。
解決方案是,我們可以通過一個代理函數(shù) proxySynchronousFile 來收集一段時間之內(nèi)的請求,最后一次性發(fā)送給服務器。比如我們等待 2 秒之后才把這 2 秒之內(nèi)需要同步的文件 ID 打包發(fā)給服務器,如果不是對實時性要求非常高的系統(tǒng),2 秒的延遲不會帶來太大副作用,卻能大大減輕服務器的壓力。
3.3 小結
縱觀圖片預加載整個程序,我們并沒有改變或者增加 MyImage 的接口,但是通過代理對象,實際上給系統(tǒng)添加了新的行為。這是符合開放—封閉原則的。給 img 節(jié)點設置 src 和圖片預加載這兩個功能,被隔離在兩個對象里,它們可以各自變化而不影響對方。何況就算有一天我們不再需要預加載,那么只需要改成請求本體而不是請求代理對象即可。
代理類主要負責為委托類預處理消息、過濾消息、把消息轉發(fā)給委托類,以及事后對返回結果的處理等。代理類本身并不真正實現(xiàn)服務,而是同過調(diào)用委托類的相關方法,來提供特定的服務。真正的業(yè)務功能還是由委托類來實現(xiàn),但是可以在業(yè)務功能執(zhí)行的前后加入一些公共的服務。例如我們想給項目加入緩存、日志這些功能,我們就可以使用代理類來完成,而沒必要打開已經(jīng)封裝好的委托類。
雖然代理模式非常有用,但我們在編寫業(yè)務代碼的時候,往往不需要去預先猜測是否需要使用代理模式。當真正發(fā)現(xiàn)不方便直接訪問某個對象的時候,再編寫代理也不遲。
4 策略模式
該模式定義了一系列算法,并將每個算法封裝起來,使它們可以相互替換,且算法的變化不會影響使用算法的客戶。
4.1 舉個 多條件業(yè)務
多業(yè)務場景下的訂單跳轉一直是個很頭疼的問題,因為每條業(yè)務的訂單可能需要定制,跳轉的詳情也可能不太一樣,當業(yè)務線過多的時候,很容易陷入多重條件地獄,不斷去累加判斷條件。
如上這種,雖然是看起來條件很多但是屬于單條件,我們可以使用策略模式來簡單改造,如下所示:
但是我們的業(yè)務可能會更加復雜,訂單頁面我們采用了 h5 嵌入其他應用的模式,我們可能將此業(yè)務嵌入快應用、RN、原生 App、小程序、h5 等各種環(huán)境里面,展示的內(nèi)容以及路由跳轉可能都不盡相同,我們?yōu)榱嗽黾与y度,更為直觀的體現(xiàn),所以每種對應的規(guī)則都默認是完全不同的方法。
看到上述代碼,可能人生已經(jīng)絕望,因為實際中的訂單類型遠不止 3 種,環(huán)境類型也遠不止 3 種,然而還可能有更多的附件條件并沒有加上去。且越來越多的條件加入的同時,造成代碼的可讀性、可維護性、可迭代性急速下降,雖然上述代碼格式化之后,看起來倒還是很工整的。
雖然上述是多重嵌套條件,但拆分開來還是可以理解為訂單類型跟環(huán)境類型的組合,我們借助 es6 map 對象來進行改造。
如上述重構后的,我們借助 map 對象的特性(此處不對 map 對象做更深的拓展講解)。如果再有新增的規(guī)則,我們可以放在 map 里面進行新增對應規(guī)則與方法,減少條件嵌套地獄出現(xiàn),并且邏輯會更加清晰。但實際情況中還可以對類似的方法進行合并,邏輯會更加清晰。
4.2 再舉個 表單校驗
在一個 Web 項目中,注冊、登錄、修改用戶信息等功能的實現(xiàn)都離不開提交表單。
在將用戶輸入的數(shù)據(jù)交給后臺之前,常常要做一些客戶端力所能及的校驗工作,比如注冊的時候需要校驗是否填寫了用戶名,密碼的長度是否符合規(guī)定等等。這樣可以避免因為提交不合法數(shù)據(jù)而帶來的不必要網(wǎng)絡開銷。
假設我們正在編寫一個注冊的頁面,在點擊注冊按鈕之前,有如下幾條校驗邏輯。
- 用戶名不能為空。
- 密碼長度不能少于 6 位。
- 手機號碼必須符合格式。
傳統(tǒng)編寫表單校驗
這是一種很常見的代碼編寫方式,它的缺點有以下。
- registerForm.onsubmit 函數(shù)比較龐大,包含了很多 if-else 語句,這些語句需要覆蓋所有的校驗規(guī)則。
- registerForm.onsubmit 函數(shù)缺乏彈性,如果增加了一種新的校驗規(guī)則,或者想把密碼的長度校驗從 6 改成 8,我們都必須深入 registerForm.onsubmit 函數(shù)的內(nèi)部實現(xiàn),這是違反開放—封閉原則的。
- 算法的復用性差,如果在程序中增加了另外一個表單,這個表單也需要進行一些類似的校驗,那我們很可能將這些校驗邏輯復制得漫天遍野。
下面我們將用策略模式來重構表單校驗的代碼,很顯然第一步我們要把這些校驗邏輯都封裝成策略對象。
4.3 小結
通過使用策略模式重構代碼,我們消除了原程序中大片的條件分支語句,代碼變得更加清晰,各個類的職責更加鮮明。一般策略對象往往被函數(shù)所代替,這時策略模式就成為一種“隱形”的模式。把這些校驗邏輯都封裝成策略對象,也可以復用在系統(tǒng)的其他地方,從而避免許多重復的復制粘貼工作。
5 觀察者模式
觀察者模式建立了一套觸發(fā)機制,幫助我們完成更松耦合的代碼編寫。
它定義對象間的一種一對多的依賴關系,當一個對象的狀態(tài)發(fā)生改變時,所有依賴于它的對象都將得到通知。
5.1 舉個 網(wǎng)站登錄
假如我們正在開發(fā)一個商城網(wǎng)站,網(wǎng)站里有 header 頭部、nav 導航、消息列表、購物車等模塊。這幾個模塊的渲染有一個共同的前提條件,就是必須先用 ajax 異步請求獲取用戶的登錄信息。這是很正常的,比如用戶的名字和頭像要顯示在 header 模塊里,而這兩個字段都來自用戶登錄后返回的信息。
現(xiàn)在登錄模塊是我們負責編寫的,但我們還必須了解 header 模塊里設置頭像的方法叫 setAvatar、購物車模塊里刷新的方法叫 refresh,這種耦合性會使程序變得僵硬,header 模塊不能隨意再改變 setAvatar 的方法名,它自身的名字也不能被改為 header1、header2。
等到有一天,項目中又新增了一個收貨地址管理的模塊,這個模塊本來是另一個同事所寫的, 而此時你正在度假,但是他卻不得不給你打電話:“Hi,登錄之后麻煩刷新一下收貨地址列表。”于是你又翻開你 3 個月前寫的登錄模塊,在最后部分加上這行代碼。
用觀察者模式重寫之后,對用戶信息感興趣的業(yè)務模塊將自行訂閱登錄成功的消息事件。當?shù)卿洺晒r,登錄模塊只需要發(fā)布登錄成功的消息,而業(yè)務方接受到消息之后,就會開始進行各自的業(yè)務處理,登錄模塊并不關心業(yè)務方究竟要做什么,也不想去了解它們的內(nèi)部細節(jié)。
我們隨時可以把 setAvatar 的方法名改成 setTouxiang。如果有一天在登錄完成之后,又增加一個刷新收貨地址列表的行為,那么只要在收貨地址模塊里加上監(jiān)聽消息的方法即可。
5.2 小結
觀察者模式的優(yōu)點非常明顯,一為時間上的解耦,二為對象之間的解耦。它的應用非常廣泛,既可以用在異步編程中,也可以幫助我們完成更松耦合的代碼編寫。但是創(chuàng)建訂閱者本身要消耗一定的時間和內(nèi)存,而且當你訂閱一個消息后,也許此消息最后都未發(fā)生,但這個訂閱者會始終存在于內(nèi)存中。另外,觀察者模式雖然可以弱化對象之間的聯(lián)系,但如果過度使用的話,對象和對象之間的必要聯(lián)系也將被深埋在背后,會導致程序難以跟蹤維護和理解。
6 總結
烹飪有菜譜,游戲有攻略,干啥都有一些能夠讓我們達到目標的“套路”,在程序世界,編程的“套路”就是設計模式。
前端常用的設計模式出從單例、代理、策略、觀察者模式入手,帶大家去了解設計模式的核心操作是去觀察你整個邏輯里面的變與不變,然后將變與不變分離,達到使變化的部分靈活、不變的地方穩(wěn)定的目的。在我們遇到相似的問題、場景時,能快速找到更優(yōu)的方式解決。
作者:京東零售 李毛毛