设计模式之享元模式

参考资料

  • 曾探《JavaScript设计模式与开发实践》;
  • 「设计模式 JavaScript 描述」享元模式
  • 设计模式之享元模式
  • Javascript 设计模式 - 享元模式

定义

享元模式的英文叫:Flyweight Design Pattern。享元设计模式是用于性能优化的模式,这种设计模式的核心在于可以共享技术并支持对大量细分过后的对象进行调整,如果系统中因为创建大量类似的对象而导致内存占用过高,享元设计模式在其中就会起到非常重要的作用,因为它可以使其减少重复创建相同类似的实例对象。在JavaScript中浏览器特别是移动端的浏览器部分所能够使用的内存并不是很多,所以在其中节省内存就变得至关重要。

享元模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式。

享元模式(Flyweight Pattern)主要用于减少创建对象的数量,以减少内存占用和提高性能。

就是分享之意,指一物被众人共享,而这也正是该模式的终旨所在,意为单元,蝇量级的个体,该模式的核心就是使用共享技术来有效的支持大量的细粒度对象。

使用场景:

  • 数据库连接池;
  • 线程池;
  • String常量池;

内部状态与外部状态

享元模式要求将对象的属性划分为内部状态外部状态(状态在这里通常指属性)。享元模式的目标是尽量减少共享对象的数量,那么如何划分内部状态和外部状态呢?

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

把所有内部状态相同的对象都指定为同一个共享的对象。而外部状态 可以从对象身上剥离出来,并储存在外部。

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

使用享元模式的关键是如何区别内部状态外部状态

举例说明-下象棋

我们还是通过网络上开房间下象棋的经典案例来说明享元设计模式。一个象棋游戏平台肯定有很多的房间,每个房间都有一盘棋,每盘棋上有32颗棋子,就有32个对象,但是每个房间里的【将】的属性有很多都是相同的,比如名字、颜色等,而且它们在各个房间的棋盘中都是不会发生变化的,唯一不同的就是所在的位置不一样。所以相同的属性就可以抽出来作为共享单元。

看例子:

享元类,即棋子公共的属性

public class ShareChessAttr {
    private Integer id;
    private String name;
    private String color;
    public ShareChessAttr(Integer id, String name, String color) {
        this.id = id;
        this.name = name;
        this.color = color;
    }
}

定义一个工厂去获取对应棋子的享元

public class ShareChessAttrFactory {
    public static final HashMap<Integer, ShareChessAttr> shareChessAttrMap = new HashMap();
    static {
        shareChessAttrMap.put(1, new ShareChessAttr(1, "将", "红"));
        shareChessAttrMap.put(2, new ShareChessAttr(2, "帅", "黑"));
        shareChessAttrMap.put(3, new ShareChessAttr(3, "车", "红"));
        shareChessAttrMap.put(4, new ShareChessAttr(4, "车", "黑"));
    }
    public static ShareChessAttr getShareChessAttr(Integer i) {
        return shareChessAttrMap.get(i);
    }
}

定义棋子

public class Chess {
    private ShareChessAttr shareChessAttr;
    private Integer positionX;
    private Integer positionY;
    public Chess(ShareChessAttr shareChessAttr, Integer positionX, Integer positionY) {
        this.shareChessAttr = shareChessAttr;
        this.positionX = positionX;
        this.positionY = positionY;
    }
}

定义棋盘

public class ChessBoard {
    private HashMap<Integer, Chess> chessOnBoard = new HashMap<Integer, Chess>();
    public ChessBoard() {
        chessOnBoard.put(1, new Chess(ShareChessAttrFactory.getShareChessAttr(1), 5, 0));
        chessOnBoard.put(2, new Chess(ShareChessAttrFactory.getShareChessAttr(2), 5, 0));
    }
}

如此每个棋盘中的棋子的id,名字和颜色都指向了同一个shareChessAttr。实现享元。

举例说明- HashMap 对象池

代码关键点:用 HashMap 对象池存储这些对象。

// 享元模式,对象池缓存对象
class colorFactory {
  constructor(name) {
    this.colors = {};
  }
  create(name) {
    let color = this.colors[name];
    if (color) return color;
    this.colors[name] = new Color(name);
    return this.colors[name];
  }
};

举例说明- 文件上传

在云文件上传模块的开发中,我们可以借助享元模式提升了程序的性能。下面我们就讲述这个例子。

对象爆炸

在云文件上传模块的开发中,可能会出现对象爆炸的问题。云文件的文件上传功能虽然可以选择依照队列,一个一个地排队上传,但也支持同时选择 2000 个文件。每一个文件都对应着一个 JavaScript 上传对象的创建,可是往程序里同时 new 了 2000 个 upload 对象,结 果可想而知,Chrome 中还勉强能够支撑,IE 下直接进入假死状态。

云文件支持好几种上传方式,比如浏览器插件、Flash 和表单上传等,为了简化例子,我们先假设只有插件和 Flash 这两种。不论是插件上传,还是 Flash 上传,原理都是一样的,当用户选择了文件之后,插件和 Flash 都会通知调用 Window 下的一个全局 JavaScript 函数,它的名字是 startUpload,用户选择的文件列表被组合成一个数组 files 塞进该函数的参数列表里,代码如下:

let id = 0;

window.startUpload = function (uploadType, files) { // uploadType 区分是控件还是 flash 
	for (let i = 0; i < files.length; i++) {
		const uploadObj = new Upload(uploadType, files[i].fileName, files[i].fileSize);
		uploadObj.init(id++); // 给 upload 对象设置一个唯一的 id 
	}
};

当用户选择完文件之后,startUpload 函数会遍历 files 数组来创建对应的 upload 对象。接下来定义 Upload 构造函数,它接受 3 个参数,分别是插件类型文件名文件大小。这些信息都已经被插件组装在 files 数组里返回,代码如下:

const Upload = function (uploadType, fileName, fileSize) {
	this.uploadType = uploadType;
	this.fileName = fileName;
	this.fileSize = fileSize;
	this.dom = null;
};

Upload.prototype.init = function (id) {
	const that = this;
	this.id = id;
	this.dom = document.createElement('div');
	this.dom.innerHTML =
		'文件名称:' + this.fileName + ', 文件大小: ' + this.fileSize + '' +
		'';
	this.dom.querySelector('.delFile').onclick = function () {
		that.delFile();
	}
	document.body.appendChild(this.dom);
};

同样为了简化示例,我们暂且去掉了 upload 对象的其他功能,只保留删除文件的功能,对应 的方法是 Upload.prototype.delFile。该方法中有一个逻辑:当被删除的文件小于 3000 KB 时,该文件将被直接删除。否则页面中会弹出一个提示框,提示用户是否确认要删除该文件,代码如下:

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.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
	}
]);

当点击删除最后一个文件时,可以看到弹出了是否确认删除的提示,如下图所示。

享元模式重构文件上传

上一节的代码是第一版的文件上传,在这段代码里有多少个需要上传的文件,就一共创建了多少个 upload 对象,接下来我们用享元模式重构它。

首先,我们需要确认插件类型 uploadType 是内部状态,那为什么单单 uploadType 是内部状态呢?前面讲过,划分内部状态和外部状态的关键主要有以下几点。

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

在文件上传的例子里,upload 对象必须依赖 uploadType 属性才能工作,这是因为插件上传、Flash 上传、表单上传的实际工作原理有很大的区别,它们各自调用的接口也是完全不一样的,必须在对象创建之初就明确它是什么类型的插件,才可以在程序的运行过程中,让它们分别调用各自的 startpausecanceldel 等方法。

实际上在云文件的真实代码中,虽然插件和 Flash 上传对象最终创建自一个大的工厂类,但它们实际上根据 uploadType 值的不同,分别是来自于两个不同类的对象。(在目前的例子中,为了 简化代码,我们把插件和 Flash 的构造函数合并成了一个。)

一旦明确了 uploadType,无论我们使用什么方式上传,这个上传对象都是可以被任何文件共 用的。而 fileNamefileSize 是根据场景而变化的,每个文件的 fileNamefileSize 都不一样,fileNamefileSize 没有办法被共享,它们只能被划分为外部状态。

剥离外部状态

明确了 uploadType 作为内部状态之后,我们再把其他的外部状态从构造函数中抽离出来,Upload 构造函数中只保留 uploadType 参数:

const Upload = function (uploadType) {
	this.uploadType = uploadType;
};

Upload.prototype.init 函数也不再需要,因为 upload 对象初始化的工作被放在了 uploadManager.add 函数里面,接下来只需要定义 Upload.prototype.del 函数即可:

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);
	}
};

在开始删除文件之前,需要读取文件的实际大小,而文件的实际大小被储存在外部管理器 uploadManager 中,所以在这里需要通过 uploadManager.setExternalState 方法给共享对象设置正确的 fileSize,上段代码中的(1)处表示把当前 id 对应的对象的外部状态都组装到共享对象中。

工厂进行对象实例化

接下来定义一个工厂来创建 upload 对象,如果某种内部状态对应的共享对象已经被创建过,那么直接返回这个对象,否则创建一个新的对象:

const UploadFactory = (function () {
	const createdFlyWeightObjs = {};
	return {
		create: function (uploadType) {
			if (createdFlyWeightObjs[uploadType]) {
				return createdFlyWeightObjs[uploadType];
			}
			return createdFlyWeightObjs[uploadType] = new Upload(uploadType);
		}
	}
})();

管理器封装外部状态

现在我们来完善前面提到的 uploadManager 对象,它负责向 UploadFactory 提交创建对象的请求,并用一个 uploadDatabase 对象保存所有 upload 对象的外部状态,以便在程序运行过程中给 upload 共享对象设置外部状态,代码如下:

const uploadManager = (function () {
	const uploadDatabase = {};
	return {
		add: function (id, uploadType, fileName, fileSize) {
			const flyWeightObj = UploadFactory.create(uploadType);
			const dom = document.createElement('div');
			dom.innerHTML =
				'文件名称:' + fileName + ', 文件大小: ' + fileSize + '' +
				'';
			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) {
			const uploadData = uploadDatabase[id];
			for (const i in uploadData) {
				flyWeightObj[i] = uploadData[i];
			}
		}
	}
})();

然后是开始触发上传动作的 startUpload 函数:

let id = 0;
window.startUpload = function (uploadType, files) {
	for (let i = 0; i < files.length; i++) {
		const uploadObj = uploadManager.add(++id, uploadType, files[i].fileName, files[i].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。

享元模式的适用性

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

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

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

可以看到,文件上传的例子完全符合这四点。

再谈内部状态和外部状态

如果顺利的话,通过前面的例子我们已经了解了内部状态和外部状态的概念以及享元模式的工作原理。我们知道,实现享元模式的关键是把内部状态和外部状态分离开来。有多少种内部状态的组合,系统中便最多存在多少个共享对象,而外部状态储存在共享对象的外部,在必要时被传入共享对象来组装成一个完整的对象。现在来考虑两种极端的情况,即对象没有外部状态和没有内部状态的时候。

没有内部状态的享元

在文件上传的例子中,我们分别进行过插件调用和 Flash 调用,即 startUpload('plugin', [])startUpload('flash', []),导致程序中创建了内部状态不同的两个共享对象。也许你会奇怪,在文件上传程序里,一般都会提前通过特性检测来选择一种上传方式,如果浏览器支持插件就用插件上传,如果不支持插件,就用 Flash 上传。那么,什么情况下既需要插件上传又需要 Flash 上传呢?

实际上这个需求是存在的,很多网盘都提供了极速上传(控件)与普通上传(Flash)两种模式,如果极速上传不好使(可能是没有安装控件或者控件损坏),用户还可以随时切换到普通上传模式,所以这里确实是需要同时存在两个不同的 upload 共享对象。

但不是每个网站都必须做得如此复杂,很多小一些的网站就只支持单一的上传方式。假设我们是这个网站的开发者,不需要考虑极速上传与普通上传之间的切换,这意味着在之前的代码中作为内部状态的 uploadType 属性是可以删除掉的。 在继续使用享元模式的前提下,构造函数 Upload 就变成了无参数的形式:

const Upload = function(){}; 

其他属性如 fileNamefileSizedom 依然可以作为外部状态保存在共享对象外部。在 uploadType 作为内部状态的时候,它可能为控件,也可能为 Flash,所以当时最多可以组合出两个共享对象。而现在已经没有了内部状态,这意味着只需要唯一的一个共享对象。现在我们要改写创建享元对象的工厂,代码如下:

const UploadFactory = (function () {
	let uploadObj;
	return {
		create: function () {
			if (uploadObj) {
				return uploadObj;
			}
			return uploadObj = new Upload();
		}
	}
})();

管理器部分的代码不需要改动,还是负责剥离和组装外部状态。可以看到,当对象没有内部状态的时候,生产共享对象的工厂实际上变成了一个单例工厂。虽然这时候的共享对象没有内部状态的区分,但还是有剥离外部状态的过程,我们依然倾向于称之为享元模式。

没有外部状态的享元

网上许多资料中,经常把 Java 或者 C#的字符串看成享元,这种说法是否正确呢?我们看看下面这段 Java 代码,来分析一下:

// 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 了两个字符串对象 a1a2intern 是一种对象池技术, new String("a").intern()的含义如下。

  • 如果值为 a 的字符串对象已经存在于对象池中,则返回这个对象的引用。
  • 反之,将字符串 a 的对象添加进对象池,并返回这个对象的引用。

所以 a1 == a2 的结果是 true,但这并不是使用了享元模式的结果,享元模式的关键是区别内部状态和外部状态。享元模式的过程是剥离外部状态,并把外部状态保存在其他地方,在合适的时刻再把外部状态组装进共享对象。这里并没有剥离外部状态的过程,a1a2 指向的完全就是同一个对象,所以如果没有外部状态的分离,即使这里使用了共享的技术,但并不是一个纯粹的享元模式。

优缺点

优点:

  • 大大减少对象的创建,降低系统的内存,使效率提高。

缺点:

  • 提高了系统的复杂度,需要分离出外部状态和内部状态,而且外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。

总结

享元模式是为解决性能问题而生的模式,这跟大部分模式的诞生原因都不一样。在一个存在 大量相似对象的系统中,享元模式可以很好地解决大量对象带来的性能问题。

享元模式的思想与单例比较类似,但是单例模式强调的是全局唯一,而享元模式则强调的是内存共享。

你可能感兴趣的:(设计模式,设计模式,享元模式,javascript)