摘要
享元模式是用于性能优化的设计模式之一,在前端编程中有重要的应用,尤其是在大量渲染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