摘要
享元模式是用于性能优化的设计模式之一,在前端编程中有重要的应用,尤其是在大量渲染DOM的时候,使用享元模式及对象池技术能获得极大优化。本文介绍了享元模式的概念,并将其用于渲染大量列表数据的优化上。
初识享元模式
在面向对象编程中,有时会重复创建大量相似的对象,当这些对象不能被垃圾回收的时候(比如被闭包在一个回调函数中)就会造成内存的高消耗,在循环体里创建对象时尤其会出现这种情况。享元模式提出了一种对象复用的技术,即我们不需要创建那么多对象,只需要创建若干个能够被复用的对象(享元对象),然后在实际使用中给享元对象注入差异,从而使对象有不同的表现。
为了要创建享元对象,首先要把对象的数据划分为内部状态和外部状态,具体何为内部状态,何为外部状态取决于你想要创建什么样的享元对象。
举个例子:
书这个类,我想创建的享元对象是“技术类书籍”,让所有技术类的书都共享这个对象,那么书的类别就是内部状态;而书的书名,作者可能是每本书都不一样的,那么书的书名和作者就是外部状态。或者换一种方式,我想创建“村上春树写的书”这种享元对象,然后让所有村上春树写的书都共享这个享元对象,此时书的作者就为内部状态。当然也可以让作者、分类同时为内部状态创建一个享元对象。
享元对象可以按照内部状态的不同创建若干个,比如技术类书,文学类书,鸡汤类书三个。在实践的时候会发现,抽象程度越高,所创建的享元对象就越少,但是外部状态就越多;相反抽象程度越低,所需创建的享元对象就越多,外部状态就越少。特别地,当对象的所有状态都归为内部状态时,此时每个对象都可以看作一个享元对象,但是没有被共享,相当于没用享元模式。
享元模式的应用
还是以书为例子,实现一个功能:每本书都要打印出自己的书名。
先来看看没用享元模式之前代码的样子
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)
})
上面代码先创建了书这个对象,然后把这个对象闭包在了点击事件的回调中,可以想象,如果有一万本书的话,这段代码的内存开销还是很可观的。现在我们使用享元模式重构这段代码
// 先定义享元对象
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
}
})()
// 然后我们要使用享元对象, 在享元对象被调用的时候,能够得到它的外部状态
books.forEach((bookData) => {
// 先生产出享元对象
const flyweightBook = flyweightBookFactory(bookData.category)
const div = document.createElement("div")
div.innerText = bookData.name
div.addEventListener("click", () => {
// 给享元对象设置外部状态
flyweightBook.getExternalState({name: bookData.name}) // 外部状态为书名
flyweightBook.print()
})
document.body.appendChild(div)
})
可以看到以上代码仅仅闭包了两个享元对象,因为书仅有两种类别。两个享元对象是在使用的时候才获取到了外部状态,从而在使用时表现出对象本来应有的样子。
思考:如果书的类别有40种,而作者只有10个,那么挑选哪个属性作为内部状态呢?
当然是作者,因为这样只需要创建10个享元对象就行了。
思考:为何不干脆定义一个没有内部状态的享元对象得了,那样只有一个享元对象用于共享?
这样当然是可以的,实际上变得跟单例模式很像,唯一的区别就是多了对外部状态的注入。
实际上内部状态越少,要注入的外部状态自然越多,而且为了代码的复用性,会让内部状态尽可能多。
在一些代码中会有一个专门用来管理外部状态的一个实例,这个实例保存了所有对象的外部状态,同时提供了一个接口给享元对象来获取这些外部状态(通过id或其它唯一索引)。
对象池技术与享元模式
在上面例子中会发现,每增加一本书就会多一个DOM,哪怕享元对象只有两个,而DOM上万个的话,页面的性能也是很差的。我们发现,每实例化一个DOM,只有它的innerText是不同的,那么我们把DOM的innerText当做外部状态,其它当做内部状态,构造出享元对象DOM:
class Div {
constructor() {
this.dom = document.createElement("div")
}
getExternalState(extState) {
// 获取外部状态
this.dom.innerText = extState.innerText
}
mount(container) {
container.appendChild(this.dom)
}
}
那么什么东西能作为内部状态呢?在这里其实不需要内部状态的,因为我们关注的是享元对象的个数,比如页面上最多显示20个DOM的话,那么我们就创建20个DOM用来给真正的实例去共享:
const divFactory = (function() {
const divPool = []; // 对象池
return function() {
if (divPool.length <= 20) {
const div = new Div()
divPool.push(div)
return div
} else {
// 滚动行为,在超过20个时,复用池中的第一个实例,返回给调用者
const div = divPool.shift()
divPool.push(div)
return div
}
}
})()
这个工厂就像奸商一样,在20个之前还是好好的,每次创建一个div都是新的,到了20个之后,就拿一些老的div返回给调用者,调用者会发现这个老的div会包含一些老的数据(像翻新机一样),但是调用者不关心,因为他会用新的数据覆盖掉老的数据。
接下来看调用者如何使用
// 先创建一个容器,因为不把DOM直接挂在document.body里了
const container = document.createElement("div")
books.forEach((bookData) => {
// 先生产出享元对象
const flyweightBook = flyweightBookFactory(bookData.category)
// const div = document.createElement("div")
// div.innerText = bookData.name
const div = divFactory()
div.getExternalState({innerText: bookData.name})
// 如果要添加事件的话,在Div里面提供接口添加,在这里会造成重复添加
// div.dom.addEventListener("click", () => {
// 给享元对象设置外部状态
// flyweightBook.getExternalState({name: bookData.name}) // 外部状态为书名
// flyweightBook.print()
// })
div.mount(container)
// document.body.appendChild(div)
})
document.body.appendChild(container)
以上代码会发现,DOM确实被复用了,但是总是显示最后的二十个,这是自然的,可以通过监听滚动事件,实现在滚动的时候加载相应的数据,同时DOM被复用,B站的弹幕列表就是用了相似的技术实现的,以下是全部代码:
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,性能优化是非常明显的。
以上,水平有限,如有纰漏,欢迎斧正。
网友评论