Javascript享元模式
- 1 什么是享元模式
- 2 内部状态与外部状态
- 3 享元模式的通用结构
- 4 文件上传
- 4.1 对象爆炸
- 4.2 享元模式重构
- 5 没有内部状态的享元模式
- 6 对象池
- 7 通用对象池实现
1 什么是享元模式
享元(flyweight
)模式是一种用于性能优化的模式,“fly
”在这里是苍蝇的意思,意为蝇量级。
享元模式的核心是运用共享技术来有效支持大量细粒度的对象。如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。
假设服装店新到了50套男士衣服和50套女士衣服,为了推销出去,店里决定买一些模特来穿上衣服进行宣传。一般情况下,需要50个男模特和50个女模特,每个模特穿上衣服拍照,实现代码如下:
var Model = function (sex, underwear) {
this.sex = sex;
this.underwear = underwear;
};
Model.prototype.takePhote = function () {
console.log("sex=" + this.sex + "underwear=" + this.underwear);
};
for (let i = 1; i <= 50; i++) {
var maleModel = new Model("male", "underwear" + i);
maleModel.takePhote();
}
for (let j = 1; j <= 50; j++) {
var femaleModel = new Model("female", "underwear" + j);
femaleModel.takePhote();
}
如果要得到一张照片,每次都需要传入sex
和underwear
参数,如上所示,现在一共有50套男款服装和50套女款服装,所以一共会产生100个对象。如果之后有10000套衣服,那这个程序可能会因为存在如此多的对象已经提前崩溃。
其实我们可以想到,虽然有100套衣服,但很显然并不需要50个男模特和50个女模特,男模特和女模特各自有一个就足够了,他们可以分别穿上不同的衣服来拍照。现在我们根据以上逻辑改写一下代码:
var Model = function (sex) {
this.sex = sex;
};
Model.prototype.takePhote = function () {
console.log("sex=" + this.sex + ", underwear=" + this.underwear);
};
// 首先分别创建一个男模特和一个女模特
var maleModel = new Model("male");
var femaleModel = new Model("female");
// 依次让男模特穿上所有的男装拍照
for (let i = 1; i <= 50; i++) {
maleModel.underwear = "underwear" + i;
maleModel.takePhote();
}
// 依次让女模特穿上所有的女装拍照
for (let j = 1; j <= 50; j++) {
femaleModel.underwear = "underwear" + j;
femaleModel.takePhote();
}
我们可以看到,改进之后的代码,只有两个对象,就可以完成同样的拍照的任务。
2 内部状态与外部状态
上面的这个例子便是享元模式的雏形,享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量。
那么如何区分内部状态和外部状态呢,我们可以根据下面这几条特征来区分:
- 内部状态存储于对象内部
- 内部状态可以被一些对象共享
- 内部状态独立于具体的场景,通常不会改变
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享
这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。
剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量。因此,享元模式是一种用时间换空间的优化模式。
在上面的例子中,性别是内部状态,衣服是外部状态,通过区分这两种状态,大大减少了系统中的对象数量。
3 享元模式的通用结构
上面展示的例子还不是一个完整的享元模式,在这个例子中还存在以下两个问题:
- 我们通过构造函数显式
new
出了男女两个model
对象,在其他系统中,也许并不是一开始就需要所有的共享对象 - 给
model
对象手动设置了underwear
外部状态,在更复杂的系统中,这不是一个最好的方式,因为外部状态可能会相当复杂,它们与共享对象的联系会变得困难。
我们通过一个对象工厂来解决第一个问题,只有当某种共享对象被真正需要时,它才从工厂中被创建出来。对于第二个问题,可以用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来。
4 文件上传
4.1 对象爆炸
什么是对象爆炸呢,比如说在文件上传功能中,可以选择依照队列一个一个地排队上传,也可以同时选择2000个文件。每一个文件都对应着一个JavaScript
上传对象的创建,那么同时上传2000个文件,程序中就需要同时new
2000个upload
对象,这对浏览器会造成很大的冲击。
比如我们要实现使用插件或者Flash
上传文件的功能,当用户选择了需要上传的文件之后,它们会去通知调用Window
下的一个全局Javascript
函数startUpload
,用户选择的文件列表被组合成一个数组files
塞进该函数的参数列表种,代码如下:
var id = 0;
window.startUpload = function (uploadType, files) {
for (let i = 0, file; (file = files[i++]); ) {
var uploadObj = new upload(uploadType, file.fileName, file.fileSize);
uploadObj.init(id++); // 为upload对象设置一个唯一的id
}
};
当用户选择完文件之后,startUpload
函数会遍历files
数组来创建对应的upload
对象。接下来定义Upload
构造函数,它接受3个参数,分别是插件类型、文件名和文件大小。这些信息都已经被插件组装在files
数组里返回,代码如下:
var Upload = function (uploadType, fileName, fileSize) {
this.uploadType = uploadType;
this.fileName = fileName;
this.fileSize = fileSize;
this.dom = null;
};
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
。该方法中有一个逻辑:当被删除的文件小于3000KB
时,该文件将被直接删除,否则页面中会弹出一个提示框,提示用户是否确认要删除该文件,代码如下:
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);
}
};
接下来分别创建3个插件上传对象和3 个Flash
上传对象:
startUpload("plugin", [
{ fileName: "1.txt", fileSize: 1000 },
{ fileName: "2.html", fileSize: 3000 },
{ fileName: "3.txt", fileSize: 5000 },
]);
startUpload("flash", [
{ fileName: "4.txt", fileSize: 1000 },
{ fileName: "5.html", fileSize: 3000 },
{ fileName: "6.txt", fileSize: 5000 },
]);
4.2 享元模式重构
首先确认内部状态和外部状态,在上面的例子中,只有上传类型uploadType
是内部状态,在文件上传的例子里,upload
对象必须依赖uploadType
属性才能工作,这是因为插件上传、Flash
上传、表单上传的实际工作原理有很大的区别,它们各自调用的接口也是完全不一样的,必须在对象创建之初就明确它是什么类型的插件,才可以在程序的运行过程中,让它们分别调用各自的方法。
无论我们使用什么方式上传,这个上传对象都是可以被任何文件共用的。而fileName
和fileSize
是根据场景而变化的,每个文件的fileName
和fileSize
都不一样, 它们只能被划分为外部状态。
明确了uploadType
作为内部状态之后,我们再把其他的外部状态从构造函数中抽离出来,Upload
构造函数中只保留uploadType
参数:
var Upload = function (uploadType) {
this.uploadType = uploadType;
};
Upload.prototype.init
函数也不再需要,因为upload
对象初始化的工作被放在了uploadManager.setExternalState
函数里面,接下来只需要定义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];
}
},
};
})();
触发上传动作:
var id = 0;
window.startUpload = function (uploadType, files) {
for (var i = 0, file; (file = files[i++]); ) {
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 },
]);
startUpload("flash", [
{ fileName: "4.txt", fileSize: 1000 },
{ fileName: "5.html", fileSize: 3000 },
{ fileName: "6.txt", fileSize: 5000 },
]);
享元模式重构之前的代码里一共创建了6个upload
对象,而通过享元模式重构之后,对象的数量减少为2,更幸运的是, 就算现在同时上传2000
个文件,需要创建的upload
对象数量依然是2。
5 没有内部状态的享元模式
在文件上传的例子中,我们分别进行过插件调用和Flash
调用,导致程序中创建了内部状态不同的两个共享对象。但是在文件上传程序里,一般都会提前通过特性检测来选择一种上传方式,如果浏览器支持插件就用插件上传,如果不支持插件,就用Flash
上传。
那么这种情况下,之前作为内部状态存在的uploadType
属性是可以删掉的,在继续使用享元模式的前提下,构造函数Upload
就变成了无参数的形式:
var Upload = function () {};
其他属性如依然可以作为外部状态保存在共享对象外部,改写创建享元对象的工厂,代码如下:
// 使用工厂模式进行对象实例化
var UploadFactory = (function () {
var uploadObj;
return {
create: function () {
if (uploadObj) {
return uploadObj;
}
return (uploadObj = new Upload());
},
};
})();
管理器部分的代码不需要改动,还是负责剥离和组装外部状态。可以看到,当对象没有内部状态的时候,生产共享对象的工厂实际上变成了一个单例工厂。虽然这时候的共享对象没有内部状态的区分,但还是有剥离外部状态的过程,我们依然倾向于称之为享元模式。
6 对象池
对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接new
,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后, 再进入
池子等待被下次获取。
对象池技术的应用非常广泛,HTTP
连接池和数据库连接池都是其代表应用。在Web
前端开发中,对象池使用最多的场景大概就是跟DOM
有关的操作。很多空间和时间都消耗在了DOM
节点上,如何避免频繁地创建和删除DOM
节点就成了一个有意义的话题。
比如说,在地图软件中,经常会出现一些标志地名的小气泡,如下所示:
当我搜索故宫时,出现了3个小气泡,当我搜索王府井时,出现了5个气泡,按照对象池的思想,在第二次搜索开始之前,并不会把第一次创建的3个小气泡删除掉,而是把它们放进对象池。这样在第二次的搜索结果页面里,我们只需要再创建2个小气泡而不是5个。
先定义一个获取小气泡节点的工厂,作为对象池的数组成为私有属性被包含在工厂闭包里,这个工厂有两个暴露对外的方法,create
表示获取一个div
节点,recover
表示回收一个div
节点:
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
},
};
})();
第一次搜索时,需要创建3个小气泡节点,为了方便回收,用一个数组ary
记录它们:
var ary = [];
for (var i = 0, str; (str = ["A", "B", "C"][i++]); ) {
var toolTip = toolTipFactory.create();
toolTip.innerHTML = str;
ary.push(toolTip);
}
接下来假设地图需要开始重新绘制,在此之前要把这3个节点回收进对象池:
for (var i = 0, toolTip; (toolTip = ary[i++]); ) {
toolTipFactory.recover(toolTip);
}
再创建5个小气泡:
for (var i = 0, str; (str = ["A", "B", "C", "D", "E"][i++]); ) {
var toolTip = toolTipFactory.create();
toolTip.innerHTML = str;
}
现在再测试一番,页面中出现了5个节点,上一次创建好的节点被共享给了下一次操作。对象池跟享元模式的思想有点相似,虽然innerHTML
的值也可以看成节点的外部状态,但在这里我们并没有主动分离内部状态和外部状态的过程。
7 通用对象池实现
我们还可以在对象池工厂里,把创建对象的具体过程封装起来,实现一个通用的对象池:
var objectPoolFactory = function (createObjFn) {
var objectPool = [];
return {
create: function () {
var obj =
objectPool.length === 0
? createObjFn.apply(this, arguments)
: objectPool.shift();
return obj;
},
recover: function (obj) {
objectPool.push(obj);
},
};
};
现在利用objectPoolFactory
来创建一个装载一些iframe
的对象池:
var iframeFactory = objectPoolFactory(function () {
var iframe = document.createElement("iframe");
document.body.appendChild(iframe);
iframe.onload = function () {
iframe.onload = null; // 防止 iframe 重复加载
iframeFactory.recover(iframe); // iframe 加载完成之后回收节点
};
return iframe;
});
var iframe1 = iframeFactory.create();
iframe1.src = "http:// baidu.com";
var iframe2 = iframeFactory.create();
iframe2.src = "http:// QQ.com";
setTimeout(function () {
var iframe3 = iframeFactory.create();
iframe3.src = "http:// 163.com";
}, 3000);