JavaScript 设计模式(中)——9.享元模式
9 享元模式
享元( flyweight)模式是一种用于性能优化的模式,享元模式的核心是运用共享技术来有效支持大量细粒度的对象;
9.1 享元模式简单示例
假设目前加工好了50件男士外套和50件女士外套,需要使用塑料模特拍照,正常情况下需要 50 个男模特和 50 个女模特,然后让他们每人分别穿上一件外套来拍照。不使用享元模式的情况下,在程序里也许会这样写:
var Model = function( sex, underwear){
this.sex = sex;
this.underwear= underwear;
};
Model.prototype.takePhoto = function(){
console.log( 'sex= ' + this.sex + ' underwear=' + this.underwear);
};
for ( var i = 1; i <= 50; i++ ){
var maleModel = new Model( 'male', 'underwear' + i );
maleModel.takePhoto();
};
for ( var j = 1; j <= 50; j++ ){
var femaleModel= new Model( 'female', 'underwear' + j );
femaleModel.takePhoto();
};
考虑一下如何优化这个场景,其实男模特和女模特各自有一个就足够,代码调整如下:
var Model = function( sex ){ this.sex = sex; };
Model.prototype.takePhoto = function(){
console.log( 'sex= ' + this.sex + ' underwear=' + this.underwear);
};
var maleModel = new Model( 'male' ), femaleModel = new Model( 'female' );
for ( var i = 1; i <= 50; i++ ){
maleModel.underwear = 'underwear' + i;
maleModel.takePhoto();
};
for ( var j = 1; j <= 50; j++ ){
femaleModel.underwear = 'underwear' + j;
femaleModel.takePhoto();
};
9.2 内部状态与外部状态
享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性),享元模式的目标是尽量减少共享对象的数量;
1.如何划分内部状态和外部状态:
- 内部状态存储于对象内部;
- 内部状态可以被一些对象共享;
- 内部状态独立于具体的场景,通常不会改变;
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享;
这样便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象;组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量,因此享元模式是一种用时间换空间的优化模式;
- 分析上面的例子:
在上面的例子中,性别是内部状态,外套是外部状态,通过区分这两种状态,大大减少了系统中的对象数量。通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象,因为性别通常只有男女两种,所以最多只需要 2 个对象;
在上面的例子中,存在的一些问题以及解决方法:
- 通过构造函数显式
new
出了男女两个model
对象,在其他系统中,并不是一开始就需要所有的共享对象;因此通过一个对象工厂来解决,只有当某种共享对象被真正需要时,它才从工厂中被创建出来; - 给
model
对象手动设置了underwear
外部状态,在更复杂的系统中,这不是一个最好的方式,因为外部状态可能会相当复杂,它们与共享对象的联系会变得困难;因此用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来;
9.3 文件上传的例子
实现多个文件的上传,上传成功文件后展示文件的信息,并支持删除文件的功能;
- 文件上传基本实现代码:
// 定义 Upload 构造函数,它接受 3 个参数,分别是插件类型、文件名和文件大小
var Upload = function( uploadType, fileName, fileSize ){
this.uploadType = uploadType;
this.fileName = fileName;
this.fileSize = fileSize;
this.dom= null;
};
// upload 对象init函数
Upload.prototype.init = function( id ){
var that = this;
this.id = id;
this.dom = document.createElement( 'div' );
this.dom.innerHTML =
'<span>文件名称:'+ this.fileName +', 文件大小: '+ this.fileSize +'</span>' +
'<button class="delFile">删除</button>';
this.dom.querySelector( '.delFile' ).onclick = function(){ that.delFile(); }
document.body.appendChild( this.dom );
};
// upload 对象删除文件的功能
Upload.prototype.delFile = function(){
if ( this.fileSize < 3000 ){
return this.dom.parentNode.removeChild( this.dom );
}
if ( window.confirm( '确定要删除该文件吗? ' + this.fileName ) ){
return this.dom.parentNode.removeChild( this.dom );
}
};
// 当选择了文件并确认上传后,调用 Window 下的一个全局函数 startUpload,用户选择的文件列表被组合成一个数组 files 塞进该函数的参数列表里,代码如下:
var id = 0;
window.startUpload = function( uploadType, files ){ // uploadType 区分是控件还是 flash
for ( var i = 0, file; file = files[ i++ ]; ){
var uploadObj = new Upload( uploadType, file.fileName, file.fileSize );
uploadObj.init( id++ ); // 给 upload 对象设置一个唯一的 id
}
};
// 插件类型上传文件
startUpload( 'plugin', [
{ fileName: '1.txt', fileSize: 1000 },
{ fileName: '2.html', fileSize: 3000 },
{ fileName: '3.txt', fileSize: 5000 }
]);
// Flash类型上传文件
startUpload( 'flash', [
{ fileName: '4.txt', fileSize: 1000 },
{ fileName: '5.html', fileSize: 3000 },
{ fileName: '6.txt', fileSize: 5000 }
]);
该方式的文件上传中,若一次性上传很多个文件时,每一个文件对应一个上传对象,这种对象爆炸的问题会使得浏览器崩溃;
- 享元模式重构文件上传:
在文件上传的例子里, upload 对象必须依赖 uploadType
属性才能工作,这是因为插件上传、Flash 上传、表单上传的实际工作原理有很大的区别,它们各自调用的接口也是完全不一样的,因此 uploadType
作为内部状态,把其他的外部状态从构造函数中抽离出来,Upload 构造函数中只保留 uploadType
参数;
var Upload = function( uploadType){
this.uploadType = uploadType;
};
同时 Upload.prototype.init
函数也不再需要,因为 upload 对象初始化的工作被放在了 upload�Manager.add
函数里面,接下来只需要定义 Upload.prototype.del
函数即可:
Upload.prototype.delFile = function( id ){
uploadManager.setExternalState( id, this ); // 表示把当前 id 对应的对象的外部状态都组装到共享对象中
if ( this.fileSize < 3000 ){
return this.dom.parentNode.removeChild( this.dom );
}
if ( window.confirm( '确定要删除该文件吗? ' + this.fileName ) ){
return this.dom.parentNode.removeChild( this.dom );
}
工厂进行对象实例化:定义一个工厂来创建 upload 对象,如果某种内部状态对应的共享对象已经被创建过,那么直接返回这个对象,否则创建一个新的对象:
var UploadFactory = (function(){
var createdFlyWeightObjs = {};
return {
create: function( uploadType){
if ( createdFlyWeightObjs [ uploadType] ){
return createdFlyWeightObjs [ uploadType];
}
return createdFlyWeightObjs [ uploadType] = new Upload( uploadType);
}
}
})();
管理器封装外部状态: uploadManager
对象负责向 UploadFactory
提交创建对象的请求,并用一个 uploadDatabase
对象保存所有 upload
对象的外部状态,以便在程序运行过程中给 upload
共享对象设置外部状态,代码如下:
var uploadManager = (function(){
var uploadDatabase = {};
return {
// 创建上传文件函数
add: function( id, uploadType, fileName, fileSize ){
var flyWeightObj = UploadFactory.create( uploadType );
var dom = document.createElement( 'div' );
dom.innerHTML =
'<span>文件名称:'+ fileName +', 文件大小: '+ fileSize +'</span>' +
'<button class="delFile">删除</button>';
dom.querySelector( '.delFile' ).onclick = function(){ flyWeightObj.delFile( id ); }
document.body.appendChild( dom );
uploadDatabase[ id ] = { fileName: fileName, fileSize: fileSize, dom: dom };
return flyWeightObj ;
},
setExternalState: function( id, flyWeightObj ){
var uploadData = uploadDatabase[ id ];
for ( var i in uploadData ){ flyWeightObj[ i ] = uploadData[ i ]; }
}
}
})();
接着是触发上传动作的 startUpload
函数:
var id = 0;
window.startUpload = function( uploadType, files ){
for ( var i = 0, file; file = files[ i++ ]; ){
var uploadObj = uploadManager.add( ++id, uploadType, file.fileName, file.fileSize );
}
};
最后测试,运行下面的代码后,可以发现运行结果跟用享元模式重构之前一致:
// 插件类型上传文件
startUpload( 'plugin', [
{ fileName: '1.txt', fileSize: 1000 },
{ fileName: '2.html', fileSize: 3000 },
{ fileName: '3.txt', fileSize: 5000 }
]);
// Flash类型上传文件
startUpload( 'flash', [
{ fileName: '4.txt', fileSize: 1000 },
{ fileName: '5.html', fileSize: 3000 },
{ fileName: '6.txt', fileSize: 5000 }
]);
9.4 享元模式的适用性
享元模式的适用场景:
- 一个程序中使用了大量的相似对象;
- 由于使用了大量对象,造成很大的内存开销;
- 对象的大多数状态都可以变为外部状态;
- 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象;
9.5 再谈内部状态和外部状态
实现享元模式的关键是把内部状态和外部状态分离开来。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在共享对象的外部,在必要时被传入共享对象来组装成一个完整的对象;现在来考虑两种极端的情况,即对象没有外部状态和没有内部状态的时候;
没有内部状态的享元:
没有外部状态的享元:
2.9.6 对象池
对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接 new
,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后, 再进入池子等待被下次获取。
对象池实现:
假设在一个地图应用中, 地图上经常会出现一些标志地名的小气泡,当搜索附近地图的时候,页面里出现了 2 个小气泡。当我再搜索附近的其他地点时,页面中出现了 6 个小气泡。按照对象池的思想,在第二次搜索开始之前,并不会把第一次创建的2 个小气泡删除掉,而是把它们放进对象池;这样在第二次的搜索结果页面里,只需要再创建 4 个小气泡而不是 6 个;
// 1. 定义一个获取小气泡节点的工厂,作为对象池的数组成为私有属性被包含在工厂闭包,该工厂的 create 方法表示获取一个 div 节点, recover 方法表示回收一个 div 节点:var toolTipFactory = (function(){
var toolTipFactory = (function(){
var toolTipPool = []; // toolTip 对象池
return {
create: function(){
if ( toolTipPool.length === 0 ){ // 如果对象池为空
var div = document.createElement( 'div' ); // 创建一个 dom
document.body.appendChild( div );
return div;
}else{ // 如果对象池里不为空
return toolTipPool.shift(); // 则从对象池中取出一个 dom
}
},
recover: function( tooltipDom ){
return toolTipPool.push( tooltipDom ); // 对象池回收 dom
}
}
})();
// 2. 创建 2 个小气泡节点,并用一个数组 ary 来记录它们
var ary = [];
for ( var i = 0, str; str = [ 'A', 'B' ][ i++ ]; ){
var toolTip = toolTipFactory.create();
toolTip.innerHTML = str;
ary.push( toolTip );
};
// 3. 假设地图需要开始重新绘制,在此之前要把这两个节点回收进对象池:
for ( var i = 0, toolTip; toolTip = ary[ i++ ]; ){
toolTipFactory.recover( toolTip );
};
// 4. 再创建 6 个小气泡:
for ( var i = 0, str; str = [ 'A', 'B', 'C', 'D', 'E', 'F' ][ i++ ]; ){
var toolTip = toolTipFactory.create();
toolTip.innerHTML = str;
};
9.6 享元模式小结
享元模式是为解决性能问题而生的模式,在一个存在大量相似对象的系统中,享元模式可以很好地解决大量对象带来的性能问题;
系列链接
- JavaScript 设计模式(上)——基础知识
- JavaScript 设计模式(中)——1.单例模式
- JavaScript 设计模式(中)——2.策略模式
- JavaScript 设计模式(中)——3.代理模式
- JavaScript 设计模式(中)——4.迭代器模式
- JavaScript 设计模式(中)——5.发布订阅模式
- JavaScript 设计模式(中)——6.命令模式
- JavaScript 设计模式(中)——7.组合模式
- JavaScript 设计模式(中)——8.模板方法模式
- JavaScript 设计模式(中)——9.享元模式
- JavaScript 设计模式(中)——10.职责链模式
- JavaScript 设计模式(中)——11. 中介者模式
- JavaScript 设计模式(中)——12. 装饰者模式
- JavaScript 设计模式(中)——13.状态模式
- JavaScript 设计模式(中)——14.适配器模式
- JavaScript 设计模式(下)——设计原则
- JavaScript 设计模式练习代码
本文主要参考了《JavaScript设计模式和开发实践》一书