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

摘要

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

初識(shí)享元模式

在面向?qū)ο缶幊讨校袝r(shí)會(huì)重復(fù)創(chuàng)建大量相似的對(duì)象,當(dāng)這些對(duì)象不能被垃圾回收的時(shí)候(比如被閉包在一個(gè)回調(diào)函數(shù)中)就會(huì)造成內(nèi)存的高消耗,在循環(huán)體里創(chuàng)建對(duì)象時(shí)尤其會(huì)出現(xiàn)這種情況。享元模式提出了一種對(duì)象復(fù)用的技術(shù),即我們不需要?jiǎng)?chuàng)建那么多對(duì)象,只需要?jiǎng)?chuàng)建若干個(gè)能夠被復(fù)用的對(duì)象(享元對(duì)象),然后在實(shí)際使用中給享元對(duì)象注入差異,從而使對(duì)象有不同的表現(xiàn)。
為了要?jiǎng)?chuàng)建享元對(duì)象,首先要把對(duì)象的數(shù)據(jù)劃分為內(nèi)部狀態(tài)外部狀態(tài),具體何為內(nèi)部狀態(tài),何為外部狀態(tài)取決于你想要?jiǎng)?chuàng)建什么樣的享元對(duì)象。
舉個(gè)例子:
書這個(gè)類,我想創(chuàng)建的享元對(duì)象是“技術(shù)類書籍”,讓所有技術(shù)類的書都共享這個(gè)對(duì)象,那么書的類別就是內(nèi)部狀態(tài);而書的書名,作者可能是每本書都不一樣的,那么書的書名和作者就是外部狀態(tài)?;蛘邠Q一種方式,我想創(chuàng)建“村上春樹寫的書”這種享元對(duì)象,然后讓所有村上春樹寫的書都共享這個(gè)享元對(duì)象,此時(shí)書的作者就為內(nèi)部狀態(tài)。當(dāng)然也可以讓作者、分類同時(shí)為內(nèi)部狀態(tài)創(chuàng)建一個(gè)享元對(duì)象。
享元對(duì)象可以按照內(nèi)部狀態(tài)的不同創(chuàng)建若干個(gè),比如技術(shù)類書,文學(xué)類書,雞湯類書三個(gè)。在實(shí)踐的時(shí)候會(huì)發(fā)現(xiàn),抽象程度越高,所創(chuàng)建的享元對(duì)象就越少,但是外部狀態(tài)就越多;相反抽象程度越低,所需創(chuàng)建的享元對(duì)象就越多,外部狀態(tài)就越少。特別地,當(dāng)對(duì)象的所有狀態(tài)都?xì)w為內(nèi)部狀態(tài)時(shí),此時(shí)每個(gè)對(duì)象都可以看作一個(gè)享元對(duì)象,但是沒有被共享,相當(dāng)于沒用享元模式。

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

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

const books = [
 {name: "計(jì)算機(jī)網(wǎng)絡(luò)", category: "技術(shù)類"},
 {name: "算法導(dǎo)論", category: "技術(shù)類"},
 {name: "計(jì)算機(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)建了書這個(gè)對(duì)象,然后把這個(gè)對(duì)象閉包在了點(diǎn)擊事件的回調(diào)中,可以想象,如果有一萬本書的話,這段代碼的內(nèi)存開銷還是很可觀的。現(xiàn)在我們使用享元模式重構(gòu)這段代碼

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

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

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

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

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

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

在上面例子中會(huì)發(fā)現(xiàn),每增加一本書就會(huì)多一個(gè)DOM,哪怕享元對(duì)象只有兩個(gè),而DOM上萬個(gè)的話,頁(yè)面的性能也是很差的。我們發(fā)現(xiàn),每實(shí)例化一個(gè)DOM,只有它的innerText是不同的,那么我們把DOM的innerText當(dāng)做外部狀態(tài),其它當(dāng)做內(nèi)部狀態(tài),構(gòu)造出享元對(duì)象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)注的是享元對(duì)象的個(gè)數(shù),比如頁(yè)面上最多顯示20個(gè)DOM的話,那么我們就創(chuàng)建20個(gè)DOM用來給真正的實(shí)例去共享:

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

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

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

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

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

class FlyweightBook {
  constructor(category) {
    this.category = category
  }
   // 用于享元對(duì)象獲取外部狀態(tài)
   getExternalState(state) {
     for(const p in state) {
        this[p] = state[p]
     }
   }
   print() {
     console.log(this.name, this.category)
   }
}
// 然后定義一個(gè)工廠,來為我們生產(chǎn)享元對(duì)象
// 注意,這段代碼實(shí)際上用了單例模式,每個(gè)享元對(duì)象都為單例, 因?yàn)槲覀儧]必要?jiǎng)?chuàng)建多個(gè)相同的享元對(duì)象
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的享元對(duì)象
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 = []; // 對(duì)象池
   return function(innerContainer) {
       let div
       if (divPool.length <= 20) {
          div = new Div()
          divPool.push(div)
       } else {
          // 滾動(dòng)行為,在超過20個(gè)時(shí),復(fù)用池中的第一個(gè)實(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` // 由每個(gè)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)出享元對(duì)象
    const flyweightBook = flyweightBookFactory(bookData.category)
    const div = divFactory(innerContainer)
    // DOM的高度需要由它的序號(hào)計(jì)算出來
    div.getExternalState({innerText: bookData.name, seq: start + index}, () => {
      flyweightBook.getExternalState({name: bookData.name})
      flyweightBook.print()
    })
  })
}

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

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容