享元模式 (Flyweight Pattern)运用共享技术来有效地支持大量细粒度对象的复用,以减小建立的对象的数量。javascript
享元模式的主要思想是共享细粒度对象,也就是说若是系统中存在多个相同的对象,那么只需共享一份就能够了,没必要每一个都去实例化每个对象,这样来精简内存资源,提高性能和效率。html
Fly 意为苍蝇,Flyweight 指轻蝇量级,指代对象粒度很小。前端
注意: 本文用到 ES6 的语法 let/const 、 Class、 Promise 等,若是还没接触过能够点击连接稍加学习 ~
咱们去驾考的时候,若是给每一个考试的人都准备一辆车,那考场就挤爆了,考点都堆不下考试车,所以驾考现场通常会有几辆车给要考试的人依次使用。若是考生人数少,就分别少准备几个自动档和手动档的驾考车,考生多的话就多准备几辆。若是考手动档的考生比较多,就多准备几辆手动档的驾考车。java
咱们去考四六级的时候(为何这么多考试?😅),若是给每一个考生都准备一个考场,怕是没那么多考场也没有这么多监考老师,所以现实中的大多数状况都是几十个考生共用一个考场。四级考试和六级考试通常同时进行,若是考生考的是四级,那么就安排四级考场,听四级的听力和试卷,六级同理。node
生活中相似的场景还有不少,好比咖啡厅的咖啡口味,餐厅的菜品种类,拳击比赛的重量级等等。mysql
在相似场景中,这些例子有如下特色:git
首先假设考生的 ID 为奇数则考的是手动档,为偶数则考的是自动档。若是给全部考生都 new
一个驾考车,那么这个系统中就会建立了和考生数量一致的驾考车对象:es6
var candidateNum = 10 // 考生数量 var examCarNum = 0 // 驾考车的数量 /* 驾考车构造函数 */ function ExamCar(carType) { examCarNum++ this.carId = examCarNum this.carType = carType ? '手动档' : '自动档' } ExamCar.prototype.examine = function(candidateId) { console.log('考生- ' + candidateId + ' 在' + this.carType + '驾考车- ' + this.carId + ' 上考试') } for (var candidateId = 1; candidateId <= candidateNum; candidateId++) { var examCar = new ExamCar(candidateId % 2) examCar.examine(candidateId) } console.log('驾考车总数 - ' + examCarNum) // 输出: 驾考车总数 - 10
若是考生不少,那么系统中就会存在更多个驾考车对象实例,假如驾考车对象比较复杂,那么这些新建的驾考车实例就会占用大量内存。这时咱们将同种类型的驾考车实例进行合并,手动档和自动档档驾考车分别引用同一个实例,就能够节约大量内存:github
var candidateNum = 10 // 考生数量 var examCarNum = 0 // 驾考车的数量 /* 驾考车构造函数 */ function ExamCar(carType) { examCarNum++ this.carId = examCarNum this.carType = carType ? '手动档' : '自动档' } ExamCar.prototype.examine = function(candidateId) { console.log('考生- ' + candidateId + ' 在' + this.carType + '驾考车- ' + this.carId + ' 上考试') } var manualExamCar = new ExamCar(true) var autoExamCar = new ExamCar(false) for (var candidateId = 1; candidateId <= candidateNum; candidateId++) { var examCar = candidateId % 2 ? manualExamCar : autoExamCar examCar.examine(candidateId) } console.log('驾考车总数 - ' + examCarNum) // 输出: 驾考车总数 - 2
能够看到咱们使用 2 个驾考车实例就实现了刚刚 10 个驾考车实例实现的功能。这是仅有 10 个考生的状况,若是有几百上千考生,这时咱们节约的内存就比较可观了,这就是享元模式要达到的目的。sql
若是你阅读了以前文章关于继承部分的讲解,那么你实际上已经接触到享元模式的思想了。相比于构造函数窃取,在原型链继承和组合继承中,子类经过原型 prototype
来复用父类的方法和属性,若是子类实例每次都建立新的方法与属性,那么在子类实例不少的状况下,内存中就存在有不少重复的方法和属性,即便这些方法和属性彻底同样,所以这部份内存彻底能够经过复用来优化,这也是享元模式的思想。
传统的享元模式是将目标对象的状态区分为内部状态和外部状态,内部状态相同的对象能够被共享出来指向同一个内部状态。正如以前举的驾考和四六级考试的例子中,自动档仍是手动档、四级仍是六级,就属于驾考考生、四六级考生中的内部状态,对应的驾考车、四六级考场就是能够被共享的对象。而考生的年龄、姓名、籍贯等就属于外部状态,通常没有被共享出来的价值。
主要的原理能够参看下面的示意图:
享元模式的主要思想是细粒度对象的共享和复用,所以对以前的驾考例子,咱们能够继续改进一下:
咱们能够简单实现一下,为了方便起见,这里就直接使用 ES6 的语法。
首先建立 3 个手动档驾考车,而后注册 10 个考生参与考试,一开始确定有 3 个考生同时上车,而后在某个考生考完以后其余考生接着后面考。为了实现这个过程,这里使用了 Promise
,考试的考生在 0 到 2 秒后的随机时间考试完毕归还驾考车,其余考生在前面考生考完以后接着进行考试:
let examCarNum = 0 // 驾考车总数 /* 驾考车对象 */ class ExamCar { constructor(carType) { examCarNum++ this.carId = examCarNum this.carType = carType ? '手动档' : '自动档' this.usingState = false // 是否正在使用 } /* 在本车上考试 */ examine(candidateId) { return new Promise((resolve => { this.usingState = true console.log(`考生- ${ candidateId } 开始在${ this.carType }驾考车- ${ this.carId } 上考试`) setTimeout(() => { this.usingState = false console.log(`%c考生- ${ candidateId } 在${ this.carType }驾考车- ${ this.carId } 上考试完毕`, 'color:#f40') resolve() // 0~2秒后考试完毕 }, Math.random() * 2000) })) } } /* 手动档汽车对象池 */ ManualExamCarPool = { _pool: [], // 驾考车对象池 _candidateQueue: [], // 考生队列 /* 注册考生 ID 列表 */ registCandidates(candidateList) { candidateList.forEach(candidateId => this.registCandidate(candidateId)) }, /* 注册手动档考生 */ registCandidate(candidateId) { const examCar = this.getManualExamCar() // 找一个未被占用的手动档驾考车 if (examCar) { examCar.examine(candidateId) // 开始考试,考完了让队列中的下一个考生开始考试 .then(() => { const nextCandidateId = this._candidateQueue.length && this._candidateQueue.shift() nextCandidateId && this.registCandidate(nextCandidateId) }) } else this._candidateQueue.push(candidateId) }, /* 注册手动档车 */ initManualExamCar(manualExamCarNum) { for (let i = 1; i <= manualExamCarNum; i++) { this._pool.push(new ExamCar(true)) } }, /* 获取状态为未被占用的手动档车 */ getManualExamCar() { return this._pool.find(car => !car.usingState) } } ManualExamCarPool.initManualExamCar(3) // 一共有3个驾考车 ManualExamCarPool.registCandidates([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) // 10个考生来考试
在浏览器中运行下试试:
能够看到一个驾考的过程被模拟出来了,这里只简单实现了手动档,自动档驾考场景同理,就不进行实现了。上面的实现还能够进一步优化,好比考生多的时候自动新建驾考车,考生少的时候逐渐减小驾考车,但又不能无限新建驾考车对象,这些状况读者能够自行发挥~
若是能够将目标对象的内部状态和外部状态区分的比较明显,就能够将内部状态一致的对象很方便地共享出来,可是对 JavaScript 来讲,咱们并不必定要严格区份内部状态和外部状态才能进行资源共享,好比资源池模式。
上面这种改进的模式通常叫作资源池(Resource Pool),或者叫对象池(Object Pool),能够看成是享元模式的升级版,实现不同,可是目的相同。资源池通常维护一个装载对象的池子,封装有获取、释放资源的方法,当须要对象的时候直接从资源池中获取,使用完毕以后释放资源等待下次被获取。
在上面的例子中,驾考车至关于有限资源,考生做为访问者根据资源的使用状况从资源池中获取资源,若是资源池中的资源都正在被占用,要么资源池建立新的资源,要么访问者等待占用的资源被释放。
资源池在后端应用至关普遍,好比缓冲池、链接池、线程池、字符常量池等场景,前端使用场景很少,可是也有使用,好比有些频繁的 DOM 建立销毁操做,就能够引入对象池来节约一些 DOM 建立损耗。
下面介绍资源池的几种主要应用。
以 Node.js 中的线程池为例,Node.js 的 JavaScript 引擎是执行在单线程中的,启动的时候会新建 4 个线程放到线程池中,当遇到一些异步 I/O 操做(好比文件异步读写、DNS 查询等)或者一些 CPU 密集的操做(Crypto、Zlib 模块等)的时候,会在线程池中拿出一个线程去执行。若是有须要,线程池会按需建立新的线程。
线程池在整个 Node.js 事件循环中的位置能够参照下图:
上面这个图就是 Node.js 的事件循环(Event Loop)机制,简单解读一下(扩展视野,不必定须要懂):
感兴趣的同窗能够阅读《深刻浅出 Nodejs》或 Node.js 依赖的底层库 Libuv 官方文档 来了解更多。
根据二八原则,80% 的请求其实访问的是 20% 的资源,咱们能够将频繁访问的资源缓存起来,若是用户访问被缓存起来的资源就直接返回缓存的版本,这就是 Web 开发中常常遇到的缓存。
缓存服务器就是缓存的最多见应用之一,也是复用资源的一种经常使用手段。缓存服务器的示意图以下:
缓存服务器位于访问者与业务服务器之间,对业务服务器来讲,减轻了压力,减少了负载,提升了数据查询的性能。对用户来讲,提高了网页打开速度,优化了体验。
缓存技术用的很是多,不只仅用在缓存服务器上,浏览器本地也有缓存,查询的 DNS 也有缓存,包括咱们的电脑 CPU 上,也有缓存硬件。
咱们知道对数据库进行操做须要先建立一个数据库链接对象,而后经过建立好的数据库链接来对数据库进行 CRUD(增删改查)操做。若是访问量不大,对数据库的 CRUD 操做就很少,每次访问都建立链接并在使用完销毁链接就没什么,可是若是访问量比较多,并发的要求比较高时,频繁建立和销毁链接就比较消耗资源了。
这时,能够不销毁链接,一直使用已建立的链接,就能够避免频繁建立销毁链接的损耗了。可是有个问题,一个链接同一时间只能作一件事,某使用者(通常是线程)正在使用时,其余使用者就不可使用了,因此若是只建立一个不关闭的链接显然不符合要求,咱们须要建立多个不关闭的链接。
这就是链接池的来源,建立多个数据库链接,当有调用的时候直接在建立好的链接中拿出来使用,使用完毕以后将链接放回去供其余调用者使用。
咱们以 Node.js 中 mysql
模块的链接池应用为例,看看后端通常是如何使用数据库链接池的。在 Node.js 中使用 mysql
建立单个链接,通常这样使用:
var mysql = require('mysql') var connection = mysql.createConnection({ // 建立数据库链接 host: 'localhost', user: 'root', // 用户名 password: '123456', // 密码 database: 'db', // 指定数据库 port: '3306' // 端口号 }) // 链接回调,在回调中增删改查 connection.connect(...) // 关闭链接 connection.end(...)
在 Node.js 中使用 mysql 模块的链接池建立链接:
var mysql = require('mysql') var pool = mysql.createPool({ // 建立数据库链接池 host: 'localhost', user: 'root', // 用户名 password: '123456', // 密码 database: 'db', // 制定数据库 port: '3306' // 端口号 }) // 从链接池中获取一个链接,进行增删改查 pool.getConnection(function(err, connection) { // ... 数据库操做 connection.release() // 将链接释放回链接池中 }) // 关闭链接池 pool.end()
通常链接池在初始化的时候,都会自动打开 n 个链接,称为链接预热。若是这 n 个链接都被使用了,再从链接池中请求新的链接时,会动态地隐式建立额外链接,即自动扩容。若是扩容后的链接池一段时间后有很多链接没有被调用,则自动缩容,适当释放空闲链接,增长链接池中链接的使用效率。在链接失效的时候,自动抛弃无效链接。在系统关闭的时候,自动释放全部链接。为了维持链接池的有效运转和避免链接池无限扩容,还会给链接池设置最大最小链接数。
这些都是链接池的功能,能够看到链接池通常能够根据当前使用状况自动地进行缩容和扩容,来进行链接池资源的最优化,和链接池链接的复用效率最大化。这些链接池的功能点,看着是否是和以前驾考例子的优化过程有点似曾相识呢~
在实际项目中,除了数据库链接池外,还有 HTTP 链接池。使用 HTTP 链接池管理长链接能够复用 HTTP 链接,省去建立 TCP 链接的 3 次握手和关闭 TCP 链接的 4 次挥手的步骤,下降请求响应的时间。
链接池某种程度也算是一种缓冲池,只不过这种缓冲池是专门用来管理链接的。
不少语言的引擎为了减小字符串对象的重复建立,会在内存中维护有一个特殊的内存,这个内存就叫字符常量池。当建立新的字符串时,引擎会对这个字符串进行检查,与字符常量池中已有的字符串进行比对,若是存在有相同内容的字符串,就直接将引用返回,不然在字符常量池中建立新的字符常量,并返回引用。
相似于 Java、C# 这些语言,都有字符常量池的机制。JavaScript 有多个引擎,以 Chrome 的 V8 引擎为例,V8 在把 JavaScript 编译成字节码过程当中就引入了字符常量池这个优化手段,这就是为何不少 JavaScript 的书籍都提到了 JavaScript 中的字符串具备不可变性,由于若是内存中的字符串可变,一个引用操做改变了字符串的值,那么其余一样的字符串也会受到影响。
V8 引擎中的字符常量池存在一个变量 string_table_
中,这个变量保存有全部的字符串 All strings are copied here, one after another
,地址位于 v8/src/ast/ast-value-factory.h,核心方法是 LookupOrInsert,这个方法给每个字符串计算出 hash 值,并从 table 中搜索,没有则插入,感兴趣的同窗能够自行阅读。
能够引用《JavaScript 高级程序设计》中的话解释一下:
ECMAScript 中的字符串是不可变的,也就是说,字符串一旦建立,它们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,而后再用另外一个包含新值的字符串填充该变量。
字符常量池也是复用资源的一种手段,只不过这种手段一般用在编译器的运行过程当中,一般开发(搬砖)过程用不到,了解便可。
享元模式的优势:
享元模式的缺点:
在一些程序中,若是引入享元模式对系统的性能和内存的占用影响不大时,好比目标对象很少,或者场景比较简单,则不须要引入,以避免拔苗助长。
享元模式和单例模式、工厂模式、组合模式、策略模式、状态模式等等常常会一块儿使用。
在区分出不一样种类的外部状态后,建立新对象时须要选择不一样种类的共享对象,这时就可使用工厂模式来提供共享对象,在共享对象的维护上,常常会采用单例模式来提供单实例的共享对象。
在使用工厂模式来提供共享对象时,好比某些时候共享对象中的某些状态就是对象不须要的,能够引入组合模式来提高自定义共享对象的自由度,对共享对象的组成部分进一步归类、分层,来实现更复杂的多层次对象结构,固然系统也会更难维护。
策略模式中的策略属于一系列功能单1、细粒度的细粒度对象,能够做为目标对象来考虑引入享元模式进行优化,可是前提是这些策略是会被频繁使用的,若是不常用,就没有必要了。
参考文章:
本文出自个人专栏 <JavaScript 设计模式精讲> 中的一篇,感兴趣的同窗能够点击连接看看更多文章~
PS:欢迎你们关注个人公众号【前端下午茶】,一块儿加油吧~
另外能够加入「前端下午茶交流群」微信群,长按识别下面二维码便可加我好友,备注加群,我拉你入群~