美文网首页
JavaScript享元模式与性能优化

JavaScript享元模式与性能优化

作者: solorhyme | 来源:发表于2018-02-17 20:54 被阅读0次

    摘要

    享元模式是用于性能优化的设计模式之一,在前端编程中有重要的应用,尤其是在大量渲染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享元模式与性能优化

          本文链接:https://www.haomeiwen.com/subject/dkvmtftx.html