享元模式
享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。
一个内衣工厂,有50个男性内衣产品和50个女性内衣产品。现在需要请一些模特穿上内衣来拍详情。通常应该怎么做?如何用代码描述这个过程?
你可能这么写:
代码语言:javascript复制class Model{
constructor({sex,underwear}){
this.sex=sex;
this.underwear=underwear;
}
takePhoto(){
console.log(this.sex,this.underwear)
}
}
for(let i=0;i<50;i ){
const model=new Model({sex:'male',underwear:`underwaer-${i}`});
model.takePhoto();
}
for(let i=0;i<50;i ){
const model=new Model({sex:'female',underwear:`underwaer-${i}`});
model.takePhoto();
}
如果按照此算法,100个产品将new出100个model对象,如果增加到1w,10w个,很快就会崩溃。
实际上,男女模特一个就够了。
代码语言:javascript复制class Model{
constructor(sex){
this.sex=sex;
}
takePhoto(){
console.log(this.sex,this.underwear)
}
}
const maleModel=new Model('male');
const femaleModel=new Model('female');
for(let i=0;i<50;i ){
maleModel.underwear=`underwaer-${i}`;
maleModel.takePhoto();
}
for(let i=0;i<50;i ){
femaleModel.underwear=`underwaer-${i}`;
femaleModel.takePhoto();
}
上面的代码很形象地描述了"穿内衣"这个过程,事实就是这样,请一男一女两个模特,然后就可以拍照了。
这个例子便是享元模式的雏形,享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。内部状态存储于对象内部。内部状态可以被一些对象共享。内部状态独立于具体的场景,通常不会改变。外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来并储存在外部。
在上面的例子中,性别是内部状态,内衣是外部状态,通过区分这两种状态,大大减少了系统中的对象数量。通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象,因为性别通常只有男女两种,所以该内衣厂商最多只需要2个对象。
通用结构
上述的代码还存在问题:
- 我们通过构造函数显式new出了男女两个model对象,在其他系统中,也许并不是一开始就需要所有的共享对象。
- 给model对象手动设置了underwear外部状态,不是一个最好的方式,因为外部状态可能会相当复杂,它们与共享对象的联系会变得困难。
第一个问题可以通过对象工厂来解决,只有当某种共享对象被真正需要时,它才从工厂中被创建出来。对于第二个问题,可以用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来。
听起来很扯,现在就以文件上传为例。
文件上传
下面看一个实际工作中遇到的问题。(重点)
项目背景:对象爆炸
一个文件上传的功能,要求支持2000个文件。原理如下:上传分为不同的模式(uploadType)比如flash上传,表单上传等。当用户选择了文件之后,控件都会通知调用一个全局startUploadt函数,用户选择的文件列表被组合成一个数组files塞进该函数的参数列表里:
代码语言:javascript复制let id=0;
const startUpload=(uploadType,files)=>{
files.forEach((file)=>{
const {fileName,fileSize}=file;
const uploadObj=new Upload(uploadType,{fileName,fileSize});
uploadObj.init(id );//设置id
})
}
其中Upload对象是这么写的:
代码语言:javascript复制class Upload{
constructor(uploadType,{fileName,fileSize}){
this.uploadType=uploadType;
this.fileName=fileName;
this.fileSize=fileSize;
this.dom=null;
}
init(id){
this.id=id;
this.dom=document.createElement('div');
this.dom.innerHTML=
`<span>文件名:${this.fileName},文件大小:${this.fileSize}</span>
<a id="del${id}" style="color:red;" href="javascript:void(0)">删除</a>
`;
document.querySelector(`del${id}`).addEventListener('click',(e)=>{
this.delFile(id);
})
document.body.appendChild(this.dom)
}
delFile(id){
//删除业务逻辑,文件小于3000k,直接删除,否则confirm提示。
if(this.fileSize>3000){
if(window.confirm('确定删除嘛?')){
this.dom.parentNode.removeChild(this.dom);
}
}else{
this.dom.parentNode.removeChild(this.dom);
}
}
}
假设每个文件都需要一个生成一个upload对象,在数据量到达2000个的时候,直接假死。
尝试用享元模式重构它。
首先要区分外部状态和内部状态,一般来说内部状态和外部状态包括以下要点:
- 内部状态储存于对象内部。
- 内部状态可以被一些对象共享。外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
- 内部状态独立于具体的场景,通常不会改变。
显然符合条件的内部状态只有 uploadType
。那么fileName和fileSize都可以不作为Upload的内置参数了。正如穿衣这个动作,设置对象外部状态也可以通过一个管理器实现。
class Upload{
constructor(uploadType){
this.uploadType=uploadType;
this.dom=null;
}
delFile(id){
// 此处应有一条语句设置外部状态到this上拿到fileName和fileSize并绑定到this
// ...
if(this.fileSize>3000){
if(window.confirm('确定删除嘛?')){
this.dom.parentNode.removeChild(this.dom);
}
}else{
this.dom.parentNode.removeChild(this.dom);
}
}
// ...
}
其次,实现一个工厂函数来创建Upload对象。这里很像是一个单例的创建。
代码语言:javascript复制const upLoadFactory=(()=>{
let createdFlyWeightObjs={};
return {
create:function(uploadType){
if(createdFlyWeightObjs[uploadType]){
return createdFlyWeightObjs[uploadType];
}
createdFlyWeightObjs[uploadType]=new Upload(uploadType);
return createdFlyWeightObjs[uploadType];
}
}
})();
接下来就是设置一个upload管理器(uploadManager),期望实现以下功能:
- 完成原有对象init的主要工作,向工厂函数提交创建upload对象的请求
- 用一个uploadDatabase对象保存所有upload对象的外部状态(fileName和fileSize),以便在程序运行过程中给upload共享对象设置外部状态,
const uploadManager = (() => {
// 共享池
let uploadDataBase = {};
return {
add: function (id, uploadType, fileName, fileSize) {
const filyWeightObj = upLoadFactory.create(uploadType);
const dom = document.createElement('div');
dom.innerHTML =
`<span>文件名:${fileName},文件大小:${fileSize}</span>
<a id="del${id}" style="color:red;" href="javascript:void(0)">删除</a>
`;
document.querySelector(`del${id}`).addEventListener('click', (e) => {
filyWeightObj.delFile(id);
})
document.body.appendChild(dom);
uploadDataBase[id]={uploadType, fileName, fileSize};
return filyWeightObj;
},
// 期望使用uploadManager.setExternalState(id,this)来绑定到this上
setExternalState:function(id,filyWeightObj){
let uploadData=uploadDataBase[id];
Object.keys(uploadData).forEach((key)=>{
filyWeightObj[key]=uploadData[key];
})
}
}
})();
add方法拿到upload对象,完成业务逻辑(因为只执行一次),再把上传信息放到状态共享池中。而 setExternalState
通过id拿到共享池对应的状态,绑定到upload对象的this上。
uploadManager.setExternalState(id,this)
那么,调用方式不再是循环内new一个Upload,而是
代码语言:javascript复制const startUpload = (uploadType, files) => {
files.forEach((file) => {
const { fileName, fileSize } = file;
cuploadManager.add( id,uploadType,file.fileName,file.fileSize);
})
}
最后无论你有几个上传类型,就new几个对象,而不再是一个循环new一个上传对象,大大减少了内存。
适用场景
享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,从前面两组代码的比较可以看到,使用了享元模式之后,我们需要分别多维护一个factory对象和一个manager对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。
享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。
- 程序中使用了大量的相似对象。由于使用了大量对象,造成很大的内存开销。
- 对象的大多数状态都可以变为外部状态。剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。
使用小结
我们可以尝试提炼享元模式的实质:实现享元模式的关键是把内部状态和外部状态分离开来,它的实现过程就是剥离外部状态。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在其他地方(共享对象的外部),在必要时被传入共享对象来组装成一个完整的对象。
享元模式和单例模式有什么区别?设想我们去掉内部状态:
代码语言:javascript复制const upLoadFactory=(()=>{
return {
create:function(){
if(upload){
return upload;
}
upload=new Upload();
return upload;
}
}
})();
这里就完成变成了单例工厂。在这种场景下习惯上仍然被称为享元模式。
但是如果去掉外部状态,你的对象池就有可能会变得异常庞大,那就不是享元模式了。
共享对象池
有内部状态的情况下,对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接new,而是转从对象池里获取。如果对象池里没有空闲对象,就往里面加一个。
图书馆就是一个很好的例子,一个班级50人,人手一套《十万个为什么》显然是不划算的。书架上有这套书,借就是了。
对象池技术的应用非常广泛,HTTP连接池和数据库连接池都是其代表应用。在Web前端开发中,对象池使用最多的场景大概就是跟DOM有关的操作。很多空间和时间都消耗在了DOM节点上,如何避免频繁地创建和删除DOM节点就成了一个有意义的话题。