JavaScript设计模式与开发实践 享元模式
享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。
如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript 中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。
一、内部状态与外部状态
享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。
- 内部状态存储于对象内部。
- 内部状态可以被一些对象共享。
- 内部状态独立于具体的场景,通常不会改变。
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量,相比之下,这点时间或许是微不足道的。因此,享元模式是一种用时间换空间的优化模式。使用享元模式的关键是如何区别内部状态和外部状态。可以被对象共享的属性通常被划分为内部状态。而外部状态取决于具体的场景,并根据场景而变化,就像例子中每件衣服都是不同的,它们不能被一些对象共享,因此只能被划分为外部状态。
二、享元模式的通用结构
2.1文件上传的例子
如果每上传一个文件都创建一个对象,创建过多对象会让浏览器进入假死状态。
当用户选择了文件之后,插件和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 } }; 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.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 ); } }; 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 } ]);
享元模式重构文件上传
插件类型uploadType 是内部状态在文件上传的例子里,upload 对象必须依赖uploadType 属性才能工作,这是因为插件上传、Flash 上传、表单上传的实际工作原理有很大的区别,它们各自调用的接口也是完全不一样的,必须在对象创建之初就明确它是什么类型的插件,才可以在程序的运行过程中,让它们分别调用各自的start、pause、cancel、del 等方法。
一旦明确了uploadType,无论我们使用什么方式上传,这个上传对象都是可以被任何文件共用的。而fileName 和fileSize 是根据场景而变化的,每个文件的fileName 和fileSize 都不一样,fileName 和fileSize 没有办法被共享,它们只能被划分为外部状态。
Upload.prototype.init 函数也不再需要,因为upload 对象初始化的工作被放在了upload-Manager.add 函数里面,接下来只需要定义Upload.prototype.del 函数即可。
x
var Upload = function( uploadType){ this.uploadType = uploadType; };
//定义一个工厂来创建upload 对象,如果某种内部状态对应的共享对象已经被创建过,那么直接返回这个对象,否则创建一个新的对象
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 ); } } //定义一个工厂来创建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++ ]; ){ var uploadObj = 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。
2.2享元模式的适用性
使用了享元模式之后,我们需要分别多维护一个factory 对象和一个manager 对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。
一般来说,以下情况发生时便可以使用享元模式。
- 一个程序中使用了大量的相似对象。
- 由于使用了大量对象,造成很大的内存开销。
- 对象的大多数状态都可以变为外部状态。
- 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。
三、再谈内部状态和外部状态
实现享元模式的关键是把内部状态和外部状态分离开来。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在共享对象的外部,在必要时被传入共享对象来组装成一个完整的对象。现在来考虑两种极端的情况,即对象没有外部状态和没有内部状态的时候。
3.1没有内部状态的享元
3.2没有内部状态的享元
四、对象池
对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接new,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后, 再进入池子等待被下次获取。
对象池技术的应用非常广泛,HTTP 连接池和数据库连接池都是其代表应用。在Web 前端开发中,对象池使用最多的场景大概就是跟DOM 有关的操作。很多空间和时间都消耗在了DOM节点上,如何避免频繁地创建和删除DOM 节点就成了一个有意义的话题。
4.1 对象池实现
在开发一个地图应用, 地图上经常会出现一些标志地名的小气泡,我们叫它toolTip。假如第一次搜索后页面出现了2个小气泡,第二次搜索出现了6个小气泡,按照对象池的思想,在第二次搜索开始之前,并不会把第一次创建的2 个小气泡删除掉,而是把它们放进对象池。这样在第二次的搜索结果页面里,我们只需要再创建4 个小气泡而不是6 个
//定义一个获取小气泡节点的工厂,作为对象池的数组成为私有属性被包含在工厂闭包里, 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 } } })(); //创建的小气泡节点 var ary = []; for ( var i = 0, str; str = [ 'A', 'B' ][ i++ ]; ){ var toolTip = toolTipFactory.create(); toolTip.innerHTML = str; ary.push( toolTip ); }; //假设地图需要开始重新绘制,在此之前要把这两个节点回收进对象池 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; };
4.2 通用对象池实现
我们还可以在对象池工厂里,把创建对象的具体过程封装起来,实现一个通用的对象池:
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'; })
对象池是另外一种性能优化方案,它跟享元模式有一些相似之处,但没有分离内部状态和外部状态这个过程。本章用享元模式完成了一个文件上传的程序,其实也可以用对象池+事件委托来代替实现。
五、小结
享元模式是为解决性能问题而生的模式,这跟大部分模式的诞生原因都不一样。在一个存在大量相似对象的系统中,享元模式可以很好地解决大量对象带来的性能问题。