就一個(gè)類而言,應(yīng)該僅有一個(gè)引起它變化的原因。在JavaScript中,需要用到類的場景并不太多,單一職責(zé)原則更多地是被運(yùn)用在對象或者方法級別上,因此本節(jié)我們的討論大多基于對象和方法。
單一職責(zé)原則(SRP)的職責(zé)被定義為“引起變化的原因”。如果我們有兩個(gè)動機(jī)去改寫一個(gè)方法,那么這個(gè)方法就具有兩個(gè)職責(zé)。每個(gè)職責(zé)都是變化的一個(gè)軸線,如果一個(gè)方法承擔(dān)了過多的職責(zé),那么在需求的變遷過程中,需要改寫這個(gè)方法的可能性就越大。
此時(shí),這個(gè)方法通常是一個(gè)不穩(wěn)定的方法,修改代碼總是一件危險(xiǎn)的事情,特別是當(dāng)兩個(gè)職責(zé)耦合在一起的時(shí)候,一個(gè)職責(zé)發(fā)生變化可能會影響到其他職責(zé)的實(shí)現(xiàn),造成意想不到的破壞,這種耦合性得到的是低內(nèi)聚和脆弱的設(shè)計(jì)。
因此,SRP原則體現(xiàn)為:一個(gè)對象(方法)只做一件事情。
設(shè)計(jì)模式中的SRP原則
SRP原則在很多設(shè)計(jì)模式中都有著廣泛的運(yùn)用,例如代理模式、迭代器模式、單例模式和裝飾者模式。
1. 代理模式
我們在之前已經(jīng)見過這個(gè)圖片預(yù)加載的例子了。通過增加虛擬代理的方式,把預(yù)加載圖片的職責(zé)放到代理對象中,而本體僅僅負(fù)責(zé)往頁面中添加img標(biāo)簽,這也是它最原始的職責(zé)。
myImage負(fù)責(zé)往頁面中添加img標(biāo)簽:
var myImage = (function(){
var imgNode = document.createElement( 'img' );
document.body.appendChild( imgNode );
return {
setSrc: function( src ){
imgNode.src = src;
}
}
})();
proxyImage負(fù)責(zé)預(yù)加載圖片,并在預(yù)加載完成之后把請求交給本體myImage:
var proxyImage = (function(){
var img = new Image;
img.onload = function(){
myImage.setSrc( this.src );
}
return {
setSrc: function( src ){
myImage.setSrc( 'file:// /C:/Users/svenzeng/Desktop/loading.gif' );
img.src = src;
}
}
})();
proxyImage.setSrc( 'http:// imgcache.qq.com/music/photo/000GGDys0yA0Nk.jpg' );
把添加img標(biāo)簽的功能和預(yù)加載圖片的職責(zé)分開放到兩個(gè)對象中,這兩個(gè)對象各自都只有一個(gè)被修改的動機(jī)。在它們各自發(fā)生改變的時(shí)候,也不會影響另外的對象。
2. 迭代器模式
我們有這樣一段代碼,先遍歷一個(gè)集合,然后往頁面中添加一些div,這些div的innerHTML分別對應(yīng)集合里的元素:
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 ] );
這其實(shí)是一段很常見的代碼,經(jīng)常用于ajax請求之后,在回調(diào)函數(shù)中遍歷ajax請求返回的數(shù)據(jù),然后在頁面中渲染節(jié)點(diǎn)。
appendDiv函數(shù)本來只是負(fù)責(zé)渲染數(shù)據(jù),但是在這里它還承擔(dān)了遍歷聚合對象data的職責(zé)。我們想象一下,如果有一天cgi返回的data數(shù)據(jù)格式從array變成了object,那我們遍歷data的代碼就會出現(xiàn)問題,必須改成 for ( var i in data )的方式,這時(shí)候必須去修改appendDiv里的代碼,否則因?yàn)楸闅v方式的改變,導(dǎo)致不能順利往頁面中添加div節(jié)點(diǎn)。
我們有必要把遍歷data的職責(zé)提取出來,這正是迭代器模式的意義,迭代器模式提供了一種方法來訪問聚合對象,而不用暴露這個(gè)對象的內(nèi)部表示。
當(dāng)把迭代聚合對象的職責(zé)單獨(dú)封裝在each函數(shù)中后,即使以后還要增加新的迭代方式,我們只需要修改each函數(shù)即可,appendDiv函數(shù)不會受到牽連,代碼如下:
var each = function( obj, callback ) {
var value,
i = 0,
length = obj.length,
isArray = isArraylike( obj ); // isArraylike函數(shù)未實(shí)現(xiàn),可以翻閱jQuery源代碼
if ( isArray ) { // 迭代類數(shù)組
for ( ; i < length; i++ ) {
callback.call( obj[ i ], i, obj[ i ] );
}
} else {
for ( i in obj ) { // 迭代object對象
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 );
});
};
appendDiv( [ 1, 2, 3, 4, 5, 6 ] );
appendDiv({a:1,b:2,c:3,d:4} );
3. 單例模式
之前曾實(shí)現(xiàn)過一個(gè)惰性單例,最開始的代碼是這樣的:
var createLoginLayer = (function(){
var div;
return function(){
if ( !div ){
div = document.createElement( 'div' );
div.innerHTML = '我是登錄浮窗';
div.style.display = 'none';
document.body.appendChild( div );
}
return div;
}
})();
現(xiàn)在我們把管理單例的職責(zé)和創(chuàng)建登錄浮窗的職責(zé)分別封裝在兩個(gè)方法里,這兩個(gè)方法可以獨(dú)立變化而互不影響,當(dāng)它們連接在一起的時(shí)候,就完成了創(chuàng)建唯一登錄浮窗的功能,下面的代碼顯然是更好的做法:
var getSingle = function( fn ){ // 獲取單例
var result;
return function(){
return result || ( result = fn .apply(this, arguments ) );
}
};
var createLoginLayer = function(){ // 創(chuàng)建登錄浮窗
var div = document.createElement( 'div' );
div.innerHTML = '我是登錄浮窗';
document.body.appendChild( div );
return div;
};
var createSingleLoginLayer = getSingle( createLoginLayer );
var loginLayer1 = createSingleLoginLayer();
var loginLayer2 = createSingleLoginLayer();
alert ( loginLayer1 === loginLayer2 ); // 輸出: true
4. 裝飾者模式
使用裝飾者模式的時(shí)候,我們通常讓類或者對象一開始只具有一些基礎(chǔ)的職責(zé),更多的職責(zé)在代碼運(yùn)行時(shí)被動態(tài)裝飾到對象上面。裝飾者模式可以為對象動態(tài)增加職責(zé),從另一個(gè)角度來看,這也是分離職責(zé)的一種方式。
下面是之前曾提到的例子,我們把數(shù)據(jù)上報(bào)的功能單獨(dú)放在一個(gè)函數(shù)里,然后把這個(gè)函數(shù)動態(tài)裝飾到業(yè)務(wù)函數(shù)上面:
<html>
<body>
<button tag="login" id="button">點(diǎn)擊打開登錄浮層</button>
</body>
<script>
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
var showLogin = function(){
console.log( '打開登錄浮層' );
};
var log = function(){
console.log( '上報(bào)標(biāo)簽為: ' + this.getAttribute( 'tag' ) );
};
document.getElementById( 'button' ).onclick = showLogin.after( log );
// 打開登錄浮層之后上報(bào)數(shù)據(jù)
</script>
</html>
SRP原則的應(yīng)用難點(diǎn)就是如何去分離職責(zé),下面我們將開始討論這點(diǎn)。
何時(shí)應(yīng)該分離職責(zé)
SRP原則是所有原則中最簡單也是最難正確運(yùn)用的原則之一。
要明確的是,并不是所有的職責(zé)都應(yīng)該一一分離。
一方面,如果隨著需求的變化,有兩個(gè)職責(zé)總是同時(shí)變化,那就不必分離他們。比如在ajax請求的時(shí)候,創(chuàng)建xhr對象和發(fā)送xhr請求幾乎總是在一起的,那么創(chuàng)建xhr對象的職責(zé)和發(fā)送xhr請求的職責(zé)就沒有必要分開。
另一方面,職責(zé)的變化軸線僅當(dāng)它們確定會發(fā)生變化時(shí)才具有意義,即使兩個(gè)職責(zé)已經(jīng)被耦合在一起,但它們還沒有發(fā)生改變的征兆,那么也許沒有必要主動分離它們,在代碼需要重構(gòu)的時(shí)候再進(jìn)行分離也不遲。
違反SRP原則
在人的常規(guī)思維中,總是習(xí)慣性地把一組相關(guān)的行為放到一起,如何正確地分離職責(zé)不是一件容易的事情。
我們也許從來沒有考慮過如何分離職責(zé),但這并不妨礙我們編寫代碼完成需求。對于SRP原則,許多專家委婉地表示“This is sometimes hard to see.”。
一方面,我們受設(shè)計(jì)原則的指導(dǎo),另一方面,我們未必要在任何時(shí)候都一成不變地遵守原則。在實(shí)際開發(fā)中,因?yàn)榉N種原因違反SRP的情況并不少見。比如jQuery的attr等方法,就是明顯違反SRP原則的做法。jQuery的attr是個(gè)非常龐大的方法,既負(fù)責(zé)賦值,又負(fù)責(zé)取值,這對于jQuery的維護(hù)者來說,會帶來一些困難,但對于jQuery的用戶來說,卻簡化了用戶的使用。
在方便性與穩(wěn)定性之間要有一些取舍。具體是選擇方便性還是穩(wěn)定性,并沒有標(biāo)準(zhǔn)答案,而是要取決于具體的應(yīng)用環(huán)境。比如如果一個(gè)電視機(jī)內(nèi)置了DVD機(jī),當(dāng)電視機(jī)壞了的時(shí)候,DVD機(jī)也沒法正常使用,那么一個(gè)DVD發(fā)燒友通常不會選擇這樣的電視機(jī)。但如果我們的客廳本來就小得夸張,或者更在意DVD在使用上的方便,那讓電視機(jī)和DVD機(jī)耦合在一起就是更好的選擇。
SRP原則的優(yōu)缺點(diǎn)
SRP原則的優(yōu)點(diǎn)是降低了單個(gè)類或者對象的復(fù)雜度,按照職責(zé)把對象分解成更小的粒度,這有助于代碼的復(fù)用,也有利于進(jìn)行單元測試。當(dāng)一個(gè)職責(zé)需要變更的時(shí)候,不會影響到其他的職責(zé)。
但SRP原則也有一些缺點(diǎn),最明顯的是會增加編寫代碼的復(fù)雜度。當(dāng)我們按照職責(zé)把對象分解成更小的粒度之后,實(shí)際上也增大了這些對象之間相互聯(lián)系的難度。