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,性能优化是非常明显的。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容