摘要
享元模式是用于性能優(yōu)化的設(shè)計(jì)模式之一,在前端編程中有重要的應(yīng)用,尤其是在大量渲染DOM的時(shí)候,使用享元模式及對(duì)象池技術(shù)能獲得極大優(yōu)化。本文介紹了享元模式的概念,并將其用于渲染大量列表數(shù)據(jù)的優(yōu)化上。
初識(shí)享元模式
在面向?qū)ο缶幊讨校袝r(shí)會(huì)重復(fù)創(chuàng)建大量相似的對(duì)象,當(dāng)這些對(duì)象不能被垃圾回收的時(shí)候(比如被閉包在一個(gè)回調(diào)函數(shù)中)就會(huì)造成內(nèi)存的高消耗,在循環(huán)體里創(chuàng)建對(duì)象時(shí)尤其會(huì)出現(xiàn)這種情況。享元模式提出了一種對(duì)象復(fù)用的技術(shù),即我們不需要?jiǎng)?chuàng)建那么多對(duì)象,只需要?jiǎng)?chuàng)建若干個(gè)能夠被復(fù)用的對(duì)象(享元對(duì)象),然后在實(shí)際使用中給享元對(duì)象注入差異,從而使對(duì)象有不同的表現(xiàn)。
為了要?jiǎng)?chuàng)建享元對(duì)象,首先要把對(duì)象的數(shù)據(jù)劃分為內(nèi)部狀態(tài)和外部狀態(tài),具體何為內(nèi)部狀態(tài),何為外部狀態(tài)取決于你想要?jiǎng)?chuàng)建什么樣的享元對(duì)象。
舉個(gè)例子:
書這個(gè)類,我想創(chuàng)建的享元對(duì)象是“技術(shù)類書籍”,讓所有技術(shù)類的書都共享這個(gè)對(duì)象,那么書的類別就是內(nèi)部狀態(tài);而書的書名,作者可能是每本書都不一樣的,那么書的書名和作者就是外部狀態(tài)?;蛘邠Q一種方式,我想創(chuàng)建“村上春樹寫的書”這種享元對(duì)象,然后讓所有村上春樹寫的書都共享這個(gè)享元對(duì)象,此時(shí)書的作者就為內(nèi)部狀態(tài)。當(dāng)然也可以讓作者、分類同時(shí)為內(nèi)部狀態(tài)創(chuàng)建一個(gè)享元對(duì)象。
享元對(duì)象可以按照內(nèi)部狀態(tài)的不同創(chuàng)建若干個(gè),比如技術(shù)類書,文學(xué)類書,雞湯類書三個(gè)。在實(shí)踐的時(shí)候會(huì)發(fā)現(xiàn),抽象程度越高,所創(chuàng)建的享元對(duì)象就越少,但是外部狀態(tài)就越多;相反抽象程度越低,所需創(chuàng)建的享元對(duì)象就越多,外部狀態(tài)就越少。特別地,當(dāng)對(duì)象的所有狀態(tài)都?xì)w為內(nèi)部狀態(tài)時(shí),此時(shí)每個(gè)對(duì)象都可以看作一個(gè)享元對(duì)象,但是沒有被共享,相當(dāng)于沒用享元模式。
享元模式的應(yīng)用
還是以書為例子,實(shí)現(xiàn)一個(gè)功能:每本書都要打印出自己的書名。
先來看看沒用享元模式之前代碼的樣子
const books = [
{name: "計(jì)算機(jī)網(wǎng)絡(luò)", category: "技術(shù)類"},
{name: "算法導(dǎo)論", category: "技術(shù)類"},
{name: "計(jì)算機(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)建了書這個(gè)對(duì)象,然后把這個(gè)對(duì)象閉包在了點(diǎn)擊事件的回調(diào)中,可以想象,如果有一萬本書的話,這段代碼的內(nèi)存開銷還是很可觀的。現(xiàn)在我們使用享元模式重構(gòu)這段代碼
// 先定義享元對(duì)象
class FlyweightBook {
constructor(category) {
this.category = category
}
// 用于享元對(duì)象獲取外部狀態(tài)
getExternalState(state) {
for(const p in state) {
this[p] = state[p]
}
}
print() {
console.log(this.name, this.category)
}
}
// 然后定義一個(gè)工廠,來為我們生產(chǎn)享元對(duì)象
// 注意,這段代碼實(shí)際上用了單例模式,每個(gè)享元對(duì)象都為單例, 因?yàn)槲覀儧]必要?jiǎng)?chuàng)建多個(gè)相同的享元對(duì)象
const flyweightBookFactory = (function() {
const flyweightBookStore = {}
return function (category) {
if (flyweightBookStore[category]) {
return flyweightBookStore[category]
}
const flyweightBook = new FlyweightBook(category)
flyweightBookStore[category] = flyweightBook
return flyweightBook
}
})()
// 然后我們要使用享元對(duì)象, 在享元對(duì)象被調(diào)用的時(shí)候,能夠得到它的外部狀態(tài)
books.forEach((bookData) => {
// 先生產(chǎn)出享元對(duì)象
const flyweightBook = flyweightBookFactory(bookData.category)
const div = document.createElement("div")
div.innerText = bookData.name
div.addEventListener("click", () => {
// 給享元對(duì)象設(shè)置外部狀態(tài)
flyweightBook.getExternalState({name: bookData.name}) // 外部狀態(tài)為書名
flyweightBook.print()
})
document.body.appendChild(div)
})
可以看到以上代碼僅僅閉包了兩個(gè)享元對(duì)象,因?yàn)闀鴥H有兩種類別。兩個(gè)享元對(duì)象是在使用的時(shí)候才獲取到了外部狀態(tài),從而在使用時(shí)表現(xiàn)出對(duì)象本來應(yīng)有的樣子。
思考:如果書的類別有40種,而作者只有10個(gè),那么挑選哪個(gè)屬性作為內(nèi)部狀態(tài)呢?
當(dāng)然是作者,因?yàn)檫@樣只需要?jiǎng)?chuàng)建10個(gè)享元對(duì)象就行了。
思考:為何不干脆定義一個(gè)沒有內(nèi)部狀態(tài)的享元對(duì)象得了,那樣只有一個(gè)享元對(duì)象用于共享?
這樣當(dāng)然是可以的,實(shí)際上變得跟單例模式很像,唯一的區(qū)別就是多了對(duì)外部狀態(tài)的注入。
實(shí)際上內(nèi)部狀態(tài)越少,要注入的外部狀態(tài)自然越多,而且為了代碼的復(fù)用性,會(huì)讓內(nèi)部狀態(tài)盡可能多。
在一些代碼中會(huì)有一個(gè)專門用來管理外部狀態(tài)的一個(gè)實(shí)例,這個(gè)實(shí)例保存了所有對(duì)象的外部狀態(tài),同時(shí)提供了一個(gè)接口給享元對(duì)象來獲取這些外部狀態(tài)(通過id或其它唯一索引)。
對(duì)象池技術(shù)與享元模式
在上面例子中會(huì)發(fā)現(xiàn),每增加一本書就會(huì)多一個(gè)DOM,哪怕享元對(duì)象只有兩個(gè),而DOM上萬個(gè)的話,頁(yè)面的性能也是很差的。我們發(fā)現(xiàn),每實(shí)例化一個(gè)DOM,只有它的innerText是不同的,那么我們把DOM的innerText當(dāng)做外部狀態(tài),其它當(dāng)做內(nèi)部狀態(tài),構(gòu)造出享元對(duì)象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)注的是享元對(duì)象的個(gè)數(shù),比如頁(yè)面上最多顯示20個(gè)DOM的話,那么我們就創(chuàng)建20個(gè)DOM用來給真正的實(shí)例去共享:
const divFactory = (function() {
const divPool = []; // 對(duì)象池
return function() {
if (divPool.length <= 20) {
const div = new Div()
divPool.push(div)
return div
} else {
// 滾動(dòng)行為,在超過20個(gè)時(shí),復(fù)用池中的第一個(gè)實(shí)例,返回給調(diào)用者
const div = divPool.shift()
divPool.push(div)
return div
}
}
})()
這個(gè)工廠就像奸商一樣,在20個(gè)之前還是好好的,每次創(chuàng)建一個(gè)div都是新的,到了20個(gè)之后,就拿一些老的div返回給調(diào)用者,調(diào)用者會(huì)發(fā)現(xiàn)這個(gè)老的div會(huì)包含一些老的數(shù)據(jù)(像翻新機(jī)一樣),但是調(diào)用者不關(guān)心,因?yàn)樗麜?huì)用新的數(shù)據(jù)覆蓋掉老的數(shù)據(jù)。
接下來看調(diào)用者如何使用
// 先創(chuàng)建一個(gè)容器,因?yàn)椴话袲OM直接掛在document.body里了
const container = document.createElement("div")
books.forEach((bookData) => {
// 先生產(chǎn)出享元對(duì)象
const flyweightBook = flyweightBookFactory(bookData.category)
// const div = document.createElement("div")
// div.innerText = bookData.name
const div = divFactory()
div.getExternalState({innerText: bookData.name})
// 如果要添加事件的話,在Div里面提供接口添加,在這里會(huì)造成重復(fù)添加
// div.dom.addEventListener("click", () => {
// 給享元對(duì)象設(shè)置外部狀態(tài)
// flyweightBook.getExternalState({name: bookData.name}) // 外部狀態(tài)為書名
// flyweightBook.print()
// })
div.mount(container)
// document.body.appendChild(div)
})
document.body.appendChild(container)
以上代碼會(huì)發(fā)現(xiàn),DOM確實(shí)被復(fù)用了,但是總是顯示最后的二十個(gè),這是自然的,可以通過監(jiān)聽滾動(dòng)事件,實(shí)現(xiàn)在滾動(dòng)的時(shí)候加載相應(yīng)的數(shù)據(jù),同時(shí)DOM被復(fù)用,B站的彈幕列表就是用了相似的技術(shù)實(shí)現(xiàn)的,以下是全部代碼:
const books = new Array(10000).fill(0).map((v, index) => {
return Math.random() > 0.5 ? {
name: `計(jì)算機(jī)科學(xué)${index}`,
category: '技術(shù)類'
} : {
name: `傲慢與偏見${index}`,
category: '文學(xué)類類'
}
})
class FlyweightBook {
constructor(category) {
this.category = category
}
// 用于享元對(duì)象獲取外部狀態(tài)
getExternalState(state) {
for(const p in state) {
this[p] = state[p]
}
}
print() {
console.log(this.name, this.category)
}
}
// 然后定義一個(gè)工廠,來為我們生產(chǎn)享元對(duì)象
// 注意,這段代碼實(shí)際上用了單例模式,每個(gè)享元對(duì)象都為單例, 因?yàn)槲覀儧]必要?jiǎng)?chuàng)建多個(gè)相同的享元對(duì)象
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的享元對(duì)象
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 = []; // 對(duì)象池
return function(innerContainer) {
let div
if (divPool.length <= 20) {
div = new Div()
divPool.push(div)
} else {
// 滾動(dòng)行為,在超過20個(gè)時(shí),復(fù)用池中的第一個(gè)實(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` // 由每個(gè)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)出享元對(duì)象
const flyweightBook = flyweightBookFactory(bookData.category)
const div = divFactory(innerContainer)
// DOM的高度需要由它的序號(hào)計(jì)算出來
div.getExternalState({innerText: bookData.name, seq: start + index}, () => {
flyweightBook.getExternalState({name: bookData.name})
flyweightBook.print()
})
})
}
load(0, 20)
let cur = 0 // 記錄當(dāng)前加載的首個(gè)數(shù)據(jù)
container.addEventListener('scroll', (e) => {
const start = container.scrollTop / 22 | 0
if (start !== cur) {
load(start, start + 20)
cur = start
}
})
以上代碼僅僅使用了2個(gè)享元對(duì)象,21個(gè)DOM對(duì)象,就完成了10000條數(shù)據(jù)的渲染,相比起建立10000個(gè)book對(duì)象和10000個(gè)DOM,性能優(yōu)化是非常明顯的。
以上,水平有限,如有紕漏,歡迎斧正。