亨元(flyweight)模式是一种用于性能优化的模式, “fly” 在这里是苍蝇的意思,觉得蝇量级。亨元模式的可行是运用共享技术来有效支持大量细粒度的对象。前端
若是系统中由于建立了大量相似的对象而致使内存占用太高,亨元模式就很是有用了。在 JavaScrip t中,浏览器特别是移动端的浏览器分配的内存并不过,如何节省内存就成了一件很是有意义的事情。数据库
假设有个内衣工厂,目前的产品有 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('mal', 'underwear' + i); }; for (var j = 1; j <= 50; j++){ var femaleModel = new Model('female', 'underwear' + j); };
要获得一张照片,每次都须要传入 sex 和 underwear 参数,如上所述,如今一共有 50 种男内衣和 50 种女内衣,因此一共会产生 100 个对象。若是未来生产了 10000 种内衣,那这个程序可能会由于存在如此多的对象已经提早崩溃。数组
下面咱们来考虑一下如何优化这个场景。虽然有 100 种内衣,但很显然并不须要 50 个男模特和 50 个女模特。其实男模特和女模特各自有一个就足够了,他们能够分别穿上不一样的内衣来拍照。浏览器
如今来改写一下代码,既然只须要区别男女模特,那咱们先把 underwear 参数从构造函数中移除,构造函数只接收 sex 参数:性能优化
var Model = function ( sex ) { this.sex = sex; }; Model.prototype.takePhoto = function () { console.log("sex= " + this.sex + ' underwear= ' + this.underwear); };
分别建立一个男模特对象和女模特对象:闭包
var maleModel = new Model('mal'), femealeModel = new Model('female');
给模特依次穿上全部的内衣,并进行拍照:app
for (var i = 1; i <= 50; i++){ maleModel.underwear = 'underwear' + i; maleModel.takePhoto(); }; for (var j = 1; j <= 50; j++){ femealeModel.underwear = 'underwear' + j; femealeModel.takePhoto(); };
能够看到,改进以后的代码,只须要两个对象便完成了一样的功能。dom
第 1 节中的例子即是亨元模式的雏形,亨元模式要求将对象的属性划分为内部状态与外部状态(状态在这里一般指属性)。亨元模式的目标是尽可能减小共享对象的数量,关于如何划份内部状态和外部状态,下面的几条经验提供了一些指引。函数
这样一来,咱们即可以把全部内部状态相同的对象都指定为同一个共享的对象。而外部状态能够从对象身上剥离出来,并储存在外部。
剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程须要花费必定的时间,但却能够大大减小系统中的对象数量,相比之下,这点时间或许是微不足道的。所以,亨元模式是一种用时间换空间的优化模式。
在上面的例子中,性别是内部状态,内衣是外部状态,经过区分这两种状态,大大减小了系统中的对象数量。一般来说,内部状态有多少种组合,系统中便最多存在多少个对象,由于性别一般只有男女两种,因此该内衣厂最多只须要 2 个对象。
使用亨元模式的关键是如何区别内部状态和外部状态。能够被对象共享的属性一般被划分为内部状态,如同无论什么样式的衣服,均可以按照性别的不一样,穿在同一个男模特或者女模特身上,模特的性别就能够做为内部状态存储在共享对象的内部。而外部状态取决于具体的场景,并根据场景而变化,就像例子中的每件衣服都是不一样的,它们不能被一些对象共享,所以只能被划分为外部状态。
第 1 节的示例初步展现了亨元模式的威力,但这还不是一个完整的亨元模式,在这个例子中还存在如下这两个问题。
咱们经过一个对象工厂来解决第一个问题,只有当某种共享对象被真正须要时,它才从工厂中被建立出来。对于第二个问题,能够用一个管理器来记录对象相关的外部状态,使这些外部状态经过某个钩子和共享对象联系起来。
在微云上传模块的开发中,咱们曾经借助亨元模式提高了程序的性能。下面咱们就讲述这个例子。
在微云上传模块的开发中,我(本书做者)曾经经历过对象爆炸的问题。微云的文件上传功能虽然能够选择依照队列,一个一个地排队上传,但也支持同时选择 2000 个文件。每个文件都对应着一个 JavaScript 上传对象的建立,在初版开发中,的确往程序里同时 new 了 2000 个 upload 对象,结果可想而知, Chrome 中还勉强可以支撑, IE 下直接进入假死状态。
微云支持好几种上传方式,好比浏览器插件, Flash 和表单上传等,为了简化例子,咱们先假设只有插件和 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 + ', 文件大小:' + this.fileSize + '</span>' + '<button class="delFile">删除</button>'; document.body.appendChild(this.dom); this.dom.querySelector('.delFile').onclick = function () { that.delFile(); } }
一样为了简化示例,咱们暂且去掉了 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.txt', fileSize: 2000 }, { fileName: '3.txt', fileSize: 3000 } ]); startUpload('flash', [ { fileName: '4.txt', fileSize: 1000 }, { fileName: '5.txt', fileSize: 2000 }, { fileName: '6.txt', fileSize: 3000 } ]);
当点击删除 3000KB 的文件时,能够看到弹出了是否确认删除的提示,如图所示:
上一节代码是初版的文件上传,在这段代码里有多少个须要上传的文件,就一共建立了多少个 upload 对象,接下来咱们用亨元模式重构它。
首先,咱们须要确认插件类型 uploadType 是内部状态,那么为何单单 uploadType 是内部状态呢?前面讲过,划份内部状态和外部状态的关键主要有如下几点:
在文件上传的例子里,upload 对象必须依赖 uploadType 属性才能工做,这是由于插件上传,Flash 上传,表单上传的实际工做原理有很大的区别,它们各自调用的接口也是彻底不同的,必须在对象建立之初就明确它是什么类型的插件,才能够在程序的运行过程当中,让它们分别调用各自的 start, pause, cancel, del 等方法。
实际上在微云的真实代码中,虽然插件和 Flash 上传对象最终建立自一个大的工厂类,但它们实际上根据 uploadType 值的不一样,分别是来自两个不一样类的对象。(在目前的例子中,为了简化代码,咱们把插件和 Flash 的构造函数合并成了一个)。
一旦明确了 uploadType ,不管咱们使用什么方式上传,这个上传对象都是能够被任何文件共用的。而 fileName 和 fileSize 是根据场景而变化的,每一个文件的 fileName 和 fileSize 都不同, fileName 和 fileSize 没有办法被共享,它们只能被划分为外部状态。
明确了 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); 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 对应的对象的外部状态都组装到共享对象中。
接下来定义一个工厂来建立 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 = 1; 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.txt', fileSize: 2000 }, { fileName: '3.txt', fileSize: 3000 } ]); startUpload('flash', [ { fileName: '4.txt', fileSize: 1000 }, { fileName: '5.txt', fileSize: 2000 }, { fileName: '6.txt', fileSize: 3000 } ]);
亨元模式重构以前的代码里一共建立了 6 个 upload 对象,而经过亨元模式重构以后,对象的数量减小为 2 ,更幸运的是,就算如今同时上传 2000 个文件,须要建立的 upload 对象数量依然是 2 。
亨元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,从前面两组代码的比较能够看到,使用了亨元模式以后,咱们须要分别多维护一个 factory 对象和一个 manager 对象,在大部分没必要要使用亨元模式的环境下,这些开销是能够避免的。
亨元模式带来的好处很大程度上取决于如何使用以及什么时候使用,通常来讲,如下状况发生时即可以使用亨元模式。
能够看到,文件上传的例子彻底符合这 4 点。
若是顺利的话,经过前面的例子咱们已经了解了内部状态和外部状态的概念以及亨元模式的工做原理。咱们知道,实现亨元模式的关键是把内部状态和外部状态分离开来。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在共享对象的外部,在必要时被传入共享对象来组装成一个完整的对象。先来来考虑两种极端的状况,即对象没有外部状态和没有内部状态的时候。
在文件上传的例子中,咱们分别进行过插件调用和 Flash 调用,即 startUpload('plugin', []) 和 startUpload('flash', []) ,致使程序中建立了内部状态不一样的两个共享对象。也许你会奇怪,在文件上传程序里,通常都提早经过特性检测来选择一种上传方式,若是浏览器支持插件就用插件上传,若是不支持插件,就用 Flash 上传。那么,什么状况下既须要插件上传又须要 Flash 上传呢?
实际上这个需求是存在的,不少网盘都提供了极速上传(控件)与普通上传(Flash)两种模式,若是极速上传很差使(多是没有安装控件或者控件损坏),用户还能够随时切换到普通上传模式,因此这里确实是须要同时存在两个不一样的 upload 共享对象。
但不是每一个网站都必须作得如此复杂,不少小一些的网站就只支持单一的上传方式。假设咱们是这个网站的开发者,不须要考虑极速上传与普通上传之间的切换,这意味着在以前的代码中做为内部状态的 uploadType 属性是能够删除的。
在继续使用亨元模式的前提下,构造函数 Upload 就变成了无参数的形式:
var Upload = function () {};
其余属性如 fileName, fileSize, dom 依然能够做为外部状态保存在共享对象外部。在 uploadType 做为内部状态的时候,它可能为控件,也可能为 Flash ,因此当时最多能够组合出两个共享对象。而如今已经没有了内部状态,这意味着只须要惟一的一个共享对象。如今咱们要改写建立亨元对象的工厂,代码以下:
var UploadFactory = (function () { var uploadObj; return { create: function () { 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 } }
在这段 Java 代码里,分别 new 了两个字符串对象 a1 和 a2 。intern 是一种对象池技术, new String("a").intern() 的含义以下。
因此 a1 == a2 的结果是 true, 但这并非使用了亨元模式的结果,亨元模式的关键是区别内部状态和外部状态。亨元模式的过程是剥离外部状态,并把外部状态保存在其余地方,在合适的时刻再把外部状态组装进共享对象。这里并无剥离外部状态的过程, a1 和 a2 指向的彻底就是同一个对象,因此若是没有外部状态的分离,即便这里使用了共享的技术,但并非一个纯粹的亨元模式。
咱们在前面已经提到了 Java 中 String 的对象池,下面就来学习这种共享的技术。对象池维护一个装载空闲对象的池子,若是须要对象的时候,不是直接 new, 而是转从对象池里获取。若是对象池里没有空闲对象,则建立一个新的对象,当获取出的对象完成它的职责以后,再进入池子等待被下次获取。
对象池的原理很好理解,好比咱们组人手一本《JavaScript权威指南》,从节约的角度来说,这并非很划算,由于大部分时间这些书都被闲置在各自的书架上,因此咱们一开始就只买一本,或者一块儿创建一个小型图书馆(对象池),须要看书的时候就从图书馆里借,看完了以后再把书还会图书馆。若是同时有三我的要看这本书,而如今图书馆里只有两本,那咱们再立刻去书店买一本放入图书馆。
对象池技术的应用很是普遍, HTTP 链接池和数据库链接池都是其表明应用。在 Web 前端开发中,对象池使用最多的场景大概就是跟 DOM 有关的操做。不少空间和时间都消耗在了 DOM 节点上,若是避免频繁地建立和删除 DOM 节点就成了一个有意义的话题。
假设咱们在开发一个地图应用,地图上常常会出现一些标志地名的小气泡(如手机或PC上的地图应用),咱们叫它 toolTip。
在搜索我家附近地图的时候,页面里出现了 2 个小气泡。当我再搜索附近的某某时,页面中出现了 6 个小气泡。按照对象池的思想,在第二次搜索开始以前,并不会把第一次建立的 2 个小气泡删除掉,而是把它们放进对象池。这样在第二次的搜索结束页面里,咱们只须要再建立 4 个小气泡而不是 6 个。
先定义一个获取小气泡节点的工厂,做为对象池的数组成为私有属性被包含在工厂闭包里,这个工厂有两个暴露对外的方法, 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 } } })();
如今把时钟拨回进行第一次搜索的时刻,目前须要建立 2 个小气泡节点,为了方便回收,用一个数组 ary 来记录它们:
var ary = []; for (var i = 0, str; str = ['A', 'B'][i++]; ){ var toolTip = toolTipFactory.create(); toolTip.innerHTML = str; ary.push(toolTip); }
若是你愿意稍稍测试一下,能够看到页面中出现了 innerHTML 分别为 A 和 B 的两个 div 节点。
接下来假设地图须要从新绘制,在此以前要把这两个节点回收进对象池:
for ( var i = 0, toolTip; toolTip = ary[i++]; ){ toolTipFactory.recover(toolTip); }
再建立 6 个小气泡:
for (var i = 0, str; str = ['A', 'B', 'C', "D", "E", "F"][i++]; ){ var toolTip = toolTipFactory.create(); toolTip.innerHTML = str; };
如今再测试一番,页面中出现了内容分别为 A, B, C, D, E, F 的 6 个节点,上一次建立好的节点被共享给了下一次的操做。对象池跟亨元模式的思想有点类似,虽然 innerHTML 的值 A, B, C, D 等也能够当作节点的外部状态,但在这里咱们并无主动分离内部状态和外部状态的过程。
咱们还能够在对象池工厂里,把建立对象的具体过程封装起来,实现一个通用的对象池:
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 重复加载的 bug 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'; }, 10000);
对象池是另一种性能优化方案,它跟亨元模式有一些类似之处,但没有分离内部状态和外部状态这个过程。本章用亨元模式完成了一个文件上传的程序,其实也能够用对象池 + 事件委托来代替实现。
亨元模式是为解决性能问题而生的模式,这跟大部分模式的诞生缘由都不同。在一个存在大量类似对象的系统中,亨元模式能够很好地解决大量对象带来的性能问题。
参考书目:《JavaScript设计模式与开发实践》