適合JavaScript的設計原則

語言: CN / TW / HK

1.單一職責原則

一個類而言,應該僅有一個引起它變化的原因。在JavaScript中,單一職責原則更多地是被運用在物件或者方法級別上。

單一職責原則(SRP)的職責被定義為“引起變化的原因”。如果有兩個動機去改寫一個方法,那麼這個方法就具有兩個職責。每個職責都是變化的一個軸線,如果一個方法承擔了過多的職責,那麼在需求的變遷過程中,需要改寫這個方法的可能性就越大

此時,這個方法通常是一個不穩定的方法,修改程式碼是一件危險的事情,特別是當兩個職責耦合在一起的時候,一個職責發生變化可能會影響到其他職責的實現,造成意想不到的破壞,這種耦合性得到的是低內聚和脆弱的設計。

因此,SRP原則體現為:一個物件(方法)只做一件事情

1.1設計模式中的SRP原則

1.1.1 代理模式

通過增加虛擬代理的方式,把預載入圖片的職責放到代理物件中,而本體僅僅負責往頁面中新增img標籤,這也是它最原始的職責

``` js // myImage負責往頁面中新增img標籤 var myImage = (function(){ var imgNode = document.createElement('img'); document.body.appendChild(imgNode);

return {
    setSrc: function(src) {
        imgNode.src = src;
    }
}

})();

// proxyImage 負責預載入圖片,並在預載入完成之後把請求交給本體myImage var proxyImage = (function(){ var img = new Image; img.onload = function(){ myImage.setSrc(this.src); } return { setSrc: function(src) { myImage.setSrc('file//xxx/loading.gif') img.src = src; } } })()

proxyImage.setSrc('http:// xxx.png') ``` 把新增 img 標籤的功能和預載入圖片的職責分開放到兩個物件中,這兩個物件各自都只有一個被修改的動機。在它們各自發生改變的時候,也不會影響另外的物件。

詳細 代理模式

1.1.2 迭代器模式

下面這段程式碼,先遍歷一個集合,然後往頁面中新增一些div,這些div的innerHTML分別對應集合裡的元素

``` js var appendDiv = function(data){ for(var i=0, l=data.length; i<l; i++) { var div = document.createElement('div'); div.innerHTML = data[i]; document.body.appendChild(div) } };

appendDiv([1,2,3,4,5,6]) ``` appendDiv函式本來只是負責渲染資料,但是在這裡它還承擔了遍歷聚合物件data的職責。如果data的資料格式從array變成了object, 那遍歷data的程式碼就會出現問題。

有必要把遍歷data的職責提取出來,這正是迭代器模式的意義,迭代器模式提供了一種方法來訪問聚合物件,而不用暴露這個物件的內部表示

把迭代聚合物件的職責單獨封裝在each函式中後,即使以後還要增加新的迭代方法,只需要修改each函式即可,appendDiv函式不會受到牽連

``` js var each = function(obj, callback) { var value, i = 0, length = obj.length, isArray = isArraylike(obj);

if (isArray) { for(; i<length; i++) { callback.call(obj[i], i, obj[i]); } } else { for(i in obj) { value = callback.call(obj[i], i, obj[i]); } } return obj; }

var appendDiv = function(data){ each(data, function(i, n) { var div = document.createElement('div'); div.innerHTML = n; document.body.appendChild(div) } }; ```

詳細 迭代器模式

1.1.3 單例模式

詳細 單例模式

1.1.4 裝飾者模式

詳細 裝飾者模式

1.2 何時應該分離職責

SRP原則是所有原則中最簡單也是最難正確運用的原則之一

注: 並不是所有的職責都應該一一分離。

  • 如果隨著需求的變化,有兩個職責總是同時變化,那就不必分離他們。比如在ajax請求的時候,建立xhr物件和傳送xhr請求幾乎總是在一起的,那麼建立xhr物件的職責和傳送xhr請求的職責就沒有必要分開。

  • 職責的變化軸線僅當它們確定會發生變化時才具有意義,即使兩個職責已經被耦合在一起,但它們還沒有發生改變的徵兆,那麼也許沒有必要主動分離它們,在程式碼需要重構的時候再進行分離也不遲

1.3 違反SRP原則

人們總是習慣性地把一組相關的行為放到一起,如何正確地分離職責不是一件容易的事情

也許從來沒有考慮過如何分離職責,但這並不妨礙編寫程式碼完成需求。

一方面,受設計原則的指導,另一方面,未必要在任何時候都一成不變地遵守原則。在實際開發中,因為種種原因違反SRP的情況並不少見。比如Jquery的attr等方法,就是明顯違反SRP原則的做法。jQuery的attr,即負責賦值,又負責取值,這對於jQuery的維護者來說,會帶來一些困難,但對於jQuery的使用者來說,卻簡化了使用者的使用。

在方便性與穩定性之間要有一些取捨。具體是選擇方便性還是穩定性,並沒有標準答案,而是要取決於具體的應用環境。

1.4 SRP原則的優缺點

SRP原則的優點是降低了單個類或者物件的複雜度,按照職責把物件分解成更小的粒度,這有助於程式碼的複用,也有利於進行單元測試。當一個職責需要變更的時候,不會影響到其他的職責

單SRP原則也有一些缺點,最明顯的是會增加編寫程式碼的複雜度。當我們按照職責把物件分解成更小的粒度之後,實際上也增大了這些物件之間相互聯絡的難度

2.最少知識原則

最少知識原則(LKP): 一個軟體實體應當儘可能少地與其他實體發生相互作用。這裡的軟體實體是一個廣義的概念,不僅包括物件,還包括系統、類、模組、函式、變數等。

2.1 減少物件之間的聯絡

單一職責原則指導我們把物件劃分成較小的粒度,這可以提高物件的可複用性。但越來越多的物件之間可能會產生錯綜複雜的聯絡,如果修改了其中一個物件,很可能會影響到跟它相互引用的其他物件。物件和物件耦合在一起,很可能會降低它們的可複用性。

最少知識原則要求我們在設計程式時,應當儘量減少物件之間的互動。如果兩個物件之間不必彼此直接通訊,那麼這兩個物件就不要發生直接的相互聯絡。常見的做法是引入一個第三者物件,來承擔這些物件之間的通行作用。如果一些物件需要向另一些物件發起請求,可以通過第三者物件來轉發這些請求。

2.2 設計模式中的最少知識原則

最少知識原則在設計模式中體現最多的地方是中介者模式和外觀模式

2.2.1 中介者模式

中介者模式很好地體現了最少知識原則。通過增加一箇中介者物件,讓所有的相關物件都通 過中介者物件來通訊,而不是互相引用。所以,當一個物件發生改變時,只需要通知中介者物件即可。

2.2.2 外觀模式

外觀模式主要是為子系統中的一組介面提供一個一致的介面,外觀模式定義了一個高層介面,這個 介面使子系統更加容易使用

外觀模式的作用是對客戶遮蔽一組子系統的複雜性。外觀模式對客戶提供一個簡單易用的高 層介面,高層介面會把客戶的請求轉發給子系統來完成具體的功能實現。大多數客戶都可以通過 請求外觀介面來達到訪問子系統的目的。但在一段使用了外觀模式的程式中,請求外觀並不是強 制的。如果外觀不能滿足客戶的個性化需求,那麼客戶也可以選擇越過外觀來直接訪問子系統。

外觀模式容易跟普通的封裝實現混淆。這兩者都封裝了一些事物,但外觀模式的關鍵是定義 一個高層介面去封裝一組“子系統”。

外觀模式對映到 JavaScript 中,這個子系統至少應該指的是一組函式的集合

js var A = function(){ a1(); a2(); } var B = function(){ b1(); b2(); } var facade = function(){ A(); B(); } facade();

外觀模式的作用主要有兩點

  • 為一組子系統提供一個簡單便利的訪問入口。
  • 隔離客戶與複雜子系統之間的聯絡,客戶不用去了解子系統的細節。

從第二點來,外觀模式是符合最少知識原則的。

2.3 封裝在最少知識原則中的體現

封裝在很大程度上表達的是資料的隱藏。一個模組或者物件可以將內部的資料或者實現細 節隱藏起來,只暴露必要的介面 API 供外界訪問。物件之間難免產生聯絡,當一個物件必須引 用另外一個物件的時候,我們可以讓物件只暴露必要的介面,讓物件之間的聯絡限制在最小的 範圍之內。

同時,封裝也用來限制變數的作用域。在 JavaScript 中對變數作用域的規定是:

  • 變數在全域性宣告,或者在程式碼的任何位置隱式申明(不用 var),則該變數在全域性可見;
  • 變數在函式內顯式申明(使用 var),則在函式內可見。

把變數的可見性限制在一個儘可能小的範圍內,這個變數對其他不相關模組的影響就越小, 變數被改寫和發生衝突的機會也越小。這也是廣義的最少知識原則的一種體現。

3. 開放-封閉原則

在面向物件的程式設計中,開放-封閉原則(OCP)是最重要的一條原則。很多時候,一個 程式具有良好的設計,往往說明它是符合開放-封閉原則的。

3.1 擴充套件 window.onload 函式

``` js Function.prototype.after = function( afterfn ){ var __self = this; return function(){ var ret = __self.apply( this, arguments ); afterfn.apply( this, arguments ); return ret; } };

window.onload = ( window.onload || function(){} ).after(function(){ console.log( document.getElementsByTagName( '*' ).length ); }); ``` 通過動態裝飾函式的方式,我們完全不用理會從前 window.onload 函式的內部實現,無論它 的實現優雅或是醜陋。就算我們作為維護者,拿到的是一份混淆壓縮過的程式碼也沒有關係。只要 它從前是個穩定執行的函式,那麼以後也不會因為我們的新增需求而產生錯誤。新增的程式碼和原 有的程式碼可以井水不犯河水。

3.2 開放和封閉

開放封閉原則的思想:當需要改變一個程式的功能或者給這個程式增加新功 能的時候,可以使用增加程式碼的方式,但是不允許改動程式的原始碼。

3.3 用物件的多型性消除條件分支

過多的條件分支語句是造成程式違反開放-封閉原則的一個常見原因。每當需要增加一個新 的 if 語句時,都要被迫改動原函式。把 if 換成 switch-case 是沒有用的,這是一種換湯不換藥 的做法。實際上,每當我們看到一大片的 if 或者 swtich-case 語句時,第一時間就應該考慮,能 否利用物件的多型性來重構它們。

利用物件的多型性來讓程式遵守開放-封閉原則,是一個常用的技巧。

``` js var makeSound = function( animal ){ animal.sound(); };

var Duck = function(){};

Duck.prototype.sound = function(){ console.log( '嘎嘎嘎' ); };

var Chicken = function(){};

Chicken.prototype.sound = function(){ console.log( '咯咯咯' ); };

makeSound( new Duck() ); // 嘎嘎嘎 makeSound( new Chicken() ); // 咯咯咯

/* 增加動物狗,不用改動原有的 makeSound 函式 **/ var Dog = function(){}; Dog.prototype.sound = function(){ console.log( '汪汪汪' ); }; makeSound( new Dog() ); // 汪汪汪 ```

3.4 找出變化的地方

開放-封閉原則是一個看起來比較虛幻的原則,並沒有實際的模板教導我們怎樣亦步亦趨地 實現它。但我們還是能找到一些讓程式儘量遵守開放-封閉原則的規律,最明顯的就是找出程式 中將要發生變化的地方,然後把變化封裝起來。

通過封裝變化的方式,可以把系統中穩定不變的部分和容易變化的部分隔離開來。在系統的 演變過程中,我們只需要替換那些容易變化的部分,如果這些部分是已經被封裝好的,那麼替換 起來也相對容易。而變化部分之外的就是穩定的部分。在系統的演變過程中,穩定的部分是不需 要改變的。

除了利用物件的多型性之外,還有其他方式可以幫助我們編寫遵守開放-封閉原則的程式碼

3.4.1 放置掛鉤

放置掛鉤(hook)也是分離變化的一種方式。我們在程式有可能發生變化的地方放置一個掛 鉤,掛鉤的返回結果決定了程式的下一步走向。這樣一來,原本的程式碼執行路徑上就出現了一個 分叉路口,程式未來的執行方向被預埋下多種可能性。

由於子類的數量是無限制的,總會有一些“個性化”的子類迫使我們不得不去改變已經封裝 好的演算法骨架。於是我們可以在父類中的某個容易變化的地方放置掛鉤,掛鉤的返回結果由具體 子類決定。這樣一來,程式就擁有了變化的可能。

3.4.2 使用回撥函式

在 JavaScript 中,函式可以作為引數傳遞給另外一個函式,這是高階函式的意義之一。在這 種情況下,我們通常會把這個函式稱為回撥函式。在 JavaScript 版本的設計模式中,策略模式和 命令模式等都可以用回撥函式輕鬆實現。

回撥函式是一種特殊的掛鉤。我們可以把一部分易於變化的邏輯封裝在回撥函式裡,然後把 回撥函式當作引數傳入一個穩定和封閉的函式中。當回撥函式被執行的時候,程式就可以因為回 調函式的內部邏輯不同,而產生不同的結果。

3.5 設計模式中的開放-封閉原則

幾乎所有的設計模式都是遵守開放-封閉原則的,的,我們見到的好設計,通常都經得起開放-封閉原則的考驗。不管是具體的各種設計 模式,還是更抽象的面向物件設計原則,比如單一職責原則、最少知識原則、依賴倒置原則等, 都是為了讓程式遵守開放-封閉原則而出現的。可以這樣說,開放-封閉原則是編寫一個好程式的 目標,其他設計原則都是達到這個目標的過程。

3.5.1 釋出-訂閱模式

釋出訂閱模式用來降低多個物件之間的依賴關係,它可以取代物件之間硬編碼的通知機制, 一個物件不用再顯式地呼叫另外一個物件的某個介面。當有新的訂閱者出現時,釋出者的程式碼不 需要進行任何修改;同樣當釋出者需要改變時,也不會影響到之前的訂閱者。

3.5.2 模板方法模式

模板方法模式是一種典型的通過封裝變化來提高系統擴充套件性的設計模式。在一個運用了模板方法模式的程式中,子類的方法種類和執行順序都是不變的,所以 我們把這部分邏輯抽出來放到父類的模板方法裡面;而子類的方法具體怎麼實現則是可變的,於 是把這部分變化的邏輯封裝到子類中。通過增加新的子類,便能給系統增加新的功能,並不需要 改動抽象父類以及其他的子類,這也是符合開放-封閉原則的。

3.5.3. 策略模式

策略模式和模板方法模式是一對競爭者。在大多數情況下,它們可以相互替換使用。模板方 法模式基於繼承的思想,而策略模式則偏重於組合和委託。

策略模式將各種演算法都封裝成單獨的策略類,這些策略類可以被交換使用。策略和使用策略 的客戶程式碼可以分別獨立進行修改而互不影響。我們增加一個新的策略類也非常方便,完全不用 修改之前的程式碼。

3.5.4 代理模式

拿預載入圖片舉 例,我們現在已有一個給圖片設定 src 的函式 myImage,當我們想為它增加圖片預載入功能時, 一種做法是改動 myImage 函式內部的程式碼,更好的做法是提供一個代理函式 proxyMyImage,代理 函式負責圖片預載入,在圖片預載入完成之後,再將請求轉交給原來的 myImage 函式,myImage 在 這個過程中不需要任何改動。

預載入圖片的功能和給圖片設定 src 的功能被隔離在兩個函式裡,它們可以單獨改變而互不 影響。myImage 不知曉代理的存在,它可以繼續專注於自己的職責——給圖片設定 src。

3.5.5 職責鏈模式

把一個巨大的訂單函式分別拆成了 500 元訂單、 200 元訂單以及普通訂單的 3 個函式。這 3 個函式通過職責鏈連線在一起,客戶的請求會在這條 鏈條裡面依次傳遞:

``` js var order500yuan = new Chain(function( orderType, pay, stock ){ // 具體程式碼略 });

var order200yuan = new Chain(function( orderType, pay, stock ){ // 具體程式碼略 });

var orderNormal = new Chain(function( orderType, pay, stock ){ // 具體程式碼略 });

order500yuan.setNextSuccessor( order200yuan ).setNextSuccessor( orderNormal ); order500yuan.passRequest( 1, true, 10 ); // 500 元定金預購,得到 100 優惠券 ```

可以看到,當我們增加一個新型別的訂單函式時,不需要改動原有的訂單函式程式碼,只需要 在鏈條中增加一個新的節點。

3.6 開放-封閉原則的相對性

在職責鏈模式程式碼中,大家也許會產生這個疑問:開放封閉原則要求我們只能通過增加源 程式碼的方式擴充套件程式的功能,而不允許修改原始碼。那當我們往職責鏈中增加一個新的 100 元訂 單函式節點時,不也必須改動設定鏈條的程式碼嗎?程式碼如下:

``` js order500yuan.setNextSuccessor( order200yuan ).setNextSuccessor( orderNormal );

變為:

order500yuan.setNextSuccessor( order200yuan ).setNextSuccessor( order100yuan ).setNextSuccessor( orderNormal ); ```

實際上,讓程式保持完全封閉是不容易做到的。就算技術上做得到,也需要花費太多的時間 和精力。而且讓程式符合開放-封閉原則的代價是引入更多的抽象層次,更多的抽象有可能會增 大程式碼的複雜度。

更何況,有一些程式碼是無論如何也不能完全封閉的,總會存在一些無法對其封閉的變化。

  • 挑選出最容易發生變化的地方,然後構造抽象來封閉這些變化。
  • 在不可避免發生修改的時候,儘量修改那些相對容易修改的地方。拿一個開源庫來說, 修改它提供的配置檔案,總比修改它的原始碼來得簡單。