JavaScript享元模式與性能優(yōu)化

摘要

享元模式是用于性能優(yōu)化的設(shè)計模式之一施逾,在前端編程中有重要的應(yīng)用托猩,尤其是在大量渲染DOM的時候肉微,使用享元模式及對象池技術(shù)能獲得極大優(yōu)化。本文介紹了享元模式的概念些楣,并將其用于渲染大量列表數(shù)據(jù)的優(yōu)化上脂凶。

初識享元模式

在面向?qū)ο缶幊讨校袝r會重復(fù)創(chuàng)建大量相似的對象愁茁,當(dāng)這些對象不能被垃圾回收的時候(比如被閉包在一個回調(diào)函數(shù)中)就會造成內(nèi)存的高消耗蚕钦,在循環(huán)體里創(chuàng)建對象時尤其會出現(xiàn)這種情況。享元模式提出了一種對象復(fù)用的技術(shù)鹅很,即我們不需要創(chuàng)建那么多對象嘶居,只需要創(chuàng)建若干個能夠被復(fù)用的對象(享元對象),然后在實(shí)際使用中給享元對象注入差異促煮,從而使對象有不同的表現(xiàn)邮屁。
為了要創(chuàng)建享元對象,首先要把對象的數(shù)據(jù)劃分為內(nèi)部狀態(tài)外部狀態(tài)菠齿,具體何為內(nèi)部狀態(tài)佑吝,何為外部狀態(tài)取決于你想要創(chuàng)建什么樣的享元對象。
舉個例子:
書這個類绳匀,我想創(chuàng)建的享元對象是“技術(shù)類書籍”芋忿,讓所有技術(shù)類的書都共享這個對象,那么書的類別就是內(nèi)部狀態(tài)疾棵;而書的書名戈钢,作者可能是每本書都不一樣的,那么書的書名和作者就是外部狀態(tài)是尔⊙沉耍或者換一種方式,我想創(chuàng)建“村上春樹寫的書”這種享元對象拟枚,然后讓所有村上春樹寫的書都共享這個享元對象薪铜,此時書的作者就為內(nèi)部狀態(tài)。當(dāng)然也可以讓作者恩溅、分類同時為內(nèi)部狀態(tài)創(chuàng)建一個享元對象痕囱。
享元對象可以按照內(nèi)部狀態(tài)的不同創(chuàng)建若干個,比如技術(shù)類書暴匠,文學(xué)類書鞍恢,雞湯類書三個。在實(shí)踐的時候會發(fā)現(xiàn),抽象程度越高帮掉,所創(chuàng)建的享元對象就越少弦悉,但是外部狀態(tài)就越多;相反抽象程度越低蟆炊,所需創(chuàng)建的享元對象就越多稽莉,外部狀態(tài)就越少。特別地涩搓,當(dāng)對象的所有狀態(tài)都?xì)w為內(nèi)部狀態(tài)時污秆,此時每個對象都可以看作一個享元對象,但是沒有被共享昧甘,相當(dāng)于沒用享元模式良拼。

享元模式的應(yīng)用

還是以書為例子,實(shí)現(xiàn)一個功能:每本書都要打印出自己的書名充边。
先來看看沒用享元模式之前代碼的樣子

const books = [
 {name: "計算機(jī)網(wǎng)絡(luò)", category: "技術(shù)類"},
 {name: "算法導(dǎo)論", category: "技術(shù)類"},
 {name: "計算機(jī)組成原理", category: "技術(shù)類"},
 {name: "傲慢與偏見", category: "文學(xué)類"},
 {name: "紅與黑", category: "文學(xué)類"},
 {name: "圍城", category: "文學(xué)類"}
]
class Book {
    constructor(name, category) {
      this.name = name;
      this.category = category
   }
   print() {
     console.log(this.name, this.category)
   }
}
books.forEach((bookData) => {
  const book = new Book(bookData.name, bookData.category)
  const div = document.createElement("div")
  div.innerText = bookData.name
  div.addEventListener("click", () => {
     book.print()
  })
  document.body.appendChild(div)
})

上面代碼先創(chuàng)建了書這個對象庸推,然后把這個對象閉包在了點(diǎn)擊事件的回調(diào)中,可以想象浇冰,如果有一萬本書的話贬媒,這段代碼的內(nèi)存開銷還是很可觀的。現(xiàn)在我們使用享元模式重構(gòu)這段代碼

// 先定義享元對象
class FlyweightBook {
  constructor(category) {
    this.category = category
  }
   // 用于享元對象獲取外部狀態(tài)
   getExternalState(state) {
     for(const p in state) {
        this[p] = state[p]
     }
   }
   print() {
     console.log(this.name, this.category)
   }
}
// 然后定義一個工廠肘习,來為我們生產(chǎn)享元對象
// 注意际乘,這段代碼實(shí)際上用了單例模式,每個享元對象都為單例, 因?yàn)槲覀儧]必要創(chuàng)建多個相同的享元對象
const flyweightBookFactory = (function() {
   const flyweightBookStore = {}
   return function (category) {
     if (flyweightBookStore[category]) {
       return flyweightBookStore[category]
     }
     const flyweightBook = new FlyweightBook(category)
     flyweightBookStore[category] = flyweightBook
     return flyweightBook
   }
})()
// 然后我們要使用享元對象, 在享元對象被調(diào)用的時候漂佩,能夠得到它的外部狀態(tài)
books.forEach((bookData) => {
   // 先生產(chǎn)出享元對象
   const flyweightBook = flyweightBookFactory(bookData.category)
   const div = document.createElement("div")
   div.innerText = bookData.name
    div.addEventListener("click", () => {
       // 給享元對象設(shè)置外部狀態(tài)
       flyweightBook.getExternalState({name: bookData.name}) // 外部狀態(tài)為書名
       flyweightBook.print()
    })
    document.body.appendChild(div)
})

可以看到以上代碼僅僅閉包了兩個享元對象蚓庭,因?yàn)闀鴥H有兩種類別。兩個享元對象是在使用的時候才獲取到了外部狀態(tài)仅仆,從而在使用時表現(xiàn)出對象本來應(yīng)有的樣子。

思考:如果書的類別有40種垢袱,而作者只有10個墓拜,那么挑選哪個屬性作為內(nèi)部狀態(tài)呢?
當(dāng)然是作者请契,因?yàn)檫@樣只需要創(chuàng)建10個享元對象就行了咳榜。

思考:為何不干脆定義一個沒有內(nèi)部狀態(tài)的享元對象得了,那樣只有一個享元對象用于共享爽锥?
這樣當(dāng)然是可以的涌韩,實(shí)際上變得跟單例模式很像,唯一的區(qū)別就是多了對外部狀態(tài)的注入氯夷。
實(shí)際上內(nèi)部狀態(tài)越少臣樱,要注入的外部狀態(tài)自然越多,而且為了代碼的復(fù)用性,會讓內(nèi)部狀態(tài)盡可能多雇毫。

在一些代碼中會有一個專門用來管理外部狀態(tài)的一個實(shí)例玄捕,這個實(shí)例保存了所有對象的外部狀態(tài),同時提供了一個接口給享元對象來獲取這些外部狀態(tài)(通過id或其它唯一索引)棚放。

對象池技術(shù)與享元模式

在上面例子中會發(fā)現(xiàn)枚粘,每增加一本書就會多一個DOM,哪怕享元對象只有兩個飘蚯,而DOM上萬個的話馍迄,頁面的性能也是很差的。我們發(fā)現(xiàn)局骤,每實(shí)例化一個DOM攀圈,只有它的innerText是不同的,那么我們把DOM的innerText當(dāng)做外部狀態(tài)庄涡,其它當(dāng)做內(nèi)部狀態(tài)量承,構(gòu)造出享元對象DOM:

class Div {
  constructor() {
    this.dom = document.createElement("div")
  }
 getExternalState(extState) {
   // 獲取外部狀態(tài)
   this.dom.innerText = extState.innerText
 }
 mount(container) {
    container.appendChild(this.dom)
  }
}

那么什么東西能作為內(nèi)部狀態(tài)呢?在這里其實(shí)不需要內(nèi)部狀態(tài)的穴店,因?yàn)槲覀冴P(guān)注的是享元對象的個數(shù)撕捍,比如頁面上最多顯示20個DOM的話,那么我們就創(chuàng)建20個DOM用來給真正的實(shí)例去共享:

const divFactory = (function() {
   const divPool = []; // 對象池
   return function() {
       if (divPool.length <= 20) {
          const div = new Div()
          divPool.push(div)
          return div
       } else {
          // 滾動行為泣洞,在超過20個時忧风,復(fù)用池中的第一個實(shí)例,返回給調(diào)用者
          const div = divPool.shift()
          divPool.push(div)
          return div
       }
   }
})()

這個工廠就像奸商一樣球凰,在20個之前還是好好的狮腿,每次創(chuàng)建一個div都是新的,到了20個之后呕诉,就拿一些老的div返回給調(diào)用者缘厢,調(diào)用者會發(fā)現(xiàn)這個老的div會包含一些老的數(shù)據(jù)(像翻新機(jī)一樣),但是調(diào)用者不關(guān)心甩挫,因?yàn)樗麜眯碌臄?shù)據(jù)覆蓋掉老的數(shù)據(jù)贴硫。
接下來看調(diào)用者如何使用

// 先創(chuàng)建一個容器,因?yàn)椴话袲OM直接掛在document.body里了
const container = document.createElement("div")
books.forEach((bookData) => {
   // 先生產(chǎn)出享元對象
   const flyweightBook = flyweightBookFactory(bookData.category)
   // const div = document.createElement("div")
   // div.innerText = bookData.name
    const div = divFactory()
    div.getExternalState({innerText: bookData.name})
    // 如果要添加事件的話伊者,在Div里面提供接口添加英遭,在這里會造成重復(fù)添加
    // div.dom.addEventListener("click", () => {
    // 給享元對象設(shè)置外部狀態(tài)
    //   flyweightBook.getExternalState({name: bookData.name}) // 外部狀態(tài)為書名
    //    flyweightBook.print()
    // })
     div.mount(container)
    // document.body.appendChild(div)
})
document.body.appendChild(container)

以上代碼會發(fā)現(xiàn),DOM確實(shí)被復(fù)用了亦渗,但是總是顯示最后的二十個挖诸,這是自然的,可以通過監(jiān)聽滾動事件法精,實(shí)現(xiàn)在滾動的時候加載相應(yīng)的數(shù)據(jù)多律,同時DOM被復(fù)用痴突,B站的彈幕列表就是用了相似的技術(shù)實(shí)現(xiàn)的,以下是全部代碼:

const books = new Array(10000).fill(0).map((v, index) => {
    return Math.random() > 0.5 ? {
              name: `計算機(jī)科學(xué)${index}`,
              category: '技術(shù)類'
            } : {
              name: `傲慢與偏見${index}`,
              category: '文學(xué)類類'
            }
  })

class FlyweightBook {
  constructor(category) {
    this.category = category
  }
   // 用于享元對象獲取外部狀態(tài)
   getExternalState(state) {
     for(const p in state) {
        this[p] = state[p]
     }
   }
   print() {
     console.log(this.name, this.category)
   }
}
// 然后定義一個工廠菱涤,來為我們生產(chǎn)享元對象
// 注意苞也,這段代碼實(shí)際上用了單例模式,每個享元對象都為單例, 因?yàn)槲覀儧]必要創(chuàng)建多個相同的享元對象
const flyweightBookFactory = (function() {
   const flyweightBookStore = {}
   return function (category) {
     if (flyweightBookStore[category]) {
       return flyweightBookStore[category]
     }
     const flyweightBook = new FlyweightBook(category)
     flyweightBookStore[category] = flyweightBook
     return flyweightBook
   }
})()
// DOM的享元對象
class Div {
  constructor() {
    this.dom = document.createElement("div")
  }
 getExternalState(extState, onClick) {
   // 獲取外部狀態(tài)
   this.dom.innerText = extState.innerText
   // 設(shè)置DOM位置
   this.dom.style.top = `${extState.seq * 22}px`
   this.dom.style.position = `absolute`
   this.dom.onclick = onClick
 }
 mount(container) {
    container.appendChild(this.dom)
 }
}

const divFactory = (function() {
   const divPool = []; // 對象池
   return function(innerContainer) {
       let div
       if (divPool.length <= 20) {
          div = new Div()
          divPool.push(div)
       } else {
          // 滾動行為粘秆,在超過20個時如迟,復(fù)用池中的第一個實(shí)例,返回給調(diào)用者
          div = divPool.shift()
          divPool.push(div)
       }
       div.mount(innerContainer)
       return div
   }
})()

// 外層container,用戶可視區(qū)域
const container = document.createElement("div")
// 內(nèi)層container, 包含了所有DOM的總高度
const innerContainer = document.createElement("div")
container.style.maxHeight = '400px'
container.style.width = '200px'
container.style.border = '1px solid'
container.style.overflow = 'auto'
innerContainer.style.height = `${22 * books.length}px` // 由每個DOM的總高度算出內(nèi)層container的高度
innerContainer.style.position = `relative`
container.appendChild(innerContainer)
document.body.appendChild(container)

function load(start, end) {
  // 裝載需要顯示的數(shù)據(jù)
  books.slice(start, end).forEach((bookData, index) => {
     // 先生產(chǎn)出享元對象
    const flyweightBook = flyweightBookFactory(bookData.category)
    const div = divFactory(innerContainer)
    // DOM的高度需要由它的序號計算出來
    div.getExternalState({innerText: bookData.name, seq: start + index}, () => {
      flyweightBook.getExternalState({name: bookData.name})
      flyweightBook.print()
    })
  })
}

load(0, 20)
let cur = 0 // 記錄當(dāng)前加載的首個數(shù)據(jù)
container.addEventListener('scroll', (e) => {
  const start = container.scrollTop / 22 | 0
  if (start !== cur) {
    load(start, start + 20)
    cur = start
  }
})

以上代碼僅僅使用了2個享元對象,21個DOM對象仇参,就完成了10000條數(shù)據(jù)的渲染尉桩,相比起建立10000個book對象和10000個DOM敷搪,性能優(yōu)化是非常明顯的。

以上,水平有限,如有紕漏贤斜,歡迎斧正。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末逛裤,一起剝皮案震驚了整個濱河市瘩绒,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌带族,老刑警劉巖锁荔,帶你破解...
    沈念sama閱讀 223,002評論 6 519
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異蝙砌,居然都是意外死亡阳堕,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,357評論 3 400
  • 文/潘曉璐 我一進(jìn)店門择克,熙熙樓的掌柜王于貴愁眉苦臉地迎上來恬总,“玉大人,你說我怎么就攤上這事肚邢∫佳撸” “怎么了?”我有些...
    開封第一講書人閱讀 169,787評論 0 365
  • 文/不壞的土叔 我叫張陵道偷,是天一觀的道長。 經(jīng)常有香客問我记劈,道長勺鸦,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,237評論 1 300
  • 正文 為了忘掉前任目木,我火速辦了婚禮换途,結(jié)果婚禮上懊渡,老公的妹妹穿的比我還像新娘。我一直安慰自己军拟,他們只是感情好剃执,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,237評論 6 398
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著懈息,像睡著了一般肾档。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上辫继,一...
    開封第一講書人閱讀 52,821評論 1 314
  • 那天怒见,我揣著相機(jī)與錄音,去河邊找鬼姑宽。 笑死遣耍,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的炮车。 我是一名探鬼主播舵变,決...
    沈念sama閱讀 41,236評論 3 424
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼瘦穆!你這毒婦竟也來了纪隙?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 40,196評論 0 277
  • 序言:老撾萬榮一對情侶失蹤难审,失蹤者是張志新(化名)和其女友劉穎瘫拣,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體告喊,經(jīng)...
    沈念sama閱讀 46,716評論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡麸拄,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,794評論 3 343
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了黔姜。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片拢切。...
    茶點(diǎn)故事閱讀 40,928評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖秆吵,靈堂內(nèi)的尸體忽然破棺而出淮椰,到底是詐尸還是另有隱情,我是刑警寧澤纳寂,帶...
    沈念sama閱讀 36,583評論 5 351
  • 正文 年R本政府宣布主穗,位于F島的核電站,受9級特大地震影響毙芜,放射性物質(zhì)發(fā)生泄漏忽媒。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,264評論 3 336
  • 文/蒙蒙 一腋粥、第九天 我趴在偏房一處隱蔽的房頂上張望晦雨。 院中可真熱鬧架曹,春花似錦、人聲如沸闹瞧。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,755評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽奥邮。三九已至万牺,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間漠烧,已是汗流浹背杏愤。 一陣腳步聲響...
    開封第一講書人閱讀 33,869評論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留已脓,地道東北人珊楼。 一個月前我還...
    沈念sama閱讀 49,378評論 3 379
  • 正文 我出身青樓,卻偏偏與公主長得像度液,于是被迫代替她去往敵國和親厕宗。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,937評論 2 361

推薦閱讀更多精彩內(nèi)容