摘要
享元模式是用於性能優化的設計模式之一,在前端編程中有重要的應用,尤其是在大量渲染DOM的時候,使用享元模式及對象池技術能獲得極大優化。本文介紹了享元模式的概念,並將其用於渲染大量列表數據的優化上。
初識享元模式
在面向對象編程中,有時會重複創建大量相似的對象,當這些對象不能被垃圾回收的時候(比如被閉包在一個回調函數中)就會造成內存的高消耗,在循環體裏創建對象時尤其會出現這種情況。享元模式提出了一種對象複用的技術,即我們不需要創建那麼多對象,只需要創建若干個能夠被複用的對象(享元對象),然後在實際使用中給享元對象注入差異,從而使對象有不同的表現。
爲了要創建享元對象,首先要把對象的數據劃分爲內部狀態和外部狀態,具體何爲內部狀態,何爲外部狀態取決於你想要創建什麼樣的享元對象。
舉個例子:
書這個類,我想創建的享元對象是“技術類書籍”,讓所有技術類的書都共享這個對象,那麼書的類別就是內部狀態;而書的書名,作者可能是每本書都不一樣的,那麼書的書名和作者就是外部狀態。或者換一種方式,我想創建“村上春樹寫的書”這種享元對象,然後讓所有村上春樹寫的書都共享這個享元對象,此時書的作者就爲內部狀態。當然也可以讓作者、分類同時爲內部狀態創建一個享元對象。
享元對象可以按照內部狀態的不同創建若干個,比如技術類書,文學類書,雞湯類書三個。在實踐的時候會發現,抽象程度越高,所創建的享元對象就越少,但是外部狀態就越多;相反抽象程度越低,所需創建的享元對象就越多,外部狀態就越少。特別地,當對象的所有狀態都歸爲內部狀態時,此時每個對象都可以看作一個享元對象,但是沒有被共享,相當於沒用享元模式。
一.享元模式的結構
1.內部狀態與外部狀態
在享元對象內部並且不會隨着環境改變而改變的共享部分,可以稱之爲享元對象的內部狀態,反之隨着環境改變而改變的,不可共享的狀態稱之爲外部狀態。
簡單地說,內部狀態是對象本身的屬性,外部狀態是管理這些對象所需的額外的屬性,相同的對象內部狀態相同,但外部狀態可能不同(比如2本相同的書被2個人借走了)
2.享元
享元是相似對象(書的例子中指的是完全相同的書,而不是題材相似的書)間可共享的屬性的集合,比如書的名字、作者、ISBN等等,完全相同的書這些信息都是相同的,沒有必要把相同的屬性在多個相似對象中保存多份,享元負責把這些屬性分離出來,以便共享
如果可共享的屬性比較複雜,還可以增加抽象享元,以及與之對應的具體享元,還可以有複合享元
3.享元工廠
享元工廠負責創建並管理享元,實現共享邏輯(創建時判斷是否存在,已存在就返回現有對象,否則創建一個)
4.客戶端(Client)
Client負責調用享元工廠,並存儲管理相似對象所需的額外屬性(比如書的id,借/還日期,是否在館等等)
實例:
如果需要管理的書籍數量非常大,那麼使用享元模式節省的內存將是一個可觀的數目
// 圖書管理
// 書的屬性:id,title,author,genre,page count,publisher id,isbn
// 管理所需的額外屬性:checkout date,checkout member,due return date,availability
// 享元(存儲內部狀態)
function Book(title, author, genre, pageCount, publisherId, isbn) {
this.title = title;
this.author = author;
this.genre = genre;
this.pageCount = pageCount;
this.publisherId = publisherId;
this.isbn = isbn;
}
// 享元工廠(創建/管理享元)
var BookFactory = (function () {
var existingBooks = {};
var existingBook = null;
return {
createBook: function (title, author, genre, pageCount, publisherId, isbn) {
// 如果書籍已經創建,,則找到並返回
// !!強制返回bool類型
existingBook = existingBooks[isbn];
if (!!existingBook) {
return existingBook;
} else {
// 如果不存在選擇創建該書的新實例並保存
var book = new Book(title, author, genre, pageCount, publisherId, isbn);
console.log(book);
existingBooks[isbn] = book;
return book;
}
}
}
})();
// 客戶端(存儲外部狀態)
var BookRecordManager = (function () {
var bookRecordDatabase = {};
return {
// 添加新書到數據庫
addBookRecord: function (id, title, author, genre, pageCount, publisherId, isbn,
checkoutDate, checkoutMember, dueReturnDate, availability) {
var book = BookFactory.createBook(title, author, genre, pageCount, publisherId, isbn);
bookRecordDatabase[id] = {
checkoutMember: checkoutMember,
checkoutDate: checkoutDate,
dueReturnDate: dueReturnDate,
availability: availability,
book: book
}
},
updateCheckStatus: function (bookId, newStatus, checkoutDate, checkoutMember, newReturnDate) {
var record = bookRecordDatabase[bookId];
record.availability = newStatus;
record.checkoutDate = checkoutDate;
record.checkoutMember = checkoutMember;
record.dueReturnDate = newReturnDate;
},
extendCheckoutPeriod: function (bookId, newReturnDate) {
bookRecordDatabase[bookId].dueReturnDate = newReturnDate;
},
isPastDue: function (bookId) {
var currDate = new Date();
return currDate.getTime() > Date.parse(bookRecordDatabase[bookId].dueReturnDate);
}
};
})();
// test
// isbn號是書籍的唯一標識,以下三條只會創建一個book對象
BookRecordManager.addBookRecord(1, 'x', 'x', 'xx', 300, 10001, '100-232-32'); // Book {title: "x", author: "x", genre: "xx", pageCount: 300, publisherId: 10001, …}
BookRecordManager.addBookRecord(1, 'xx', 'xx', 'xx', 300, 10001, '100-232-32');
BookRecordManager.addBookRecord(1, 'xxx', 'xxx', 'xxx', 300, 10001, '100-232-32');
jQuery與享元模式
jQuery.single = (function(o){
var collection = jQuery([1]); // Fill with 1 item, to make sure length === 1
return function(element) {
// Give collection the element:
collection[0] = element;
// Return the collection:
return collection;
};
}());
// window.$_ = jQuery.single; // 定義別名
// test
jQuery('a').click(function(){
var html = jQuery.single(this).next().html(); // Method chaining works!
alert(html);
// etc. etc.
});
維護了一個單例collection,避免多次用$()包裹同一個DOM對象帶來的內存消耗(會創建多個jQuery對象),使用jQuery.single永遠都只會創
建一個jQuery對象,節省了創建額外jQuery對象消耗的時間,還減少了內存開銷,但這樣做最大的問題可能是:jQuery.single返回的對象無法
被緩存。因爲內部是單例實現,緩存的對象在下一次調用jQuery.single後可能會被改變,所以無法像$()一樣隨時緩存。但據說直接使用jQuery.single
獲取單例要比緩存普通jQuery對象更快,但爲了避免混亂,建議只在需要把DOM對象包裹成jQuery對象時才使用jQuery.single方法。
要對參數進行處理,多態的實現
jQuery.extend = function () {
if (arguments.length === 1) {
for (const item in arguments[0]) {
jQuery[item] = arguments[item];
}
} else {
for (const item in arguments[1]) {
arguments[0][item] = arguments[1][item]; // A={a:1,b:2} B={c:3,d:4} =>A.c=B.c A.d=B.d =>A={a:1,b:2,c:3,d:4}
}
}
}
//把for循環變成享元模式
jQuery.extend = function () {
var target = arguments[0] || {}; //健壯性的體現,出現錯誤不會阻塞
if (target !== 'object') {
target = {}
}
var length = arguments.length;
var i = 1;
if (length == 1) {
target = this; //this指jQuery
i--
}
for (var item in arguments[1]) {
target[item] = arguments[1][item];
}
}
享元模式的應用
還是以書爲例子,實現一個功能:每本書都要打印出自己的書名。
先來看看沒用享元模式之前代碼的樣子
const books = [
{name: "計算機網絡", category: "技術類"},
{name: "算法導論", category: "技術類"},
{name: "計算機組成原理", category: "技術類"},
{name: "傲慢與偏見", category: "文學類"},
{name: "紅與黑", category: "文學類"},
{name: "圍城", category: "文學類"}
]
class Book {
constructor(name, category) {
this.name = name;
this.category = category
}
print() {
console.log(this.name, this.category)
}
}
books.forEach((bookData) => {
const book = new Book(bookData.name, bookData.category)
const div = document.createElement("div")
div.innerText = bookData.name
div.addEventListener("click", () => {
book.print()
})
document.body.appendChild(div)
})
上面代碼先創建了書這個對象,然後把這個對象閉包在了點擊事件的回調中,可以想象,如果有一萬本書的話,這段代碼的內存開銷還是很可觀的。現在我們使用享元模式重構這段代碼
思考:如果書的類別有40種,而作者只有10個,那麼挑選哪個屬性作爲內部狀態呢?
當然是作者,因爲這樣只需要創建10個享元對象就行了。
思考:爲何不乾脆定義一個沒有內部狀態的享元對象得了,那樣只有一個享元對象用於共享?
這樣當然是可以的,實際上變得跟單例模式很像,唯一的區別就是多了對外部狀態的注入。
實際上內部狀態越少,要注入的外部狀態自然越多,而且爲了代碼的複用性,會讓內部狀態儘可能多。
const books = new Array(10000).fill(0).map((v, index) => {
return Math.random() > 0.5 ? {
name: `計算機科學${index}`,
category: '技術類'
} : {
name: `傲慢與偏見${index}`,
category: '文學類類'
}
})
class FlyweightBook {
constructor(category) {
this.category = category
}
// 用於享元對象獲取外部狀態
getExternalState(state) {
for(const p in state) {
this[p] = state[p]
}
}
print() {
console.log(this.name, this.category)
}
}
// 然後定義一個工廠,來爲我們生產享元對象
// 注意,這段代碼實際上用了單例模式,每個享元對象都爲單例, 因爲我們沒必要創建多個相同的享元對象
const flyweightBookFactory = (function() {
const flyweightBookStore = {}
return function (category) {
if (flyweightBookStore[category]) {
return flyweightBookStore[category]
}
const flyweightBook = new FlyweightBook(category)
flyweightBookStore[category] = flyweightBook
return flyweightBook
}
})()
// DOM的享元對象
class Div {
constructor() {
this.dom = document.createElement("div")
}
getExternalState(extState, onClick) {
// 獲取外部狀態
this.dom.innerText = extState.innerText
// 設置DOM位置
this.dom.style.top = `${extState.seq * 22}px`
this.dom.style.position = `absolute`
this.dom.onclick = onClick
}
mount(container) {
container.appendChild(this.dom)
}
}
const divFactory = (function() {
const divPool = []; // 對象池
return function(innerContainer) {
let div
if (divPool.length <= 20) {
div = new Div()
divPool.push(div)
} else {
// 滾動行爲,在超過20個時,複用池中的第一個實例,返回給調用者
div = divPool.shift()
divPool.push(div)
}
div.mount(innerContainer)
return div
}
})()
// 外層container,用戶可視區域
const container = document.createElement("div")
// 內層container, 包含了所有DOM的總高度
const innerContainer = document.createElement("div")
container.style.maxHeight = '400px'
container.style.width = '200px'
container.style.border = '1px solid'
container.style.overflow = 'auto'
innerContainer.style.height = `${22 * books.length}px` // 由每個DOM的總高度算出內層container的高度
innerContainer.style.position = `relative`
container.appendChild(innerContainer)
document.body.appendChild(container)
function load(start, end) {
// 裝載需要顯示的數據
books.slice(start, end).forEach((bookData, index) => {
// 先生產出享元對象
const flyweightBook = flyweightBookFactory(bookData.category)
const div = divFactory(innerContainer)
// DOM的高度需要由它的序號計算出來
div.getExternalState({innerText: bookData.name, seq: start + index}, () => {
flyweightBook.getExternalState({name: bookData.name})
flyweightBook.print()
})
})
}
load(0, 20)
let cur = 0 // 記錄當前加載的首個數據
container.addEventListener('scroll', (e) => {
const start = container.scrollTop / 22 | 0
if (start !== cur) {
load(start, start + 20)
cur = start
}
})
以上代碼僅僅使用了2個享元對象,21個DOM對象,就完成了10000條數據的渲染,相比起建立10000個book對象和10000個DOM,性能優化是非常明顯的。
享元模式的優缺點
優點
減少內存開銷
提供了一種方便的管理大量相似對象的方法
缺點
享元模式需要分離內部狀態和外部狀態,會使邏輯變得更加複雜
外部狀態被分離出去後,訪問會產生輕微的額外時耗(時間換空間?)
P.S.如果項目中需要創建大量實例對象,就應該考慮一下享元模式是否適用
參考文章:
JavaScript設計模式
享元模式_JavaScript設計模式12