享元模式

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。享元模式的核心是运用共享技术来有效支持大量细粒度的对象。 如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。

初识享元模式

假设有个内衣工厂,目前的产品有50种男式内衣和50种女士内衣,为了推销产品,工厂决定生产一些塑料模特来穿上他们的内衣拍成广告照片。正常情况下需要50个男模特和50个女模特,然后让他们每人分别穿上一件内衣来拍照。不使用享元模式的情况下,在程序里也许会这样写:

  1. var Model = function (sex, underwear) {
  2. this.sex = sex;
  3. this.underwear = underwear;
  4. };
  5. Model.prototype.takePhoto = function () {
  6. console.log('sex= ' + this.sex + ' underwear=' + this.underwear);
  7. };
  8. for (var i = 1; i <= 50; i++) {
  9. var maleModel = new Model('male', 'underwear' + i);
  10. maleModel.takePhoto();
  11. };
  12. for (var j = 1; j <= 50; j++) {
  13. var femaleModel = new Model('female', 'underwear' + j);
  14. femaleModel.takePhoto();
  15. };

下面我们来考虑一下如何优化这个场景。虽然有100种内衣,但很显然并不需要50个男模特和50个女模特。其实男模特和女模特各自有一个就足够了,他们可以分别穿上不同的内衣来拍照。
现在来改写一下代码,既然只需要区别男女模特,那我们先把underwear参数从构造函数中移除,构造函数只接收sex参数:

  1. var Model = function (sex) {
  2. this.sex = sex;
  3. }
  4. Model.prototype.takePhoto = function () {
  5. console.log('sex= ' + this.sex + ' underwear=' + this.underwear);
  6. };
  7. for (var i = 0; i < 50; i++) {
  8. var maleModel = new Model('male');
  9. maleModel.underwear = 'M-underwear';
  10. maleModel.takePhoto();
  11. }
  12. for (var j = 0; j < 50; j++) {
  13. var femaleModel = new Model('female');
  14. femaleModel.underwear = 'F-underwear';
  15. femaleModel.takePhoto();
  16. }

可以看到,改进之后的代码,只需要两个对象便完成了同样的功能。

内部状态与外部状态

上面这个例子便是享元模式的雏形,享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。

  • 内部状态存储于对象内部。
  • 内部状态可以被一些对象共享。
  • 内部状态独立于具体的场景,通常不会改变。
  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。

剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量,相比之下,这点时间或许是微不足道的。因此,享元模式是一种用时间换空间的优化模式。

使用享元模式的关键是如何区别内部状态和外部状态。可以被对象共享的属性通常被划分为内部状态,如同不管什么样式的衣服,都可以按照性别不同,穿在同一个男模特或者女模特身上,模特的性别就可以作为内部状态储存在共享对象的内部。而外部状态取决于具体的场景,并根据场景而变化,就像例子中每件衣服都是不同的,它们不能被一些对象共享,因此只能被划分为外部状态。

享元模式的通用结构

上面的示例初步展示了享元模式的威力,但这还不是一个完整的享元模式,在这个例子中还存在以下两个问题。

  • 我们通过构造函数显式new出了男女两个model对象,在其他系统中,也许并不是一开始就需要所有的共享对象。
  • 给model对象手动设置了underwear外部状态,在更复杂的系统中,这不是一个最好的方式,因为外部状态可能会相当复杂,它们与共享对象的联系会变得困难。
    我们通过一个对象工厂来解决第一个问题,只有当某种共享对象被真正需要时,它才从工厂中被创建出来。对于第二个问题,可以用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来。

文件上传的例子

对象爆炸的例子

  1. var id = 0;
  2. window.startUpload = function (uploadType, files) { // uploadType区分是控件还是flash
  3. for (var i = 0, file; file = files[i++];) {
  4. var uploadObj = new Upload(uploadType, file.fileName, file.fileSize);
  5. uploadObj.init(id++); // 给upload对象设置一个唯一的id
  6. }
  7. };
  8. var Upload = function (uploadType, fileName, fileSize) {
  9. this.uploadType = uploadType;
  10. this.fileName = fileName;
  11. this.fileSize = fileSize;
  12. this.dom = null;
  13. };
  14. Upload.prototype.init = function (id) {
  15. var that = this;
  16. this.id = id;
  17. this.dom = document.createElement('div');
  18. this.dom.innerHTML =
  19. '<span>文件名称:' + this.fileName + ',文件大小: ' + this.fileSize + '</span>' +
  20. '<button class="delFile">删除</button>';
  21. this.dom.querySelector('.delFile').onclick = function () {
  22. that.delFile();
  23. }
  24. document.body.appendChild(this.dom);
  25. };
  26. Upload.prototype.delFile = function () {
  27. if (this.fileSize < 3000) {
  28. return this.dom.parentNode.removeChild(this.dom);
  29. }
  30. if (window.confirm('确定要删除该文件吗? ' + this.fileName)) {
  31. return this.dom.parentNode.removeChild(this.dom);
  32. }
  33. };
  34. // 调用
  35. document.querySelector('button').addEventListener('click', e => {
  36. startUpload('plugin', [
  37. {
  38. fileName: '1.txt',
  39. fileSize: 1000
  40. },
  41. {
  42. fileName: '2.html',
  43. fileSize: 3000
  44. },
  45. {
  46. fileName: '3.txt',
  47. fileSize: 5000
  48. }
  49. ]);
  50. startUpload('flash', [
  51. {
  52. fileName: '4.txt',
  53. fileSize: 1000
  54. },
  55. {
  56. fileName: '5.html',
  57. fileSize: 3000
  58. },
  59. {
  60. fileName: '6.txt',
  61. fileSize: 5000
  62. }
  63. ]);
  64. })

享元模式重构文件上传

可以看到,在上面常规的文件上传例子中,有多少个文件,就创建了多少个对象,对浏览器的内存消耗非常大。
可以通过享元模式改进。
uploadType作为内部状态。一旦明确了uploadType,无论我们使用什么方式上传,这个上传对象都是可以被任何文件共用的。而fileName和fileSize是根据场景而变化的,每个文件的fileName和fileSize都不一样,fileName和fileSize没有办法被共享,它们只能被划分为外部状态。

  1. // 剥离外部状态
  2. var Upload = function (uploadType) {
  3. this.uploadType = uploadType;
  4. };
  5. Upload.prototype.delFile = function (id) {
  6. debugger
  7. uploadManager.setExternalState(id, this); // 当前id对应的对象的外部状态都组装到共享对象中。
  8. if (this.fileSize < 3000) {
  9. return this.dom.parentNode.removeChild(this.dom);
  10. }
  11. if (window.confirm('确定要删除该文件吗? ' + this.fileName)) {
  12. return this.dom.parentNode.removeChild(this.dom);
  13. }
  14. };
  15. // 工厂进行对象实例化
  16. var UploadFactory = (function () {
  17. var createdFlyWeightObjs = {};
  18. return {
  19. create: function (uploadType) {
  20. if (createdFlyWeightObjs[uploadType]) {
  21. return createdFlyWeightObjs[uploadType];
  22. }
  23. return createdFlyWeightObjs[uploadType] = new Upload(uploadType);
  24. }
  25. }
  26. })();
  27. // 管理器封装外部状态
  28. var uploadManager = (function () {
  29. var uploadDatabase = {};
  30. return {
  31. add: function (id, uploadType, fileName, fileSize) {
  32. var flyWeightObj = UploadFactory.create(uploadType);
  33. var dom = document.createElement('div');
  34. dom.innerHTML =
  35. '<span>文件名称:' + fileName + ',文件大小: ' + fileSize + '</span>' +
  36. '<button class="delFile">删除</button>';
  37. dom.querySelector('.delFile').onclick = function () {
  38. flyWeightObj.delFile(id);
  39. }
  40. document.body.appendChild(dom);
  41. uploadDatabase[id] = {
  42. fileName: fileName,
  43. fileSize: fileSize,
  44. dom: dom
  45. };
  46. return flyWeightObj;
  47. },
  48. setExternalState: function (id, flyWeightObj) {
  49. var uploadData = uploadDatabase[id];
  50. for (var i in uploadData) {
  51. flyWeightObj[i] = uploadData[i];
  52. }
  53. }
  54. }
  55. })();
  56. var id = 0;
  57. window.startUpload = function (uploadType, files) {
  58. for (var i = 0, file; file = files[i++];) {
  59. var uploadObj = uploadManager.add(++id, uploadType, file.fileName, file.fileSize);
  60. console.warn(uploadObj)
  61. }
  62. };

享元模式重构之前的代码里一共创建了6个upload对象,而通过享元模式重构之后,对象的数量减少为2,更幸运的是,就算现在同时上传2000个文件,需要创建的upload对象数量依然是2。

享元模式的适用性

享元模式是一种很好的性能优化方案,但它也会带来一些复杂性的问题,从前面两组代码的比较可以看到,使用了享元模式之后,我们需要分别多维护一个factory对象和一个manager对象,在大部分不必要使用享元模式的环境下,这些开销是可以避免的。
享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。

  • 一个程序中使用了大量的相似对象。
  • 由于使用了大量对象,造成很大的内存开销。
  • 对象的大多数状态都可以变为外部状态。
  • 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。

对象池

对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接new,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责之后,再进入池子等待被下次获取。

  1. var toolTipFactory = (function () {
  2. var toolTipPool = []; // toolTip对象池
  3. return {
  4. create: function () {
  5. if (toolTipPool.length === 0) { // 如果对象池为空
  6. var div = document.createElement('div'); // 创建一个dom
  7. document.body.appendChild(div);
  8. return div;
  9. } else { // 如果对象池里不为空
  10. return toolTipPool.shift(); // 则从对象池中取出一个dom
  11. }
  12. },
  13. recover: function (tooltipDom) {
  14. return toolTipPool.push(tooltipDom); // 对象池回收dom
  15. }
  16. }
  17. })();
  18. var ary = [];
  19. for (var i = 0, str; str = ['A', 'B'][i++];) {
  20. var toolTip = toolTipFactory.create();
  21. toolTip.innerHTML = str;
  22. ary.push(toolTip);
  23. };
  24. for (var i = 0, toolTip; toolTip = ary[i++];) {
  25. toolTipFactory.recover(toolTip);
  26. };
  27. for (var i = 0, str; str = ['A', 'B', 'C', 'D', 'E', 'F'][i++];) {
  28. var toolTip = toolTipFactory.create();
  29. toolTip.innerHTML = str;
  30. };

对象池的通用实现

  1. var objectPoolFactory = function( createObjFn ){
  2. var objectPool = [];
  3. return {
  4. create: function(){
  5. var obj = objectPool.length === 0 ?
  6. createObjFn.apply( this, arguments ) : objectPool.shift();
  7. return obj;
  8. },
  9. recover: function( obj ){
  10. objectPool.push( obj );
  11. }
  12. }
  13. };

通用对象池装载iFrame的实现

  1. var iframeFactory = objectPoolFactory(function () {
  2. var iframe = document.createElement('iframe');
  3. document.body.appendChild(iframe);
  4. iframe.onload = function () {
  5. iframe.onload = null; // 防止iframe重复加载的bug
  6. iframeFactory.recover(iframe); // iframe加载完成之后回收节点
  7. }
  8. return iframe;
  9. });
  10. var iframe1 = iframeFactory.create();
  11. iframe1.src = 'http://baidu.com';
  12. var iframe2 = iframeFactory.create();
  13. iframe2.src = 'http://QQ.com';
  14. setTimeout(function () {
  15. var iframe3 = iframeFactory.create();
  16. iframe3.src = 'http://163.com';
  17. }, 3000);