享元模式的要求与目标
享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。
内部状态存储于对象内部。
内部状态可以被一些对象共享。
内部状态独立于具体的场景,通常不会改变。
外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。
享元模式应用举例:文件上传
先假设文件上传方式只有插件和 Flash 这两种。不论是插件上传,还是 Flash 上传,原理都是一样的,当用户选择了文件之后,插件和 Flash 都会通知调用 Window 下的一个全局 JavaScript 函数,它的名字是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 函数会遍历 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}</span><span>文件大小:${this.fileSize}</span> <button class="delFile">删除</button>`;
this.dom.querySelector('.delFile').onclick = function () {
that.delFile()
}
document.body.appendChild(this.dom)
}
定义一个删除文件的方法,假设该方法中有一个逻辑:当被删除的文件小于 1024 KB 时,该文件将被直接删除。否则页面中会弹出一个提示框,提示用户是否确认要删除该文件,代码如下:
Upload.prototype.delFile = function (fileSize) {
if (this.fileSize < 1024) {
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: 100
},
{
fileName: '2.html',
fileSize: 300
},
{
fileName: '3.txt',
fileSize: 2000
}
]);
startUpload('flash', [{
fileName: '4.txt',
fileSize: 100
},
{
fileName: '5.html',
fileSize: 300
},
{
fileName: '6.txt',
fileSize: 2000
}
]);
经过验证可以看到当点击删除大小为2000的文件时,弹出了是否确认删除的提示:
版本二:使用享元模式重构版本一
版本一的文件上传代码里有多少个需要上传的文件,就一共创建了多少个 upload 对象,接下来我们用享元模式重构它。
首先,我们需要确认插件类型 uploadType 是内部状态,那为什么单单 uploadType 是内部状态呢?前面讲过,划分内部状态和外部状态的关键主要有以下几点。
内部状态储存于对象内部。
内部状态可以被一些对象共享。
内部状态独立于具体的场景,通常不会改变。
外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
在文件上传的例子里,upload 对象必须依赖 uploadType 属性才能工作,这是因为插件上传、Flash 上传的实际工作原理有很大的区别,它们各自调用的接口也是完全不一样的,必须在对象创建之初就明确它是什么类型的插件,才可以在程序的运行过程中,让它们分别调用各自的 start、pause、cancel、del 等方法。
一旦明确了 uploadType,无论我们使用什么方式上传,这个上传对象都是可以被任何文件共用的。而 fileName 和 fileSize 是根据场景而变化的,每个文件的 fileName 和 fileSize 都不一样,
fileName 和 fileSize 没有办法被共享,它们只能被划分为外部状态。
1.剥离外部状态
明确了 uploadType 作为内部状态之后,我们再把其他的外部状态从构造函数中抽离出来,Upload 构造函数中只保留uploadType 参数:
var Upload = function( uploadType){
this.uploadType = uploadType;
};
Upload.prototype.init 函数也不再需要,因为 upload 对象初始化的工作被放在了 uploadManager.add 函数里面,接下来只需要定义 Upload.prototype.del 函数即可:
Upload.prototype.delFile = function (id) {
uploadManager.setExternalState(id, this); // (1)
if (this.fileSize < 1024) {
return this.dom.parentNode.removeChild(this.dom);
}
if (window.confirm(`确认要删除${this.fileName}吗?`)) {
return this.dom.parentNode.removeChild(this.dom)
}
}
在开始删除文件之前,需要读取文件的实际大小,而文件的实际大小被储存在外部管理器uploadManager 中,所以在这里需要通过 uploadManager.setExternalState 方法给共享对象设置正确
的 fileSize,上段代码中的(1)处表示把当前 id 对应的对象的外部状态都组装到共享对象中。
2. 工厂进行对象实例化
接下来定义一个工厂来创建 upload 对象,如果某种内部状态对应的共享对象已经被创建过,那么直接返回这个对象,否则创建一个新的对象:
var UploadFactory = (function () {
var createdFlyWeightObjs = {};
return {
create: function (uploadType) {
if (createdFlyWeightObjs[uploadType]) {
return createdFlyWeightObjs[uploadType];
}
return createdFlyWeightObjs[uploadType] = new Upload(uploadType);
}
}
})();
3. 管理器封装外部状态
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}</span><span>文件大小:${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];
}
}
}
})();
4. 触发上传动作的 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: 100
},
{
fileName: '2.html',
fileSize: 300
},
{
fileName: '3.txt',
fileSize: 2000
}
]);
startUpload('flash', [{
fileName: '4.txt',
fileSize: 100
},
{
fileName: '5.html',
fileSize: 300
},
{
fileName: '6.txt',
fileSize: 2000
}
]);
总结
享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,从前面两组代码的比较可以看到,使用了享元模式之后,我们需要分别多维护一个 factory 对象和一个 manager 对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。
享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。
一个程序中使用了大量的相似对象。
由于使用了大量对象,造成很大的内存开销。
对象的大多数状态都可以变为外部状态。
剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。
可以看到,文件上传的例子完全符合这四点所以很适合用享元模式。
(本文参考于《JavaScript设计模式与开发实战》)