JavaScript享元模式与性能优化

摘要

享元模式是用于性能优化的设计模式之一,在前端编程中有重要的应用,尤其是在大量渲染DOM的时候,使用享元模式及对象池技术能获得极大优化。本文介绍了享元模式的概念,并将其用于渲染大量列表数据的优化上。

初识享元模式

在面向对象编程中,有时会重复创建大量相似的对象,当这些对象不能被垃圾回收的时候(比如被闭包在一个回调函数中)就会造成内存的高消耗,在循环体里创建对象时尤其会出现这种情况。享元模式提出了一种对象复用的技术,即我们不需要创建那么多对象,只需要创建若干个能够被复用的对象(享元对象),然后在实际使用中给享元对象注入差异,从而使对象有不同的表现。
为了要创建享元对象,首先要把对象的数据划分为内部状态外部状态,具体何为内部状态,何为外部状态取决于你想要创建什么样的享元对象。
举个例子:
书这个类,我想创建的享元对象是“技术类书籍”,让所有技术类的书都共享这个对象,那么书的类别就是内部状态;而书的书名,作者可能是每本书都不一样的,那么书的书名和作者就是外部状态。或者换一种方式,我想创建“村上春树写的书”这种享元对象,然后让所有村上春树写的书都共享这个享元对象,此时书的作者就为内部状态。当然也可以让作者、分类同时为内部状态创建一个享元对象。
享元对象可以按照内部状态的不同创建若干个,比如技术类书,文学类书,鸡汤类书三个。在实践的时候会发现,抽象程度越高,所创建的享元对象就越少,但是外部状态就越多;相反抽象程度越低,所需创建的享元对象就越多,外部状态就越少。特别地,当对象的所有状态都归为内部状态时,此时每个对象都可以看作一个享元对象,但是没有被共享,相当于没用享元模式。

享元模式的应用

还是以书为例子,实现一个功能:每本书都要打印出自己的书名。
先来看看没用享元模式之前代码的样子

const books = [
 {name: "计算机网络", category: "技术类"},
 {name: "算法导论", category: "技术类"},
 {name: "计算机组成原理", category: "技术类"},
 {name: "傲慢与偏见", category: "文学类"},
 {name: "红与黑", category: "文学类"},
 {name: "围城", category: "文学类"}
]
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)
})

上面代码先创建了书这个对象,然后把这个对象闭包在了点击事件的回调中,可以想象,如果有一万本书的话,这段代码的内存开销还是很可观的。现在我们使用享元模式重构这段代码

// 先定义享元对象
class FlyweightBook {
  constructor(category) {
    this.category = category
  }
   // 用于享元对象获取外部状态
   getExternalState(state) {
     for(const p in state) {
        this[p] = state[p]
     }
   }
   print() {
     console.log(this.name, this.category)
   }
}
// 然后定义一个工厂,来为我们生产享元对象
// 注意,这段代码实际上用了单例模式,每个享元对象都为单例, 因为我们没必要创建多个相同的享元对象
const flyweightBookFactory = (function() {
   const flyweightBookStore = {}
   return function (category) {
     if (flyweightBookStore[category]) {
       return flyweightBookStore[category]
     }
     const flyweightBook = new FlyweightBook(category)
     flyweightBookStore[category] = flyweightBook
     return flyweightBook
   }
})()
// 然后我们要使用享元对象, 在享元对象被调用的时候,能够得到它的外部状态
books.forEach((bookData) => {
   // 先生产出享元对象
   const flyweightBook = flyweightBookFactory(bookData.category)
   const div = document.createElement("div")
   div.innerText = bookData.name
    div.addEventListener("click", () => {
       // 给享元对象设置外部状态
       flyweightBook.getExternalState({name: bookData.name}) // 外部状态为书名
       flyweightBook.print()
    })
    document.body.appendChild(div)
})

可以看到以上代码仅仅闭包了两个享元对象,因为书仅有两种类别。两个享元对象是在使用的时候才获取到了外部状态,从而在使用时表现出对象本来应有的样子。

思考:如果书的类别有40种,而作者只有10个,那么挑选哪个属性作为内部状态呢?
当然是作者,因为这样只需要创建10个享元对象就行了。

思考:为何不干脆定义一个没有内部状态的享元对象得了,那样只有一个享元对象用于共享?
这样当然是可以的,实际上变得跟单例模式很像,唯一的区别就是多了对外部状态的注入。
实际上内部状态越少,要注入的外部状态自然越多,而且为了代码的复用性,会让内部状态尽可能多。

在一些代码中会有一个专门用来管理外部状态的一个实例,这个实例保存了所有对象的外部状态,同时提供了一个接口给享元对象来获取这些外部状态(通过id或其它唯一索引)。

对象池技术与享元模式

在上面例子中会发现,每增加一本书就会多一个DOM,哪怕享元对象只有两个,而DOM上万个的话,页面的性能也是很差的。我们发现,每实例化一个DOM,只有它的innerText是不同的,那么我们把DOM的innerText当做外部状态,其它当做内部状态,构造出享元对象DOM:

class Div {
  constructor() {
    this.dom = document.createElement("div")
  }
 getExternalState(extState) {
   // 获取外部状态
   this.dom.innerText = extState.innerText
 }
 mount(container) {
    container.appendChild(this.dom)
  }
}

那么什么东西能作为内部状态呢?在这里其实不需要内部状态的,因为我们关注的是享元对象的个数,比如页面上最多显示20个DOM的话,那么我们就创建20个DOM用来给真正的实例去共享:

const divFactory = (function() {
   const divPool = []; // 对象池
   return function() {
       if (divPool.length <= 20) {
          const div = new Div()
          divPool.push(div)
          return div
       } else {
          // 滚动行为,在超过20个时,复用池中的第一个实例,返回给调用者
          const div = divPool.shift()
          divPool.push(div)
          return div
       }
   }
})()

这个工厂就像奸商一样,在20个之前还是好好的,每次创建一个div都是新的,到了20个之后,就拿一些老的div返回给调用者,调用者会发现这个老的div会包含一些老的数据(像翻新机一样),但是调用者不关心,因为他会用新的数据覆盖掉老的数据。
接下来看调用者如何使用

// 先创建一个容器,因为不把DOM直接挂在document.body里了
const container = document.createElement("div")
books.forEach((bookData) => {
   // 先生产出享元对象
   const flyweightBook = flyweightBookFactory(bookData.category)
   // const div = document.createElement("div")
   // div.innerText = bookData.name
    const div = divFactory()
    div.getExternalState({innerText: bookData.name})
    // 如果要添加事件的话,在Div里面提供接口添加,在这里会造成重复添加
    // div.dom.addEventListener("click", () => {
    // 给享元对象设置外部状态
    //   flyweightBook.getExternalState({name: bookData.name}) // 外部状态为书名
    //    flyweightBook.print()
    // })
     div.mount(container)
    // document.body.appendChild(div)
})
document.body.appendChild(container)

以上代码会发现,DOM确实被复用了,但是总是显示最后的二十个,这是自然的,可以通过监听滚动事件,实现在滚动的时候加载相应的数据,同时DOM被复用,B站的弹幕列表就是用了相似的技术实现的,以下是全部代码:

const books = new Array(10000).fill(0).map((v, index) => {
    return Math.random() > 0.5 ? {
              name: `计算机科学${index}`,
              category: '技术类'
            } : {
              name: `傲慢与偏见${index}`,
              category: '文学类类'
            }
  })

class FlyweightBook {
  constructor(category) {
    this.category = category
  }
   // 用于享元对象获取外部状态
   getExternalState(state) {
     for(const p in state) {
        this[p] = state[p]
     }
   }
   print() {
     console.log(this.name, this.category)
   }
}
// 然后定义一个工厂,来为我们生产享元对象
// 注意,这段代码实际上用了单例模式,每个享元对象都为单例, 因为我们没必要创建多个相同的享元对象
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) {
   // 获取外部状态
   this.dom.innerText = extState.innerText
   // 设置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个时,复用池中的第一个实例,返回给调用者
          div = divPool.shift()
          divPool.push(div)
       }
       div.mount(innerContainer)
       return div
   }
})()

// 外层container,用户可视区域
const container = document.createElement("div")
// 内层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的总高度算出内层container的高度
innerContainer.style.position = `relative`
container.appendChild(innerContainer)
document.body.appendChild(container)

function load(start, end) {
  // 装载需要显示的数据
  books.slice(start, end).forEach((bookData, index) => {
     // 先生产出享元对象
    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 // 记录当前加载的首个数据
container.addEventListener('scroll', (e) => {
  const start = container.scrollTop / 22 | 0
  if (start !== cur) {
    load(start, start + 20)
    cur = start
  }
})

以上代码仅仅使用了2个享元对象,21个DOM对象,就完成了10000条数据的渲染,相比起建立10000个book对象和10000个DOM,性能优化是非常明显的。

以上,水平有限,如有纰漏,欢迎斧正。

你可能感兴趣的:(JavaScript享元模式与性能优化)