[1]享元模式初識 [2]文件上傳 [3]適用性 [4]對象池 ...
前面的話
享元(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 );
對象池是另外一種性能優化方案,它跟享元模式有一些相似之處,但沒有分離內部狀態和外部狀態這個過程。文件上傳的程式其實也可以用對象池+事件委托來代替實現
享元模式是為解決性能問題而生的模式,這跟大部分模式的誕生原因都不一樣。在一個存在大量相似對象的系統中,享元模式可以很好地解決大量對象帶來的性能問題