享元模式

2019-11-19 17:52:02 浏览数 (2)

享元模式

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

一个内衣工厂,有50个男性内衣产品和50个女性内衣产品。现在需要请一些模特穿上内衣来拍详情。通常应该怎么做?如何用代码描述这个过程?

你可能这么写:

代码语言:javascript复制
class Model{
  constructor({sex,underwear}){
    this.sex=sex;
    this.underwear=underwear;
  }

  takePhoto(){
    console.log(this.sex,this.underwear)
  }
}

for(let i=0;i<50;i  ){
  const model=new Model({sex:'male',underwear:`underwaer-${i}`});
  model.takePhoto();
}

for(let i=0;i<50;i  ){
  const model=new Model({sex:'female',underwear:`underwaer-${i}`});
  model.takePhoto();
}

如果按照此算法,100个产品将new出100个model对象,如果增加到1w,10w个,很快就会崩溃。

实际上,男女模特一个就够了。

代码语言:javascript复制
class Model{
  constructor(sex){
    this.sex=sex;
  }

  takePhoto(){
    console.log(this.sex,this.underwear)
  }
}


const maleModel=new Model('male');
const femaleModel=new Model('female');
for(let i=0;i<50;i  ){
  maleModel.underwear=`underwaer-${i}`;
  maleModel.takePhoto();
}

for(let i=0;i<50;i  ){
  femaleModel.underwear=`underwaer-${i}`;
  femaleModel.takePhoto();
}

上面的代码很形象地描述了"穿内衣"这个过程,事实就是这样,请一男一女两个模特,然后就可以拍照了。

这个例子便是享元模式的雏形,享元模式要求将对象的属性划分为内部状态与外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,关于如何划分内部状态和外部状态,下面的几条经验提供了一些指引。内部状态存储于对象内部。内部状态可以被一些对象共享。内部状态独立于具体的场景,通常不会改变。外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态可以从对象身上剥离出来并储存在外部。

在上面的例子中,性别是内部状态,内衣是外部状态,通过区分这两种状态,大大减少了系统中的对象数量。通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象,因为性别通常只有男女两种,所以该内衣厂商最多只需要2个对象。

通用结构

上述的代码还存在问题:

  • 我们通过构造函数显式new出了男女两个model对象,在其他系统中,也许并不是一开始就需要所有的共享对象。
  • 给model对象手动设置了underwear外部状态,不是一个最好的方式,因为外部状态可能会相当复杂,它们与共享对象的联系会变得困难。

第一个问题可以通过对象工厂来解决,只有当某种共享对象被真正需要时,它才从工厂中被创建出来。对于第二个问题,可以用一个管理器来记录对象相关的外部状态,使这些外部状态通过某个钩子和共享对象联系起来。

听起来很扯,现在就以文件上传为例。

文件上传

下面看一个实际工作中遇到的问题。(重点)

项目背景:对象爆炸

一个文件上传的功能,要求支持2000个文件。原理如下:上传分为不同的模式(uploadType)比如flash上传,表单上传等。当用户选择了文件之后,控件都会通知调用一个全局startUploadt函数,用户选择的文件列表被组合成一个数组files塞进该函数的参数列表里:

代码语言:javascript复制
let id=0;

const startUpload=(uploadType,files)=>{
  files.forEach((file)=>{
    const {fileName,fileSize}=file;
    const uploadObj=new Upload(uploadType,{fileName,fileSize});
    uploadObj.init(id  );//设置id
  })
}

其中Upload对象是这么写的:

代码语言:javascript复制
class Upload{
  constructor(uploadType,{fileName,fileSize}){
    this.uploadType=uploadType;
    this.fileName=fileName;
    this.fileSize=fileSize;

    this.dom=null;
  }

  init(id){
    this.id=id;
    this.dom=document.createElement('div');
    this.dom.innerHTML=
    `<span>文件名:${this.fileName},文件大小:${this.fileSize}</span>
     <a id="del${id}" style="color:red;" href="javascript:void(0)">删除</a>
    `;

    document.querySelector(`del${id}`).addEventListener('click',(e)=>{
      this.delFile(id);
    })

    document.body.appendChild(this.dom)
  }

  delFile(id){
    //删除业务逻辑,文件小于3000k,直接删除,否则confirm提示。
    if(this.fileSize>3000){
      if(window.confirm('确定删除嘛?')){
        this.dom.parentNode.removeChild(this.dom);
      }
    }else{
      this.dom.parentNode.removeChild(this.dom);
    }
  }
}

假设每个文件都需要一个生成一个upload对象,在数据量到达2000个的时候,直接假死。

尝试用享元模式重构它。

首先要区分外部状态和内部状态,一般来说内部状态和外部状态包括以下要点:

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

显然符合条件的内部状态只有 uploadType。那么fileName和fileSize都可以不作为Upload的内置参数了。正如穿衣这个动作,设置对象外部状态也可以通过一个管理器实现。

代码语言:javascript复制
class Upload{
  constructor(uploadType){
    this.uploadType=uploadType;
    this.dom=null;
  }

  delFile(id){
    // 此处应有一条语句设置外部状态到this上拿到fileName和fileSize并绑定到this
    // ...
    if(this.fileSize>3000){
      if(window.confirm('确定删除嘛?')){
        this.dom.parentNode.removeChild(this.dom);
      }
    }else{
      this.dom.parentNode.removeChild(this.dom);
    }
  }
  // ...
}

其次,实现一个工厂函数来创建Upload对象。这里很像是一个单例的创建。

代码语言:javascript复制
const upLoadFactory=(()=>{
  let createdFlyWeightObjs={};
  return {
    create:function(uploadType){
      if(createdFlyWeightObjs[uploadType]){
        return createdFlyWeightObjs[uploadType];
      }
      createdFlyWeightObjs[uploadType]=new Upload(uploadType);
      return createdFlyWeightObjs[uploadType];
    }
  }
})();

接下来就是设置一个upload管理器(uploadManager),期望实现以下功能:

  • 完成原有对象init的主要工作,向工厂函数提交创建upload对象的请求
  • 用一个uploadDatabase对象保存所有upload对象的外部状态(fileName和fileSize),以便在程序运行过程中给upload共享对象设置外部状态,
代码语言:javascript复制
const uploadManager = (() => {
  // 共享池
  let uploadDataBase = {};
  return {
    add: function (id, uploadType, fileName, fileSize) {
      const filyWeightObj = upLoadFactory.create(uploadType);
      const dom = document.createElement('div');
      dom.innerHTML =
        `<span>文件名:${fileName},文件大小:${fileSize}</span>
        <a id="del${id}" style="color:red;" href="javascript:void(0)">删除</a>
      `;

      document.querySelector(`del${id}`).addEventListener('click', (e) => {
        filyWeightObj.delFile(id);
      })

      document.body.appendChild(dom);
      uploadDataBase[id]={uploadType, fileName, fileSize};

      return filyWeightObj;
    },

        // 期望使用uploadManager.setExternalState(id,this)来绑定到this上
    setExternalState:function(id,filyWeightObj){
      let uploadData=uploadDataBase[id];
      Object.keys(uploadData).forEach((key)=>{
        filyWeightObj[key]=uploadData[key];
      })
    }
  }
})();

add方法拿到upload对象,完成业务逻辑(因为只执行一次),再把上传信息放到状态共享池中。而 setExternalState通过id拿到共享池对应的状态,绑定到upload对象的this上。

代码语言:javascript复制
uploadManager.setExternalState(id,this)

那么,调用方式不再是循环内new一个Upload,而是

代码语言:javascript复制
const startUpload = (uploadType, files) => {
  files.forEach((file) => {
    const { fileName, fileSize } = file;
    cuploadManager.add(  id,uploadType,file.fileName,file.fileSize);
  })
}

最后无论你有几个上传类型,就new几个对象,而不再是一个循环new一个上传对象,大大减少了内存。

适用场景

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

享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。

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

使用小结

我们可以尝试提炼享元模式的实质:实现享元模式的关键是把内部状态和外部状态分离开来,它的实现过程就是剥离外部状态。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在其他地方(共享对象的外部),在必要时被传入共享对象来组装成一个完整的对象。

享元模式和单例模式有什么区别?设想我们去掉内部状态:

代码语言:javascript复制
const upLoadFactory=(()=>{
  return {
    create:function(){
      if(upload){
        return upload;
      }
      upload=new Upload();
      return upload;
    }
  }
})();

这里就完成变成了单例工厂。在这种场景下习惯上仍然被称为享元模式。

但是如果去掉外部状态,你的对象池就有可能会变得异常庞大,那就不是享元模式了。

共享对象池

有内部状态的情况下,对象池维护一个装载空闲对象的池子,如果需要对象的时候,不是直接new,而是转从对象池里获取。如果对象池里没有空闲对象,就往里面加一个。

图书馆就是一个很好的例子,一个班级50人,人手一套《十万个为什么》显然是不划算的。书架上有这套书,借就是了。

对象池技术的应用非常广泛,HTTP连接池和数据库连接池都是其代表应用。在Web前端开发中,对象池使用最多的场景大概就是跟DOM有关的操作。很多空间和时间都消耗在了DOM节点上,如何避免频繁地创建和删除DOM节点就成了一个有意义的话题。

0 人点赞