一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
享元模式的概念
享元(flyweight)模式是一种用于性能优化的模式,"fly"在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。
如果系统中创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。
享元模式的概念听起来并不好理解,在深入讲解之前,我们来看一个例子
初识享元模式
假设有个衣服工厂,目前的产品有50种男性衣服和50种女性衣服,为了推销产品,工厂决定生产一些塑料模特来穿上它们的衣服拍广告照片。正常情况下需要50个男模特和50个女模特,然后让他们分别穿上一件衣服来拍照。在不适用享元模式的情况下,代码如下
const Model = function(sex, underwear) {
this.sex = sex
this.underwear = underwear
}
Model.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 i = 1; i <= 50; i++) {
const femaleModel = new Model('female', 'underwear' + i)
femaleModel.takePhoto()
}
要得到一张照片,每次都得传入sex和underwear参数,如上所述,会产生100个对象。如果将来生产了10000种衣服,那这个程序可能会因为存在如此多的对象而崩溃
下面我们来考虑如何优化一下这个场景。虽然有100种衣服,但很明显不需要50个男性和50个女模特。女模特和男模特各有一个足以,他们可以穿上不同的衣服来拍照
现在来改写代码,既然只需要区分男女模特,那我们先把underwear参数从构造函数中移除
const Model = function(sex) {
this.sex = sex
}
Model.takePhoto = function() {
console.log(`sex=${this.sex},underwear=${this.underwear}`)
}
然后分别创建男女模特对象
const maleModel = new Model('male'),
femaleModel = new Model('female');
男模特穿男装,拍照
for(let i = 1; i <= 50; i++) {
maleModel.underwear = `underwear${i}`
maleModel.takePhoto()
}
女模特穿女装,拍照
for(let i = 1; i <= 50; i++) {
femaleModel.underwear = `underwear${i}`
femaleModel.takePhoto()
}
可以看到,改进之后,只需要两个对象变完成了任务
内部状态与外部状态
上面这个例子就是享元模式的雏形,享元模式要求将对象的属性划分为内部状态和外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,请看下面
- 内部状态存储与对象内部
- 内部状态可以被一些对象共享
- 内部状态独立于具体的场景,通常不会改变
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享
上面的例子中,性别是内部状态,衣服是外部状态
享元模式的通用结构
上面的例子存在两个问题
- 我们通过构造函数显式new 出了男女两个model对象,在其他系统中,也许并不是一开始就需要所有的共享对象。
- 给model对象手动设置了underwear外部状态,在复杂的系统中,这并不是一个好方式,因为外部状态可能相当复杂,它们与共享对象的联系会变得困难。
我们通过对象工厂(单例模式)来解决第一个问题
第二个问题,可以用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来
文件上传的例子
对象爆炸
以下是不用享元模式的代码
在微云的上传模块的开发中,增加出现过对象爆炸的问题。微云的文件上传功能虽然可以选择依照队列,一个一个排队上传,但也支持同时选择2000个文件。每一个文件都对应着javaScript上传对象的创建,在第一版的开发中,的确往程序里new 了 2000个upload对象,结果可选而知。
微云支持好几种上传方式,例如flash和表单上传,代码如下
let id = 0;
window.startUpload = function(type, files) { // type是区分flash还是表单
for (let i = 0, file; file = files[i++];) {
const uploadObj = new Upload(type, file.fileName, file.fileSize)
uploadObj.init(id++)
}
}
当用户选择完文件之后,startUpload会遍历files数组来创建对应的upload对象。接下来定义Upload构造函数。
// 类型、文件名、文件大小
const Upload = function(type, fileName, fileSize) {
this.type = type
this.fileName = fileName
this.fileSize = fileSize
this.dom = null
}
Upload.prototype.init = function(id) {
this.id = id
this.dom = document.createElement('div')
this.dom.innerHTML = `<span>文件名${this.fileName}</span><div> <button class="delFile">删除</button> </div>`
this.dom.querySelector('delFile').onclick = () => {
this.delFile()
}
document.body.appendChild(this.dom)
}
接下来书写delFile方法,该方法有一个逻辑,当被删除的文件小于3000KB时,直接删除。否则弹出一个提示框,提示用户是否确认删除,代码如下
Upload.prototype.delFile = function() {
if (this.fileSize < 3000) {
return this.dom.parentNode.removeChild(this.dom)
}
if (window.confirm('确认删除该文件吗')){
return this.dom.parentNode.removeChild(this.dom)
}
}
接下来分别创建3个插件上传对象和3个Flash上传对象:
startUpload('plugin', [
{
fileName: '1.txt',
fileSize: 1000
},
{
fileName: '2.txt',
fileSize: 3000
},
{
fileName: '3.txt',
fileSize: 5000
}
])
startUpload('flash', [
{
fileName: '4.txt',
fileSize: 1000
},
{
fileName: '5.txt',
fileSize: 3000
},
{
fileName: '6.txt',
fileSize: 5000
}
])
享元模式重构文件上传
首先,我们需要确认外部状态和内部状态。
type是内部状态,其他都是外部状态,因为都是不确定的
剥离外部状态
const Upload = function(type) {
this.type = type
}
Upload.prototype.init也不再需要,因为初始upload对象的工作被放在了 uploadManager.add函数里面,接下来定义删除函数
Upload.prototype.delFile = function() {
uploadManager.sexExternamState(id, this) // (1)
if (this.fileSize < 3000) {
return this.dom.parentNode.removeChild(this.dom)
}
if (window.confirm('确认删除该文件吗')){
return this.dom.parentNode.removeChild(this.dom)
}
}
工厂对象实例化
很简单,就是单例模式,定义一个工厂来创建对象,如果已经被创建过,直接返回,否则创建一个新的对象
const UploadFactory = (function() {
const createdFlyWeightObjs = {}
return {
create(type) {
if (createdFlyWeightObjs[type]) {
return createdFlyWeightObjs[type]
}
return createdFlyWeightObjs[type] = new Upload(type)
}
}
})()
管理器封装外部状态
现在我们来完善前面提到的uploadManager对象,它负责向UploadFactory提交创建对象的请求,并用一个uploadDataBase对象保存所有upload对象的外部状态,以便在程序运行过程中给upload共享对象设置外部状态,代码如下
const uploadManager = (function() {
const uploadDatabase = {}
return {
add(id, type, fileName, fileSize) {
const flyWeightObj = UploadFactory.create(type)
const dom = document.createElement('div')
dom.innerHTML = `<span>文件名${fileName}</span><div> <button class="delFile">删除</button> </div>`
dom.querySelector('delFile').onclick = () => {
flyWeightObj.delFile()
}
document.body.appendChild(dom)
uploadDatabase[id] = {
fileName,
fileSize,
dom
}
},
setExternalState(id, flyWeightObj) {
const uploadData = uploadDatabase[id]
for (let i in uploadData) {
flyWeightObj[i] = uploadData[i]
}
}
}
})()
然后是开始触发上传动作的startUpload函数。
let id = 0;
window.startUpload = function(type, files) { // type是区分flash还是表单
for (let i = 0, file; file = files[i++];) {
const uploadObj = uploadManager.add(++id, type, file.fileName, file.fileSize)
}
}
接下来是测试时间。
startUpload('plugin', [
{
fileName: '1.txt',
fileSize: 1000
},
{
fileName: '2.txt',
fileSize: 3000
},
{
fileName: '3.txt',
fileSize: 5000
}
])
startUpload('flash', [
{
fileName: '4.txt',
fileSize: 1000
},
{
fileName: '5.txt',
fileSize: 3000
},
{
fileName: '6.txt',
fileSize: 5000
}
])
可以看到跟刚才的结果一样,之前是创建了6个upload对象,现在是2个,就算同时创建2000个文件,需要创建的upload对象依然为2
享元模式的适用性
享元模式是一种很好的性能优化方案,但它会使程序更复杂,从两组代码可以看到,适用享元模式后,我们需要维护一个factory对象和manager对象。
一般来说,以下情况发生时便可以适用享元模式。
- 程序中适用了大量的相似对象
- 由于使用了大量对象,造成了很大的性能开销
- 对象的大多数状态都可以变为外部状态
- 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象
再谈外部状态和内部状态
我们知道,实现享元模式的关键是把内部状态和外部状态分离开来。
现在我们来考虑一种极端情况,即对象没有外部状态和内部状态的时候
没有内部状态的享元
举个例子,现在不支持flash上传,只能使用控件上传,所以我们不需要type参数了
const Upload = function() {}
const UploadFactory = (() => {
let uploadObj = null;
return {
create() {
if (uploadObj) return uploadObj
return uploadObj = new Upload()
}
}
})
没有外部状态的享元,只有内部状态
网上的许多资料中,经常把Java或者c#的字符串看出享元,这种说法是否正确呢?我们来看下面这段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
}
}
这段代码中,分别 new 了两个字符串对象a1和a2 。intern是一种对象池技术,new String('a').intern()的含义如下
- 如果值为a的字符串已经存在于对象池中,则返回这个对象的引用
- 反之将字符串a的对象添加到对象池,并返回这个对象的引用
所以a1 == a2的结果是true,但这并不是享元模式的结果,享元模式的关键是区别内部状态和外部状态。这里没有外部状态的分离,即使这里使用了共享的技术,但并不是享元模式
对象池(重点,贼牛皮)
对象池技术应用非常广泛,HTTP连接池和数据库连接池都是其代表应用,甚至是各自游戏开发。在Web前端开发中,对象池使用最多的场景大概是跟DOM有关的操作。很多空间和时间都消耗在了DOM节点上,如何避免频繁地创建和删除DOM节点就成了一个有意义的话题
对象池实现
假设我们在开发一个地图应用,地图上经常会出现标志地名的小气泡,我们叫他tooltip,做过地图相关开发的肯定知道。
举个例子,当搜索我家附件地图的时候,地图上出现了两个小气泡,当搜索附件的兰州拉面的时候,出现了6个。按照对象池的思想,在第二次搜索之前,并不会把第一次创建的2个小气泡删掉,而是把它们放到对象池。这样在第二次的搜索结果页面里,我们只需要创建4个而不是6个
下面是代码时间,来创建一个创建小气泡的工厂
const toolTipFactory = (function(){
const toolTipPool = [] // toolTip对象池
return {
create() {
if (this.toolTipPool.length === 0) {
// 如果对象池为空
const div = document.createElement('div')
document.body.appendChild(div)
return div
} else {
return toolTipPool.shift() // 从对象池中取出一个dom
}
},
revocer(tooltipDom) {
return toolTipPool.push(tooltipDom)
}
}
})()
现在我们把时钟拨回第一次搜索的时刻,创建两个小气泡节点,为了方便回收,用一个数组ary来记录
const ary = []
for (let i = 0, str; str = ['A', 'B'][i++]; ) {
const toolTip = toolTipFactory.create()
toolTip.innerHTML = str
ary.push(toolTip)
}
接下来假设地图重新绘制,在此之前要把这两个节点回收到对象池
for (let i = 0, toolTip; toolTip = ary[i++]; ) {
toolTipFactory.recover(toolTip)
}
再创建6个小气泡
for (let i = 0, str; str = ['A', 'B', 'C', 'D', 'E', 'F'][i++]; ) {
const toolTip = toolTipFactory.create()
toolTip.innerHTML = str
}
是不是很牛皮,上一次创建的节点给共享给了下一次操作。对象池跟享元模式的思想有点相似,虽然innerHTML的值A、B、C、D等可以看出节点的外部状态,但在这里我们并没有主动分离内部和外部状态的过程
通用对象池实现
const objectPoolFactory = function(createObjFn) {
const objectPool = []
return {
create(...args) {
const obj = objectPool.length === 0 ? createObjFn.apply(this, args) : objectPool.shift()
return obj
},
recover(obj) {
objectPool.push(obj)
}
}
}
已经写完了,下面我们利用它来创建一个iframe的对象池
const iframeFactory = objectPoolFactory(() => {
const iframe = document.createElement('iframe');
document.appendChild(iframe)
iframe.onload = () => {
iframe.onload = null // 防止iframe重复加载的bug
iframeFactory.recover(iframe)
}
return iframe
})
const iframe1 = iframeFactory.create()
iframe1.src = 'https://www.baidu.com';
const iframe2 = iframeFactory.create()
iframe2.src = 'https://www.weibo.com';
对象池也是一种性能优化方案
小结
设计模式完整记录请看我的github