Javascript 設計模式 之 9亨元模式

zhaoyezi發表於2018-06-04

1 亨元模式的定義

亨元模式(flyweight)是一種效能優化的模式,fly是蒼蠅的意思,意為蠅量級。亨元模式核心在於運用共享技術來有效地支援大量細粒度的物件。
如果系統中出現了大量類似的物件而導致記憶體佔用過高,亨元模式就比較有用了。

2 初識亨元模式

假設內衣廠,目前生產了50件男士T恤和50件女士T恤。現在要請模特穿上T恤並拍照。不使用亨元模式情況下,每件衣服都需要1個模特。

// 模特類
var Model = function(sex, tShirt) {
    this.sex = sex;
    this.tShirt = tShirt;
}

Model.prototype.takePhoto = function() {
    console.log('sex:' + this.sex + ", tShrit: " + this.tShirt);
}

for ( var i = 1; i <= 50; i++ ){
    var maleModel = new Model( 'male', 'tShrit' + i );
    maleModel.takePhoto();
};
for ( var j = 1; j <= 50; j++ ){
    var femaleModel= new Model( 'female', 'tShrit' + j );
    femaleModel.takePhoto();
};
複製程式碼

在這裡建立了100個Model物件。但是實際情況我們只需要找一個男模特和一個女模特就夠了。男模特穿50件T恤拍照,女模特穿50件T恤拍照。只需要2個物件便能夠完成同樣的功能。

var Model = function(sex) {
    this.sex = sex;
    this.tShirt = null;
}

Model.prototype.takePhoto = function() {
    console.log('sex:' + this.sex + ", tShrit: " + this.tShirt);
}

var maleModel = new Model( 'male');
for ( var i = 1; i <= 50; i++ ){
    maleModel.tShirt = 'tShrit' + j;
    maleModel.takePhoto();
};
var femaleModel= new Model( 'female');
for ( var j = 1; j <= 50; j++ ){
    femaleModel.tShirt = 'tShrit' + j;
    femaleModel.takePhoto();
};
複製程式碼

3 內部狀態與外部狀態

上面的例子中有了亨元模式的雛形,亨元模式要求將物件的屬性劃分為內部狀態和外部狀態。亨元模式的目標旨在減少共享物件的數量,關於如何劃分內部狀態和外部狀態,下面有點經驗指引:

  • 內部狀態儲存於物件內部
  • 內部狀態可以被一些物件共享
  • 內部狀態獨立於具體的場景,通常不會改變
  • 外部狀態取決於具體的場景,並根據場景而變化,外部狀態不能被共享 這樣我們可以把所有內部狀態(性別)相同的物件(例如性別為女的一類50個人)指定為同一個共享獨享(最終只需要以一個人),外部物件可以從物件上脫離出來(T恤被脫離),並儲存於外部。
  • 剝離了外部狀態(T恤)的物件成為共享物件(maleModel),外部狀態在必要時被傳入共享物件然後組裝成為一個完整的物件。雖然耗時,但是減少了記憶體佔用,亨元模式就是以時間換空間的優化模式。

4 利用亨元模式檔案上傳

1小結中的亨元模式例子存在兩個問題:

  • 通過建構函式new出來的男女兩個model物件,在其他系統中,也許並不不是一開始就需要所有的共享物件
  • 給model手動設定的外部狀態,在複雜的系統中,這不是一個好的方式,因為外部物件可能相當複雜,它們與共享物件的聯絡變的困難。
    解決第一個問題:可以使用物件工廠的方式來建立,當真正需要的時候才在工廠物件建立出來。
    解決第二個問題:用一個管理器來記錄物件相關的外部狀態,使這些外部狀態通過某個鉤子與共享物件聯絡起來。

4.1 檔案上傳問題描述

  • 上傳一個檔案需要建立一個檔案對阿寧,當同一個操作需要支援多個檔案上傳時,每一個檔案需要建立一個物件,如果1000個檔案那麼就需要new 1000個物件。
  • 當專案支援多種上傳方式:瀏覽器外掛上傳,Flash上傳和表單上傳。當使用者選擇上傳檔案之後,外掛或者Flash都會通知window的一個全域性javascript函式,名字叫startUpload。使用者選擇的檔案列表被組合成為一耳光陣列files塞進該函式的引數列表中。
var i = 0;
// fileType: 區分是控制元件,flash還是表單上傳
window.startUpload = function(fileType, files) {
    files.map(function(file) {
        var uploadFile = new Upload(uploadType, file.fileName, file.fileSize);
        // 初始化檔案到頁面中
        uploadFile.init(id++);
    })
};
// 檔案物件接收三個引數:外掛型別,檔名,檔案大小
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);
};
// 檔案如果小於3000直接刪除,否則給與提示
Upload.prototype.delFile = function() {
    if (this.fileSize < 3000) {
        return this.dom.parentNode.removeChild(this.dom);
    } else {
        if (window.confirm('你確定要刪除檔案嗎?' + this.fileName)) {
            return this.dom.parentNode.removeChild(this.dom); 
        }
    }
}
複製程式碼

下面上分別建立三個外掛物件和三個flash物件。每個檔案都被建立了一個Upload物件。總共6個物件。

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
    }
]);
複製程式碼

4.2 亨元模式完成檔案上傳

第一版本的檔案上傳功能,需要上傳多少個檔案,就需要建立多少個物件。我們利用亨元模式的來重構。

  • 找到內部狀態(儲存於物件內部,能被多個物件共享,獨立於具體場景不被改變):上傳型別即為內部狀態
  • 找到外部狀態(取決於具體場景,根據場景變化而變化,不能被共享):檔案大小和檔名稱
  • 使用工廠方法建立上傳物件(同意上傳型別只需要建立一個物件)
  • 管理器封裝外部狀態
// 只有uploadType才是內部狀態
var Uplaod = function(uploadType) {
    this.uploadType = uploadType;
}
// 檔案如果小於3000直接刪除,否則給與提示
Upload.prototype.delFile = function() {
    // 獲取自己的屬性(fileSize,fileName)
    uploadManager.setExternalState( id, this);
    if (this.fileSize < 3000) {
        return this.dom.parentNode.removeChild(this.dom);
    } else {
        if (window.confirm('你確定要刪除檔案嗎?' + this.fileName)) {
            return this.dom.parentNode.removeChild(this.dom); 
        }
    }
}

// 工廠進行物件例項化
var UploadFactory = function() {
    var createFlyWeight = {};
    return {
        create: function(uploadType) {
            if (!(uploadType in createFlyWeight)) {
                createFlyWeight[uploadType] =  new Upload(uploadType);
            }
            return createFlyWeight[uploadType];
        }
    };
};

// 管理器封裝外部狀態
var uploadManager = (function() {
    var uploadDatabase = {};
    return {
        add: function(id, uploadType, fileName, fileSize) {
            var flyWight = UploadFactory.create(uploadType);
            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);
            uploadDatabase[id] = {
                fileName: fileName,
                fileSize: fileSize,
                dom: dom
            };
            return flyWight;
        },
        setExternalState: function(id, flyWight) {
            var uploadData = uploadDatabase[ id ];
            for ( var i in uploadData ){
                flyWight[ i ] = uploadData[ i ];
            }
        }
    }
})();

// 呼叫
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 );
    }
};
複製程式碼

和上面一樣的方式建立三個外掛物件和三個flash物件。最終在程式碼中只會產生2個物件,通過工廠方法建立的。

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
    }
]);
複製程式碼

5 亨元模式再談

亨元模式用於效能優化,但是需要額外維護factory和manager物件。在大部分不必要使用亨元模式的環境下,這部分開銷可以避免。亨元模式主要用於以下情況:

  • 一個程式使用了大量相似的物件
  • 由於使用大量物件造成記憶體開銷
  • 物件的大多數狀態為外部狀態
  • 剝離物件外部狀態之後,可以使用相對較少的共享物件來取代大量物件。
    可以看出檔案上傳完全符合這四點。我們也知道了實現亨元模式就是講外部物件與內部物件分離出來,有多少種內部狀態的組合,系統中便最多產生多少個共享物件。而外部物件在需要的時候傳入共享物件組裝成為要給完整的物件。現在考慮一下兩種極端情況:完全沒有外部狀態 和 完全沒有內部狀態

5.1 完全沒有內部狀態

在檔案上傳的例子中,我們內部狀態是外掛和flash。因此建立了兩個物件。

  • startUpload('plugin', []);
  • startUpload('flash', []);
    有些網站支援極速上傳(plugin),也支援普通上傳(flash)。當極速上傳不好使就使用普通上傳。但是也有很多網站並不需要做的如此複雜,只支援單一的上傳方式。那麼內部狀態uploadType實際上就不存在。沒有了內部狀態意味著我們只需要唯一一個共享物件。改寫我們的工廠方法:
// 工廠進行物件例項化
var UploadFactory = function() {
    var flyWeight;
    return {
        create: function() {
            if (!flyWeight) {
                flyWeight =  new Upload();
            }
            return flyWeight;
        }
    };
};
複製程式碼

雖然生產共享物件的工廠方法程式設計了一個單例工廠,這從共享物件沒有了內部狀態的區別,但是還是有剝離狀態的過程,我們依然傾向於稱之為亨元模式。

5.2 完全沒有外部狀態

對於上面的工廠化建立的物件obj1, obj2,通過判斷obj1 === obj2,會返回true。但是單純只有這兩個物件並不能說明是亨元模式的結果。亨元模式關鍵區分於內部狀態和外部狀態。亨元模式的過程是剝離外部狀態。把外部狀態儲存在其他地方,並在合適的時候將外部狀態組裝進入共享物件。這裡的obj1 和obj2完全是同一個物件。如果沒有外部狀態的剝離,即使有共享資料,但是不是一個純粹的亨元模式。

var obj1 = UploadFactory.create();
var obj2 = UploadFactory.create();
複製程式碼

6 物件池

物件池維護這一個裝載空閒物件的池子,如果需要物件時,不是直接new物件,而是從池子中獲取物件。如果池子中沒有空閒物件,則狀態一個新的物件,當獲取的物件完成了它的職責後,再進入池子等待下一次被獲取。
物件池的應用情況:

  • HTTP連線池和資料庫連線池
  • web前端開發,物件池使用最多的場景是DOM有關的操作。

6.1 物件池的實現

例如在地圖上,我們搜尋的時候,頁面會出現2個小氣泡。當搜尋蘭州拉麵時,會出現6個氣泡,當搜尋新巴克時,出現3個氣泡。這裡的氣泡就可以使用物件池實現

Javascript 設計模式 之 9亨元模式

var ToolTipFactrory = (function() {
    var toolTips = []; // 儲存toolTip物件池
    return {
        create: function() {
            // 如果物件池不為空,取出一個返回
            if (toolTips.length > 0) {
                return toolTips.shift();
            } else  {
                // 建立一個dom返回
                var div = document.createElement('div');
                return div;
            }
        },
        recover: function(toolTipDom) {
            toolTips.push(toolTipDom);
        }
    };
})();
複製程式碼

點選按鈕,建立toolTip。將上一次建立的節點共享於下一次的操作。物件池的思想與亨元模式的思想有點相似,el的innerHTML是外部狀態,而封裝內部el節點是內部狀態。但是我們沒有主動分離內部狀態和外部狀態。

  • 第一次點選建立n個(隨機)
  • 第二次點選,銷燬之前建立的內容,在重新根據返回的資料建立(n個,隨機)
var button = document.getElementById('id');
var arr = [];
button.onclick = function() {
    // 新的一輪建立個數 > 當前個數,則在現有基礎上新增(newLength - oldLength)個
    // 新的個數 < 當前個數,則在當前基礎上移除(oldLength - newLength)個
	var length = parseInt(Math.random() * 10);
	var differ = length - arr.length;
	if (differ < 0) {
		recoverToolTip(Math.abs(differ));
	} else {
		 getToolTip(differ);
	}
}
// 移除
function recoverToolTip(length) {
	for(i = 0; i< length; i++) {
		var el = arr.shift();
		ToolTipFactrory.recover(el);
		document.body.removeChild(el);
	}
}
// 新增
function getToolTip(length,) {
    for (var i = 0; i < length; i++) {
			var el = ToolTipFactrory.create();
			el.innerHTML = i + 'div';
			document.body.appendChild(el);
			arr.push(el);
    }
}
複製程式碼

6.2 通用物件池

上面的物件池只能作用於toolTip的建立。我們可以將該物件池改造一下,實現一個通用的物件池。

var ObjPoolFactory = (function(createObjFn) {
    var objectPool = [];
    return {
        create: function() {
            if (objectPool.length === 0) {
                return createObjFn.apply(this, arguments);
            }
            return objectPool.shift();
        },
        recover: function(obj){
            objectPool.push(obj);
        }
    }
})();
複製程式碼

相關文章