摘要
享元模式是用于性能優(yōu)化的設(shè)計模式之一施逾,在前端編程中有重要的應(yīng)用托猩,尤其是在大量渲染DOM的時候肉微,使用享元模式及對象池技術(shù)能獲得極大優(yōu)化。本文介紹了享元模式的概念些楣,并將其用于渲染大量列表數(shù)據(jù)的優(yōu)化上脂凶。
初識享元模式
在面向?qū)ο缶幊讨校袝r會重復(fù)創(chuàng)建大量相似的對象愁茁,當(dāng)這些對象不能被垃圾回收的時候(比如被閉包在一個回調(diào)函數(shù)中)就會造成內(nèi)存的高消耗蚕钦,在循環(huán)體里創(chuàng)建對象時尤其會出現(xiàn)這種情況。享元模式提出了一種對象復(fù)用的技術(shù)鹅很,即我們不需要創(chuàng)建那么多對象嘶居,只需要創(chuàng)建若干個能夠被復(fù)用的對象(享元對象),然后在實(shí)際使用中給享元對象注入差異促煮,從而使對象有不同的表現(xiàn)邮屁。
為了要創(chuàng)建享元對象,首先要把對象的數(shù)據(jù)劃分為內(nèi)部狀態(tài)和外部狀態(tài)菠齿,具體何為內(nèi)部狀態(tài)佑吝,何為外部狀態(tài)取決于你想要創(chuàng)建什么樣的享元對象。
舉個例子:
書這個類绳匀,我想創(chuàng)建的享元對象是“技術(shù)類書籍”芋忿,讓所有技術(shù)類的書都共享這個對象,那么書的類別就是內(nèi)部狀態(tài)疾棵;而書的書名戈钢,作者可能是每本書都不一樣的,那么書的書名和作者就是外部狀態(tài)是尔⊙沉耍或者換一種方式,我想創(chuàng)建“村上春樹寫的書”這種享元對象拟枚,然后讓所有村上春樹寫的書都共享這個享元對象薪铜,此時書的作者就為內(nèi)部狀態(tài)。當(dāng)然也可以讓作者恩溅、分類同時為內(nèi)部狀態(tài)創(chuàng)建一個享元對象痕囱。
享元對象可以按照內(nèi)部狀態(tài)的不同創(chuàng)建若干個,比如技術(shù)類書暴匠,文學(xué)類書鞍恢,雞湯類書三個。在實(shí)踐的時候會發(fā)現(xiàn),抽象程度越高帮掉,所創(chuàng)建的享元對象就越少弦悉,但是外部狀態(tài)就越多;相反抽象程度越低蟆炊,所需創(chuàng)建的享元對象就越多稽莉,外部狀態(tài)就越少。特別地涩搓,當(dāng)對象的所有狀態(tài)都?xì)w為內(nèi)部狀態(tài)時污秆,此時每個對象都可以看作一個享元對象,但是沒有被共享昧甘,相當(dāng)于沒用享元模式良拼。
享元模式的應(yīng)用
還是以書為例子,實(shí)現(xiàn)一個功能:每本書都要打印出自己的書名充边。
先來看看沒用享元模式之前代碼的樣子
const books = [
{name: "計算機(jī)網(wǎng)絡(luò)", category: "技術(shù)類"},
{name: "算法導(dǎo)論", category: "技術(shù)類"},
{name: "計算機(jī)組成原理", category: "技術(shù)類"},
{name: "傲慢與偏見", category: "文學(xué)類"},
{name: "紅與黑", category: "文學(xué)類"},
{name: "圍城", category: "文學(xué)類"}
]
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)
})
上面代碼先創(chuàng)建了書這個對象庸推,然后把這個對象閉包在了點(diǎn)擊事件的回調(diào)中,可以想象浇冰,如果有一萬本書的話贬媒,這段代碼的內(nèi)存開銷還是很可觀的。現(xiàn)在我們使用享元模式重構(gòu)這段代碼
// 先定義享元對象
class FlyweightBook {
constructor(category) {
this.category = category
}
// 用于享元對象獲取外部狀態(tài)
getExternalState(state) {
for(const p in state) {
this[p] = state[p]
}
}
print() {
console.log(this.name, this.category)
}
}
// 然后定義一個工廠肘习,來為我們生產(chǎn)享元對象
// 注意际乘,這段代碼實(shí)際上用了單例模式,每個享元對象都為單例, 因?yàn)槲覀儧]必要創(chuàng)建多個相同的享元對象
const flyweightBookFactory = (function() {
const flyweightBookStore = {}
return function (category) {
if (flyweightBookStore[category]) {
return flyweightBookStore[category]
}
const flyweightBook = new FlyweightBook(category)
flyweightBookStore[category] = flyweightBook
return flyweightBook
}
})()
// 然后我們要使用享元對象, 在享元對象被調(diào)用的時候漂佩,能夠得到它的外部狀態(tài)
books.forEach((bookData) => {
// 先生產(chǎn)出享元對象
const flyweightBook = flyweightBookFactory(bookData.category)
const div = document.createElement("div")
div.innerText = bookData.name
div.addEventListener("click", () => {
// 給享元對象設(shè)置外部狀態(tài)
flyweightBook.getExternalState({name: bookData.name}) // 外部狀態(tài)為書名
flyweightBook.print()
})
document.body.appendChild(div)
})
可以看到以上代碼僅僅閉包了兩個享元對象蚓庭,因?yàn)闀鴥H有兩種類別。兩個享元對象是在使用的時候才獲取到了外部狀態(tài)仅仆,從而在使用時表現(xiàn)出對象本來應(yīng)有的樣子。
思考:如果書的類別有40種垢袱,而作者只有10個墓拜,那么挑選哪個屬性作為內(nèi)部狀態(tài)呢?
當(dāng)然是作者请契,因?yàn)檫@樣只需要創(chuàng)建10個享元對象就行了咳榜。
思考:為何不干脆定義一個沒有內(nèi)部狀態(tài)的享元對象得了,那樣只有一個享元對象用于共享爽锥?
這樣當(dāng)然是可以的涌韩,實(shí)際上變得跟單例模式很像,唯一的區(qū)別就是多了對外部狀態(tài)的注入氯夷。
實(shí)際上內(nèi)部狀態(tài)越少臣樱,要注入的外部狀態(tài)自然越多,而且為了代碼的復(fù)用性,會讓內(nèi)部狀態(tài)盡可能多雇毫。
在一些代碼中會有一個專門用來管理外部狀態(tài)的一個實(shí)例玄捕,這個實(shí)例保存了所有對象的外部狀態(tài),同時提供了一個接口給享元對象來獲取這些外部狀態(tài)(通過id或其它唯一索引)棚放。
對象池技術(shù)與享元模式
在上面例子中會發(fā)現(xiàn)枚粘,每增加一本書就會多一個DOM,哪怕享元對象只有兩個飘蚯,而DOM上萬個的話馍迄,頁面的性能也是很差的。我們發(fā)現(xiàn)局骤,每實(shí)例化一個DOM攀圈,只有它的innerText是不同的,那么我們把DOM的innerText當(dāng)做外部狀態(tài)庄涡,其它當(dāng)做內(nèi)部狀態(tài)量承,構(gòu)造出享元對象DOM:
class Div {
constructor() {
this.dom = document.createElement("div")
}
getExternalState(extState) {
// 獲取外部狀態(tài)
this.dom.innerText = extState.innerText
}
mount(container) {
container.appendChild(this.dom)
}
}
那么什么東西能作為內(nèi)部狀態(tài)呢?在這里其實(shí)不需要內(nèi)部狀態(tài)的穴店,因?yàn)槲覀冴P(guān)注的是享元對象的個數(shù)撕捍,比如頁面上最多顯示20個DOM的話,那么我們就創(chuàng)建20個DOM用來給真正的實(shí)例去共享:
const divFactory = (function() {
const divPool = []; // 對象池
return function() {
if (divPool.length <= 20) {
const div = new Div()
divPool.push(div)
return div
} else {
// 滾動行為泣洞,在超過20個時忧风,復(fù)用池中的第一個實(shí)例,返回給調(diào)用者
const div = divPool.shift()
divPool.push(div)
return div
}
}
})()
這個工廠就像奸商一樣球凰,在20個之前還是好好的狮腿,每次創(chuàng)建一個div都是新的,到了20個之后呕诉,就拿一些老的div返回給調(diào)用者缘厢,調(diào)用者會發(fā)現(xiàn)這個老的div會包含一些老的數(shù)據(jù)(像翻新機(jī)一樣),但是調(diào)用者不關(guān)心甩挫,因?yàn)樗麜眯碌臄?shù)據(jù)覆蓋掉老的數(shù)據(jù)贴硫。
接下來看調(diào)用者如何使用
// 先創(chuàng)建一個容器,因?yàn)椴话袲OM直接掛在document.body里了
const container = document.createElement("div")
books.forEach((bookData) => {
// 先生產(chǎn)出享元對象
const flyweightBook = flyweightBookFactory(bookData.category)
// const div = document.createElement("div")
// div.innerText = bookData.name
const div = divFactory()
div.getExternalState({innerText: bookData.name})
// 如果要添加事件的話伊者,在Div里面提供接口添加英遭,在這里會造成重復(fù)添加
// div.dom.addEventListener("click", () => {
// 給享元對象設(shè)置外部狀態(tài)
// flyweightBook.getExternalState({name: bookData.name}) // 外部狀態(tài)為書名
// flyweightBook.print()
// })
div.mount(container)
// document.body.appendChild(div)
})
document.body.appendChild(container)
以上代碼會發(fā)現(xiàn),DOM確實(shí)被復(fù)用了亦渗,但是總是顯示最后的二十個挖诸,這是自然的,可以通過監(jiān)聽滾動事件法精,實(shí)現(xiàn)在滾動的時候加載相應(yīng)的數(shù)據(jù)多律,同時DOM被復(fù)用痴突,B站的彈幕列表就是用了相似的技術(shù)實(shí)現(xiàn)的,以下是全部代碼:
const books = new Array(10000).fill(0).map((v, index) => {
return Math.random() > 0.5 ? {
name: `計算機(jī)科學(xué)${index}`,
category: '技術(shù)類'
} : {
name: `傲慢與偏見${index}`,
category: '文學(xué)類類'
}
})
class FlyweightBook {
constructor(category) {
this.category = category
}
// 用于享元對象獲取外部狀態(tài)
getExternalState(state) {
for(const p in state) {
this[p] = state[p]
}
}
print() {
console.log(this.name, this.category)
}
}
// 然后定義一個工廠菱涤,來為我們生產(chǎn)享元對象
// 注意苞也,這段代碼實(shí)際上用了單例模式,每個享元對象都為單例, 因?yàn)槲覀儧]必要創(chuàng)建多個相同的享元對象
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) {
// 獲取外部狀態(tài)
this.dom.innerText = extState.innerText
// 設(shè)置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個時如迟,復(fù)用池中的第一個實(shí)例,返回給調(diào)用者
div = divPool.shift()
divPool.push(div)
}
div.mount(innerContainer)
return div
}
})()
// 外層container,用戶可視區(qū)域
const container = document.createElement("div")
// 內(nèi)層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的總高度算出內(nèi)層container的高度
innerContainer.style.position = `relative`
container.appendChild(innerContainer)
document.body.appendChild(container)
function load(start, end) {
// 裝載需要顯示的數(shù)據(jù)
books.slice(start, end).forEach((bookData, index) => {
// 先生產(chǎn)出享元對象
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 // 記錄當(dāng)前加載的首個數(shù)據(jù)
container.addEventListener('scroll', (e) => {
const start = container.scrollTop / 22 | 0
if (start !== cur) {
load(start, start + 20)
cur = start
}
})
以上代碼僅僅使用了2個享元對象,21個DOM對象仇参,就完成了10000條數(shù)據(jù)的渲染尉桩,相比起建立10000個book對象和10000個DOM敷搪,性能優(yōu)化是非常明顯的。
以上,水平有限,如有紕漏贤斜,歡迎斧正。