文章出處

前面的話

  享元(flyweight)模式是一種用于性能優化的模式,“fly”在這里是蒼蠅的意思,意為蠅量級。享元模式的核心是運用共享技術來有效支持大量細粒度的對象。如果系統中因為創建了大量類似的對象而導致內存占用過高,享元模式就非常有用了。在javascript中,瀏覽器特別是移動端的瀏覽器分配的內存并不算多,如何節省內存就成了一件非常有意義的事情。本文將詳細介紹享元模式

 

享元模式初識

  假設有個內衣工廠,目前的產品有50種男式內衣和50種女士內衣,為了推銷產品,工廠決定生產一些塑料模特來穿上他們的內衣拍成廣告照片。正常情況下需要50個男模特和50個女模特,然后讓他們每人分別穿上一件內衣來拍照。不使用享元模式的情況下,在程序里也許會這樣寫:

var Model = function( sex, underwear){
    this.sex = sex;
    this.underwear= underwear;
};
Model.prototype.takePhoto = function(){
    console.log( 'sex= ' + this.sex + ' underwear=' + this.underwear);
};
for ( var i = 1; i <= 50; i++ ){
    var maleModel = new Model( 'male', 'underwear' + i );
    maleModel.takePhoto();
};
for ( var j = 1; j <= 50; j++ ){
    var femaleModel= new Model( 'female', 'underwear' + j );
    femaleModel.takePhoto();
};

  要得到一張照片,每次都需要傳入sex和underwear參數,如上所述,現在一共有50種男內衣和50種女內衣,所以一共會產生100個對象。如果將來生產了10000種內衣,那這個程序可能會因為存在如此多的對象已經提前崩潰

  下面來考慮一下如何優化這個場景。雖然有100種內衣,但很顯然并不需要50個男模特和50個女模特。其實男模特和女模特各自有一個就足夠了,他們可以分別穿上不同的內衣來拍照

  現在來改寫一下代碼,既然只需要區別男女模特,那先把underwear參數從構造函數中移除,構造函數只接收sex參數:

var Model = function( sex ){
    this.sex = sex;
};
Model.prototype.takePhoto = function(){
    console.log( 'sex= ' + this.sex + ' underwear=' + this.underwear);
};

  分別創建一個男模特對象和一個女模特對象:

var maleModel = new Model( 'male' ),
femaleModel = new Model( 'female' );

  給男模特依次穿上所有的男裝,并進行拍照:

for ( var i = 1; i <= 50; i++ ){
    maleModel.underwear = 'underwear' + i;
    maleModel.takePhoto();
};

  同樣,給女模特依次穿上所有的女裝,并進行拍照:

for ( var j = 1; j <= 50; j++ ){
    femaleModel.underwear = 'underwear' + j;
    femaleModel.takePhoto();
};

  可以看到,改進之后的代碼,只需要兩個對象便完成了同樣的功能

【內部狀態與外部狀態】

  上面的這個例子便是享元模式的雛形,享元模式要求將對象的屬性劃分為內部狀態與外部狀態(狀態在這里通常指屬性)。享元模式的目標是盡量減少共享對象的數量,關于如何劃分內部狀態和外部狀態,下面的幾條經驗提供了一些指引

  1、內部狀態存儲于對象內部。

  2、內部狀態可以被一些對象共享。

  3、內部狀態獨立于具體的場景,通常不會改變。

  4、外部狀態取決于具體的場景,并根據場景而變化,外部狀態不能被共享

  這樣一來,便可以把所有內部狀態相同的對象都指定為同一個共享的對象。而外部狀態可以從對象身上剝離出來,并儲存在外部

  剝離了外部狀態的對象成為共享對象,外部狀態在必要時被傳入共享對象來組裝成一個完整的對象。雖然組裝外部狀態成為一個完整對象的過程需要花費一定的時間,但卻可以大大減少系統中的對象數量,相比之下,這點時間或許是微不足道的。因此,享元模式是一種用時間換空間的優化模式

  在上面的例子中,性別是內部狀態,內衣是外部狀態,通過區分這兩種狀態,大大減少了系統中的對象數量。通常來講,內部狀態有多少種組合,系統中便最多存在多少個對象,因為性別通常只有男女兩種,所以該內衣廠商最多只需要2個對象

  使用享元模式的關鍵是如何區別內部狀態和外部狀態。可以被對象共享的屬性通常被劃分為內部狀態,如同不管什么樣式的衣服,都可以按照性別不同,穿在同一個男模特或者女模特身上,模特的性別就可以作為內部狀態儲存在共享對象的內部。而外部狀態取決于具體的場景,并根據場景而變化,就像例子中每件衣服都是不同的,它們不能被一些對象共享,因此只能被劃分為外部狀態

  上面的例子還不是一個完整的享元模式,存在以下兩個問題

  1、通過構造函數顯式new出了男女兩個model對象,在其他系統中,也許并不是一開始就需要所有的共享對象

  2、給model對象手動設置了underwear外部狀態,在更復雜的系統中,這不是一個最好的方式,因為外部狀態可能會相當復雜,它們與共享對象的聯系會變得困難

  通過一個對象工廠來解決第一個問題,只有當某種共享對象被真正需要時,它才從工廠中被創建出來。對于第二個問題,可以用一個管理器來記錄對象相關的外部狀態,使這些外部狀態通過某個鉤子和共享對象聯系起來

 

文件上傳

【基本版本】

  在文件上傳模塊的開發中,文件上傳功能雖然可以選擇依照隊列,一個一個地排隊上傳,但也支持同時選擇2000個文件。每一個文件都對應著一個javascript上傳對象的創建,往程序里同時new了2000個upload對象,結果可想而知,Chrome中還勉強能夠支撐,IE下直接進入假死狀態

  文件支持好幾種上傳方式,比如瀏覽器插件、Flash和表單上傳等,為了簡化例子,先假設只有插件和Flash這兩種。不論是插件上傳,還是Flash上傳,原理都是一樣的,當用戶選擇了文件之后,插件和Flash都會通知調用Window下的一個全局javascript函數,它的名字是startUpload,用戶選擇的文件列表被組合成一個數組files塞進該函數的參數列表里,代碼如下:

var id = 0;
window.startUpload = function( uploadType, files ){ // uploadType 區分是控件還是flash
    for ( var i = 0, file; file = files[ i++ ]; ){
        var uploadObj = new Upload( uploadType, file.fileName, file.fileSize );
        uploadObj.init( id++ ); // 給upload 對象設置一個唯一的id
    }
};

  當用戶選擇完文件之后,startUpload函數會遍歷files數組來創建對應的upload對象。接下來定義Upload構造函數,它接受3個參數,分別是插件類型、文件名和文件大小。這些信息都已經被插件組裝在files數組里返回,代碼如下:

var Upload = function( uploadType, fileName, fileSize ){
    this.uploadType = uploadType;
    this.fileName = fileName;
    this.fileSize = fileSize;
    this.dom= null;
};
Upload.prototype.init = function( id ){
    var that = this;
    this.id = id;
    this.dom = document.createElement( 'div' );
    this.dom.innerHTML =
    '<span>文件名稱:'+ this.fileName +', 文件大小: '+ this.fileSize +'</span>' +
    '<button class="delFile">刪除</button>';
    this.dom.querySelector( '.delFile' ).onclick = function(){
        that.delFile();
    }
    document.body.appendChild( this.dom );
};

  為了簡化示例,暫且去掉了upload對象的其他功能,只保留刪除文件的功能,對應的方法是Upload.prototype.delFile。該方法中有一個邏輯:當被刪除的文件小于3000KB時,該文件將被直接刪除。否則頁面中會彈出一個提示框,提示用戶是否確認要刪除該文件,代碼如下:

Upload.prototype.delFile = function(){
    if ( this.fileSize < 3000 ){
        return this.dom.parentNode.removeChild( this.dom );
    }
    if ( window.confirm( '確定要刪除該文件嗎? ' + this.fileName ) ){
        return this.dom.parentNode.removeChild( this.dom );
    }
};

  接下來分別創建3個插件上傳對象和3個Flash上傳對象:

startUpload( 'plugin', [
{
    fileName: '1.txt',
    fileSize: 1000
},
{
    fileName: '2.html',
    fileSize: 3000
},
{
    fileName: '3.txt',
    fileSize: 5000
}
]);
startUpload( 'flash', [
{
    fileName: '4.txt',
    fileSize: 1000
},
{
    fileName: '5.html',
    fileSize: 3000
},
{
    fileName: '6.txt',
    fileSize: 5000
}
]);

【享元模式重構】

  上一節的代碼是第一版的文件上傳,在這段代碼里有多少個需要上傳的文件,就一共創建了多少個upload對象,接下來用享元模式重構它

  首先,需要確認插件類型uploadType是內部狀態,那為什么單單uploadType是內部狀態呢?在文件上傳的例子里,upload對象必須依賴uploadType屬性才能工作,這是因為插件上傳、Flash上傳、表單上傳的實際工作原理有很大的區別,它們各自調用的接口也是完全不一樣的,必須在對象創建之初就明確它是什么類型的插件,才可以在程序的運行過程中,讓它們分別調用各自的start、pause、cancel、del等方法

  一旦明確了uploadType,無論使用什么方式上傳,這個上傳對象都是可以被任何文件共用的。而fileName和fileSize是根據場景而變化的,每個文件的fileName和fileSize都不一樣,fileName和fileSize沒有辦法被共享,它們只能被劃分為外部狀態

  明確了uploadType作為內部狀態之后,再把其他的外部狀態從構造函數中抽離出來,Upload構造函數中只保留uploadType參數:

var Upload = function( uploadType){
    this.uploadType = uploadType;
};

  Upload.prototype.init函數也不再需要,因為upload對象初始化的工作被放在了upload-Manager.add函數里面,接下來只需要定義Upload.prototype.del函數即可:

Upload.prototype.delFile = function( id ){
    uploadManager.setExternalState( id, this ); // (1)
    if ( this.fileSize < 3000 ){
        return this.dom.parentNode.removeChild( this.dom );
    }
    if ( window.confirm( '確定要刪除該文件嗎? ' + this.fileName ) ){
        return this.dom.parentNode.removeChild( this.dom );
    }
}

  在開始刪除文件之前,需要讀取文件的實際大小,而文件的實際大小被儲存在外部管理器uploadManager中,所以在這里需要通過uploadManager.setExternalState方法給共享對象設置正確的fileSize,上段代碼中的(1)處表示把當前id對應的對象的外部狀態都組裝到共享對象中

  接下來定義一個工廠來創建upload對象,如果某種內部狀態對應的共享對象已經被創建過,那么直接返回這個對象,否則創建一個新的對象:

var UploadFactory = (function(){
    var createdFlyWeightObjs = {};
    return {
        create: function( uploadType){
            if ( createdFlyWeightObjs [ uploadType] ){
                return createdFlyWeightObjs [ uploadType];
            }
            return createdFlyWeightObjs [ uploadType] = new Upload( uploadType);
        }
    }
})();

  現在來完善前面提到的uploadManager對象,它負責向UploadFactory提交創建對象的請求,并用一個uploadDatabase對象保存所有upload對象的外部狀態,以便在程序運行過程中給upload共享對象設置外部狀態,代碼如下:

var uploadManager = (function(){
    var uploadDatabase = {};
    return {
        add: function( id, uploadType, fileName, fileSize ){
            var flyWeightObj = UploadFactory.create( uploadType );
            var dom = document.createElement( 'div' );
            dom.innerHTML =
            '<span>文件名稱:'+ fileName +', 文件大小: '+ fileSize +'</span>' +
            '<button class="delFile">刪除</button>';
            dom.querySelector( '.delFile' ).onclick = function(){
                flyWeightObj.delFile( id );
            }

            document.body.appendChild( dom );
            uploadDatabase[ id ] = {
                fileName: fileName,
                fileSize: fileSize,
                dom: dom
            };
            return flyWeightObj ;
        },
        setExternalState: function( id, flyWeightObj ){
            var uploadData = uploadDatabase[ id ];
            for ( var i in uploadData ){
                flyWeightObj[ i ] = uploadData[ i ];
            }
        }
    }
})();

  然后是開始觸發上傳動作的startUpload函數:

var id = 0;
window.startUpload = function( uploadType, files ){
    for ( var i = 0, file; file = files[ i++ ]; ){
        var uploadObj = uploadManager.add( ++id, uploadType, file.fileName, file.fileSize );
    }
};

  最后是測試時間,運行下面的代碼后,可以發現運行結果跟用享元模式重構之前一致:

startUpload( 'plugin', [
{
    fileName: '1.txt',
    fileSize: 1000
},
{
    fileName: '2.html',
    fileSize: 3000
},
{
    fileName: '3.txt',
    fileSize: 5000
}
]);
startUpload( 'flash', [
{
    fileName: '4.txt',
    fileSize: 1000
},
{
    fileName: '5.html',
    fileSize: 3000
},
{
    fileName: '6.txt',

    fileSize: 5000
}
]);

  享元模式重構之前的代碼里一共創建了6個upload對象,而通過享元模式重構之后,對象的數量減少為2,更幸運的是,就算現在同時上傳2000個文件,需要創建的upload對象數量依然是2

 

適用性

  享元模式是一種很好的性能優化方案,但它也會帶來一些復雜性的問題,從前面兩組代碼的比較可以看到,使用了享元模式之后,需要分別多維護一個factory對象和一個manager對象,在大部分不必要使用享元模式的環境下,這些開銷是可以避免的

  享元模式帶來的好處很大程度上取決于如何使用以及何時使用,一般來說,以下情況發生時便可以使用享元模式

  1、一個程序中使用了大量的相似對象

  2、由于使用了大量對象,造成很大的內存開銷

  3、對象的大多數狀態都可以變為外部狀態

  4、剝離出對象的外部狀態之后,可以用相對較少的共享對象取代大量對象。可以看到,文件上傳的例子完全符合這四點

  實現享元模式的關鍵是把內部狀態和外部狀態分離開來。有多少種內部狀態的組合,系統中便最多存在多少個共享對象,而外部狀態儲存在共享對象的外部,在必要時被傳入共享對象來組裝成一個完整的對象。現在來考慮兩種極端的情況,即對象沒有外部狀態和沒有內部狀態的時候

【沒有內部狀態的享元】

  在文件上傳的例子中,分別進行過插件調用和Flash調用,即startUpload('plugin',[])和startUpload(flash,[]),導致程序中創建了內部狀態不同的兩個共享對象。在文件上傳程序里,一般都會提前通過特性檢測來選擇一種上傳方式,如果瀏覽器支持插件就用插件上傳,如果不支持插件,就用Flash上傳。那么,什么情況下既需要插件上傳又需要Flash上傳呢?

  實際上這個需求是存在的,很多網盤都提供了極速上傳(控件)與普通上傳(Flash)兩種模式,如果極速上傳不好使(可能是沒有安裝控件或者控件損壞),用戶還可以隨時切換到普通上傳模式,所以這里確實是需要同時存在兩個不同的upload共享對象

  但不是每個網站都必須做得如此復雜,很多小一些的網站就只支持單一的上傳方式。假設我們是這個網站的開發者,不需要考慮極速上傳與普通上傳之間的切換,這意味著在之前的代碼中作為內部狀態的uploadType屬性是可以刪除掉的

  在繼續使用享元模式的前提下,構造函數Upload就變成了無參數的形式:

var Upload = function(){};

  其他屬性如fileName、fileSize、dom依然可以作為外部狀態保存在共享對象外部。在uploadType作為內部狀態的時候,它可能為控件,也可能為Flash,所以當時最多可以組合出兩個共享對象。而現在已經沒有了內部狀態,這意味著只需要唯一的一個共享對象。現在要改寫創建享元對象的工廠,代碼如下:

var UploadFactory = (function(){ 
  var uploadObj;
  return {
   create: function(){ 
     if ( uploadObj ){
      return uploadObj;
    }
    return uploadObj = new Upload();
    })();
  }
}

  管理器部分的代碼不需要改動,還是負責剝離和組裝外部狀態。可以看到,當對象沒有內部狀態的時候,生產共享對象的工廠實際上變成了一個單例工廠。雖然這時候的共享對象沒有內部狀態的區分,但還是有剝離外部狀態的過程,依然傾向于稱之為享元模式

 

對象池

  對象池維護一個裝載空閑對象的池子,如果需要對象的時候,不是直接new,而是轉從對象池里獲取。如果對象池里沒有空閑對象,則創建一個新的對象,當獲取出的對象完成它的職責之后,再進入池子等待被下次獲取

  對象池的原理很好理解,比如我們組人手一本《javascript權威指南》,從節約的角度來講,這并不是很劃算,因為大部分時間這些書都被閑置在各自的書架上,所以我們一開始就只買一本,或者一起建立一個小型圖書館(對象池),需要看書的時候就從圖書館里借,看完了之后再把書還回圖書館。如果同時有三個人要看這本書,而現在圖書館里只有兩本,那我們再馬上去書店買一本放入圖書館

  對象池技術的應用非常廣泛,HTTP連接池和數據庫連接池都是其代表應用。在Web前端開發中,對象池使用最多的場景大概就是跟DOM有關的操作。很多空間和時間都消耗在了DOM節點上,如何避免頻繁地創建和刪除DOM節點就成了一個有意義的話題

  假設開發一個地圖應用,地圖上經常會出現一些標志地名的小氣泡,叫它toolTip。在搜索附近地圖的時候,頁面里出現了2個小氣泡。當再搜索附近的蘭州拉面館時,頁面中出現了6個小氣泡。按照對象池的思想,在第二次搜索開始之前,并不會把第一次創建的2個小氣泡刪除掉,而是把它們放進對象池。這樣在第二次的搜索結果頁面里,只需要再創建4個小氣泡而不是6個

  先定義一個獲取小氣泡節點的工廠,作為對象池的數組成為私有屬性被包含在工廠閉包里,這個工廠有兩個暴露對外的方法,create表示獲取一個div節點,recover表示回收一個div節點:

var toolTipFactory = (function(){
    var toolTipPool = []; // toolTip 對象池
    return {
        create: function(){
            if ( toolTipPool.length === 0 ){ // 如果對象池為空
                var div = document.createElement( 'div' ); // 創建一個dom
                document.body.appendChild( div );
                recovereturn div;
            }else{ // 如果對象池里不為空
                return toolTipPool.shift(); // 則從對象池中取出一個dom
            }
        },
        recover: function( tooltipDom ){
            return toolTipPool.push( tooltipDom ); // 對象池回收dom
        }
    }
})();

  現在把時鐘撥回進行第一次搜索的時刻,目前需要創建2個小氣泡節點,為了方便回收,用一個數組ary來記錄它們:

var ary = [];
for ( var i = 0, str; str = [ 'A', 'B' ][ i++ ]; ){
    var toolTip = toolTipFactory.create();
    toolTip.innerHTML = str;
    ary.push( toolTip );
};

  接下來假設地圖需要開始重新繪制,在此之前要把這兩個節點回收進對象池:

for ( var i = 0, toolTip; toolTip = ary[ i++ ]; ){
    toolTipFactory.recover( toolTip );
};

  再創建6個小氣泡:

for ( var i = 0, str; str = [ 'A', 'B', 'C', 'D', 'E', 'F' ][ i++ ]; ){
    var toolTip = toolTipFactory.create();
    toolTip.innerHTML = str;
};

  頁面中出現了內容分別為A、B、C、D、E、F的6個節點,上一次創建好的節點被共享給了下一次操作。對象池跟享元模式的思想有點相似,雖然innerHTML的值A、B、C、D等也可以看成節點的外部狀態,但在這里并沒有主動分離內部狀態和外部狀態的過程

【通用對象池實現】

  還可以在對象池工廠里,把創建對象的具體過程封裝起來,實現一個通用的對象池:

var objectPoolFactory = function( createObjFn ){
    var objectPool = [];
    return {
        create: function(){
            var obj = objectPool.length === 0 ?
            createObjFn.apply( this, arguments ) : objectPool.shift();
            return obj;
        },
        recover: function( obj ){
            objectPool.push( obj );

        }
    }
};

var iframeFactory = objectPoolFactory( function(){
    var iframe = document.createElement( 'iframe' );
    document.body.appendChild( iframe );
    iframe.onload = function(){
        iframe.onload = null; // 防止iframe 重復加載的bug
        iframeFactory.recover( iframe ); // iframe 加載完成之后回收節點
    }
    return iframe;
});

var iframe1 = iframeFactory.create();
iframe1.src = 'http:// baidu.com';
var iframe2 = iframeFactory.create();
iframe2.src = 'http:// QQ.com';
setTimeout(function(){
    var iframe3 = iframeFactory.create();
    iframe3.src = 'http:// 163.com';
}, 3000 );

  對象池是另外一種性能優化方案,它跟享元模式有一些相似之處,但沒有分離內部狀態和外部狀態這個過程。文件上傳的程序其實也可以用對象池+事件委托來代替實現

  享元模式是為解決性能問題而生的模式,這跟大部分模式的誕生原因都不一樣。在一個存在大量相似對象的系統中,享元模式可以很好地解決大量對象帶來的性能問題

 


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()