「设计模式 JavaScript 描述」享元模式
享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。
如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在 JavaScript 中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非 常有意义的事情。
享元模式的概念初听起来并不太好理解,所以在深入讲解之前,我们先看一个例子。
1. 初识享元模式
假设有个内衣工厂,目前的产品有 50 种男式内衣和 50 种女士内衣,为了推销产品,工厂决定生产一些塑料模特来穿上他们的内衣拍成广告照片。 正常情况下需要 50 个男模特和 50 个女模特,然后让他们每人分别穿上一件内衣来拍照。不使用享元模式的情况下,在程序里也许会这 样写:
const Model = function (sex, underwear) { this.sex = sex; this.underwear = underwear; }; Model.prototype.takePhoto = function () { console.log('sex= ' + this.sex + ' underwear=' + this.underwear); }; for (let i = 1; i <= 50; i++) { const maleModel = new Model('male', 'underwear' + i); maleModel.takePhoto(); }; for (let j = 1; j <= 50; j++) { const femaleModel = new Model('female', 'underwear' + j); femaleModel.takePhoto(); };
要得到一张照片,每次都需要传入 sex
和 underwear
参数,如上所述,现在一共有 50 种男内 衣和 50 种女内衣,所以一共会产生 100 个对象。如果将来生产了 10000 种内衣,那这个程序可能会因为存在如此多的对象已经提前崩溃。
下面我们来考虑一下如何优化这个场景。虽然有 100 种内衣,但很显然并不需要 50 个男 模特和 50 个女模特。其实男模特和女模特各自有一个就足够了,他们可以分别穿上不同的内衣来拍照。
现在来改写一下代码,既然只需要区别男女模特,那我们先把 underwear
参数从构造函数中移除,构造函数只接收 sex
参数:
const Model = function (sex) { this.sex = sex; }; Model.prototype.takePhoto = function () { console.log('sex= ' + this.sex + ' underwear=' + this.underwear); };
分别创建一个男模特对象和一个女模特对象:
const maleModel = new Model('male'); const femaleModel = new Model('female');
给男模特依次穿上所有的男装,并进行拍照:
for (let i = 1; i <= 50; i++) { maleModel.underwear = 'underwear' + i; maleModel.takePhoto(); };
同样,给女模特依次穿上所有的女装,并进行拍照:
for (let j = 1; j <= 50; j++) { femaleModel.underwear = 'underwear' + j; femaleModel.takePhoto(); };
可以看到,改进之后的代码,只需要两个对象便完成了同样的功能。
2. 内部状态与外部状态
上节的这个例子便是享元模式的雏形,享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。
- 内部状态存储于对象内部。
- 内部状态可以被一些对象共享。
- 内部状态独立于具体的场景,通常不会改变。
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来,并储存在外部。
剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量,相比之下,这点时间或许是微不足道的。因此,享元模式是一种用时间换空间的优化模式。
在上面的例子中,性别是内部状态,内衣是外部状态,通过区分这两种状态,大大减少了系统中的对象数量。通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象,因为性别通常只有男女两种,所以该内衣厂商最多只需要 2 个对象。
使用享元模式的关键是如何「区别内部状态和外部状态」。可以被对象共享的属性通常被划分为内部状态,如同不管什么样式的衣服,都可以按照性别不同,穿在同一个男模特或者女模特身上,模特的性别就可以作为内部状态储存在共享对象的内部。而外部状态取决于具体的场景,并根据场景而变化,就像例子中每件衣服都是不同的,它们不能被一些对象共享,因此只能被划分为外部状态。
3. 享元模式的通用结构
上上节的示例初步展示了享元模式的威力,但这还不是一个完整的享元模式,在这个例子中还存在以下两个问题。
- 我们通过构造函数显式
new
出了男女两个model
对象,在其他系统中,也许并不是一开始就需要所有的共享对象。 - 给
model
对象手动设置了underwear
外部状态,在更复杂的系统中,这不是一个最好的方式,因为外部状态可能会相当复杂,它们与共享对象的联系会变得困难。
我们通过一个对象工厂来解决第一个问题,只有当某种共享对象被真正需要时,它才从工厂中被创建出来。对于第二个问题,可以用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来。
4. 文件上传的例子
在云文件上传模块的开发中,我们可以借助享元模式提升了程序的性能。下面我们就讲述这个例子。
4.1 对象爆炸
在云文件上传模块的开发中,可能会出现对象爆炸的问题。云文件的文件上传功能虽然可以选择依照队列,一个一个地排队上传,但也支持同时选择 2000 个文件。每一个文件都对应着一个 JavaScript 上传对象的创建,可是往程序里同时 new
了 2000 个 upload
对象,结 果可想而知,Chrome 中还勉强能够支撑,IE 下直接进入假死状态。
云文件支持好几种上传方式,比如浏览器插件、Flash 和表单上传等,为了简化例子,我们先假设只有插件和 Flash 这两种。不论是插件上传,还是 Flash 上传,原理都是一样的,当用户选择了文件之后,插件和 Flash 都会通知调用 Window
下的一个全局 JavaScript 函数,它的名字是 startUpload
,用户选择的文件列表被组合成一个数组 files
塞进该函数的参数列表里,代码如下:
let id = 0; window.startUpload = function (uploadType, files) { // uploadType 区分是控件还是 flash for (let i = 0; i < files.length; i++) { const uploadObj = new Upload(uploadType, files[i].fileName, files[i].fileSize); uploadObj.init(id++); // 给 upload 对象设置一个唯一的 id } };
当用户选择完文件之后,startUpload
函数会遍历 files
数组来创建对应的 upload
对象。接下来定义 Upload
构造函数,它接受 3 个参数,分别是「插件类型」、「文件名」和「文件大小」。这些信息都已经被插件组装在 files
数组里返回,代码如下:
const Upload = function (uploadType, fileName, fileSize) { this.uploadType = uploadType; this.fileName = fileName; this.fileSize = fileSize; this.dom = null; }; Upload.prototype.init = function (id) { const 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
。该方法中有一个逻辑:当被删除的文件小于 3000 KB 时,该文件将被直接删除。否则页面中会弹出一个提示框,提示用户是否确认要删除该文件,代码如下:
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 享元模式重构文件上传
上一节的代码是第一版的文件上传,在这段代码里有多少个需要上传的文件,就一共创建了多少个 upload
对象,接下来我们用享元模式重构它。
首先,我们需要确认插件类型 uploadType
是内部状态,那为什么单单 uploadType
是内部状态呢?前面讲过,划分内部状态和外部状态的关键主要有以下几点。
- 内部状态储存于对象内部。
- 内部状态可以被一些对象共享。
- 内部状态独立于具体的场景,通常不会改变。
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
在文件上传的例子里,upload
对象必须依赖 uploadType
属性才能工作,这是因为插件上传、Flash 上传、表单上传的实际工作原理有很大的区别,它们各自调用的接口也是完全不一样的,必须在对象创建之初就明确它是什么类型的插件,才可以在程序的运行过程中,让它们分别调用各自的 start
、pause
、cancel
、del
等方法。
实际上在云文件的真实代码中,虽然插件和 Flash 上传对象最终创建自一个大的工厂类,但它们实际上根据 uploadType
值的不同,分别是来自于两个不同类的对象。(在目前的例子中,为了 简化代码,我们把插件和 Flash 的构造函数合并成了一个。)
一旦明确了 uploadType
,无论我们使用什么方式上传,这个上传对象都是可以被任何文件共 用的。而 fileName
和 fileSize
是根据场景而变化的,每个文件的 fileName
和 fileSize
都不一样,fileName
和 fileSize
没有办法被共享,它们只能被划分为外部状态。
4.3 剥离外部状态
明确了 uploadType
作为内部状态之后,我们再把其他的外部状态从构造函数中抽离出来,Upload
构造函数中只保留 uploadType
参数:
const 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 < 3000) { 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 对应的对象的外部状态都组装到共享对象中。
4.4 工厂进行对象实例化
接下来定义一个工厂来创建 upload
对象,如果某种内部状态对应的共享对象已经被创建过,那么直接返回这个对象,否则创建一个新的对象:
const UploadFactory = (function () { const createdFlyWeightObjs = {}; return { create: function (uploadType) { if (createdFlyWeightObjs[uploadType]) { return createdFlyWeightObjs[uploadType]; } return createdFlyWeightObjs[uploadType] = new Upload(uploadType); } } })();
4.5 管理器封装外部状态
现在我们来完善前面提到的 uploadManager
对象,它负责向 UploadFactory
提交创建对象的请求,并用一个 uploadDatabase
对象保存所有 upload
对象的外部状态,以便在程序运行过程中给 upload
共享对象设置外部状态,代码如下:
const uploadManager = (function () { const uploadDatabase = {}; return { add: function (id, uploadType, fileName, fileSize) { const flyWeightObj = UploadFactory.create(uploadType); const 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) { const uploadData = uploadDatabase[id]; for (const i in uploadData) { flyWeightObj[i] = uploadData[i]; } } } })();
然后是开始触发上传动作的 startUpload
函数:
let id = 0; window.startUpload = function (uploadType, files) { for (let i = 0; i < files.length; i++) { const uploadObj = uploadManager.add(++id, uploadType, files[i].fileName, files[i].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. 享元模式的适用性
享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,从前面两组代码的比较可以看到,使用了享元模式之后,我们需要分别多维护一个 factory
对象和一个 manager
对 象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。
享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。
- 一个程序中使用了大量的相似对象。
- 由于使用了大量对象,造成很大的内存开销。
- 对象的大多数状态都可以变为外部状态。
- 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。
可以看到,文件上传的例子完全符合这四点。
6. 再谈内部状态和外部状态
如果顺利的话,通过前面的例子我们已经了解了内部状态和外部状态的概念以及享元模式的工作原理。我们知道,实现享元模式的关键是把内部状态和外部状态分离开来。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在共享对象的外部,在必要时被传入共享对象来组装成一个完整的对象。现在来考虑两种极端的情况,即对象没有外部状态和没有内部状态的时候。
6.1 没有内部状态的享元
在文件上传的例子中,我们分别进行过插件调用和 Flash 调用,即 startUpload('plugin', [])
和 startUpload('flash', [])
,导致程序中创建了内部状态不同的两个共享对象。也许你会奇怪,在文件上传程序里,一般都会提前通过特性检测来选择一种上传方式,如果浏览器支持插件就用插件上传,如果不支持插件,就用 Flash 上传。那么,什么情况下既需要插件上传又需要 Flash 上传呢?
实际上这个需求是存在的,很多网盘都提供了极速上传(控件)与普通上传(Flash)两种模式,如果极速上传不好使(可能是没有安装控件或者控件损坏),用户还可以随时切换到普通上传模式,所以这里确实是需要同时存在两个不同的 upload 共享对象。
但不是每个网站都必须做得如此复杂,很多小一些的网站就只支持单一的上传方式。假设我们是这个网站的开发者,不需要考虑极速上传与普通上传之间的切换,这意味着在之前的代码中作为内部状态的 uploadType
属性是可以删除掉的。 在继续使用享元模式的前提下,构造函数 Upload
就变成了无参数的形式:
const Upload = function(){};
其他属性如 fileName
、fileSize
、dom
依然可以作为外部状态保存在共享对象外部。在 uploadType
作为内部状态的时候,它可能为控件,也可能为 Flash,所以当时最多可以组合出两个共享对象。而现在已经没有了内部状态,这意味着只需要唯一的一个共享对象。现在我们要改写创建享元对象的工厂,代码如下:
const UploadFactory = (function () { let uploadObj; return { create: function () { if (uploadObj) { return uploadObj; } return uploadObj = new Upload(); } } })();
管理器部分的代码不需要改动,还是负责剥离和组装外部状态。可以看到,当对象没有内部状态的时候,生产共享对象的工厂实际上变成了一个单例工厂。虽然这时候的共享对象没有内部状态的区分,但还是有剥离外部状态的过程,我们依然倾向于称之为享元模式。
6.2 没有外部状态的享元
网上许多资料中,经常把 Java 或者 C#的字符串看成享元,这种说法是否正确呢?我们看看下面这段 Java 代码,来分析一下:
// Java 代码 public class Test { public static void main(String args[]) { String a1 = new String("a").intern(); String a2 = new String("a").intern(); System.out.println(a1 == a2); // true } }
在这段 Java 代码里,分别 new
了两个字符串对象 a1
和 a2
。intern
是一种对象池技术, new String("a").intern()
的含义如下。
- 如果值为 a 的字符串对象已经存在于对象池中,则返回这个对象的引用。
- 反之,将字符串 a 的对象添加进对象池,并返回这个对象的引用。
所以 a1 == a2
的结果是 true
,但这并不是使用了享元模式的结果,享元模式的关键是区别内部状态和外部状态。享元模式的过程是剥离外部状态,并把外部状态保存在其他地方,在合适的时刻再把外部状态组装进共享对象。这里并没有剥离外部状态的过程,a1
和 a2
指向的完全就是同一个对象,所以如果没有外部状态的分离,即使这里使用了共享的技术,但并不是一个纯粹的享元模式。
7. 小结
享元模式是为解决性能问题而生的模式,这跟大部分模式的诞生原因都不一样。在一个存在 大量相似对象的系统中,享元模式可以很好地解决大量对象带来的性能问题。