享元模式 (Flyweight Pattern)運用共享技術來有效地支持大量細粒度對象的複用,以減小建立的對象的數量。javascript
享元模式的主要思想是共享細粒度對象,也就是說若是系統中存在多個相同的對象,那麼只需共享一份就能夠了,沒必要每一個都去實例化每個對象,這樣來精簡內存資源,提高性能和效率。html
Fly 意爲蒼蠅,Flyweight 指輕蠅量級,指代對象粒度很小。前端
注意: 本文用到 ES6 的語法 let/const 、Class、Promise 等,若是還沒接觸過能夠點擊連接稍加學習 ~java
咱們去駕考的時候,若是給每一個考試的人都準備一輛車,那考場就擠爆了,考點都堆不下考試車,所以駕考現場通常會有幾輛車給要考試的人依次使用。若是考生人數少,就分別少準備幾個自動檔和手動檔的駕考車,考生多的話就多準備幾輛。若是考手動檔的考生比較多,就多準備幾輛手動檔的駕考車。node
咱們去考四六級的時候(爲何這麼多考試?😅),若是給每一個考生都準備一個考場,怕是沒那麼多考場也沒有這麼多監考老師,所以現實中的大多數狀況都是幾十個考生共用一個考場。四級考試和六級考試通常同時進行,若是考生考的是四級,那麼就安排四級考場,聽四級的聽力和試卷,六級同理。mysql
生活中相似的場景還有不少,好比咖啡廳的咖啡口味,餐廳的菜品種類,拳擊比賽的重量級等等。git
在相似場景中,這些例子有如下特色:es6
首先假設考生的 ID 爲奇數則考的是手動檔,爲偶數則考的是自動檔。若是給全部考生都 new
一個駕考車,那麼這個系統中就會建立了和考生數量一致的駕考車對象: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 + ' 上考試')
}
for (var candidateId = 1; candidateId <= candidateNum; candidateId++) {
var examCar = new ExamCar(candidateId % 2)
examCar.examine(candidateId)
}
console.log('駕考車總數 - ' + examCarNum)
// 輸出: 駕考車總數 - 10
複製代碼
若是考生不少,那麼系統中就會存在更多個駕考車對象實例,假如駕考車對象比較複雜,那麼這些新建的駕考車實例就會佔用大量內存。這時咱們將同種類型的駕考車實例進行合併,手動檔和自動檔檔駕考車分別引用同一個實例,就能夠節約大量內存:sql
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 個考生的狀況,若是有幾百上千考生,這時咱們節約的內存就比較可觀了,這就是享元模式要達到的目的。
若是你閱讀了以前文章關於繼承部分的講解,那麼你實際上已經接觸到享元模式的思想了。相比於構造函數竊取,在原型鏈繼承和組合繼承中,子類經過原型 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:歡迎你們關注個人公衆號【前端下午茶】,一塊兒加油吧
另外能夠加入「前端下午茶交流羣」微信羣,長按識別下面二維碼便可加我好友,備註加羣,我拉你入羣~