js享元模式设计原理以及应用案例

摘要

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

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

一.享元模式的结构

1.内部状态与外部状态

在享元对象内部并且不会随着环境改变而改变的共享部分,可以称之为享元对象的内部状态,反之随着环境改变而改变的,不可共享的状态称之为外部状态。

简单地说,内部状态是对象本身的属性,外部状态是管理这些对象所需的额外的属性,相同的对象内部状态相同,但外部状态可能不同(比如2本相同的书被2个人借走了)

2.享元

享元是相似对象(书的例子中指的是完全相同的书,而不是题材相似的书)间可共享的属性的集合,比如书的名字、作者、ISBN等等,完全相同的书这些信息都是相同的,没有必要把相同的属性在多个相似对象中保存多份,享元负责把这些属性分离出来,以便共享

如果可共享的属性比较复杂,还可以增加抽象享元,以及与之对应的具体享元,还可以有复合享元

3.享元工厂

享元工厂负责创建并管理享元,实现共享逻辑(创建时判断是否存在,已存在就返回现有对象,否则创建一个)

4.客户端(Client)

Client负责调用享元工厂,并存储管理相似对象所需的额外属性(比如书的id,借/还日期,是否在馆等等)
实例:
如果需要管理的书籍数量非常大,那么使用享元模式节省的内存将是一个可观的数目

// 图书管理
  // 书的属性:id,title,author,genre,page count,publisher id,isbn
  // 管理所需的额外属性:checkout date,checkout member,due return date,availability
    // 享元(存储内部状态)
    function Book(title, author, genre, pageCount, publisherId, isbn) {
      this.title = title;
      this.author = author;
      this.genre = genre;
      this.pageCount = pageCount;
      this.publisherId = publisherId;
      this.isbn = isbn;
    }

    // 享元工厂(创建/管理享元)
    var BookFactory = (function () {
      var existingBooks = {};
      var existingBook = null;
      return {
        createBook: function (title, author, genre, pageCount, publisherId, isbn) {
          // 如果书籍已经创建,,则找到并返回
          // !!强制返回bool类型
          existingBook = existingBooks[isbn];
          if (!!existingBook) {
            return existingBook;
          } else {
            // 如果不存在选择创建该书的新实例并保存
            var book = new Book(title, author, genre, pageCount, publisherId, isbn);
            console.log(book);
            existingBooks[isbn] = book;
            return book;
          }
        }
      }
    })();

    // 客户端(存储外部状态)
    var BookRecordManager = (function () {
      var bookRecordDatabase = {};
      return {
        // 添加新书到数据库
        addBookRecord: function (id, title, author, genre, pageCount, publisherId, isbn,
          checkoutDate, checkoutMember, dueReturnDate, availability) {
          var book = BookFactory.createBook(title, author, genre, pageCount, publisherId, isbn);
          bookRecordDatabase[id] = {
            checkoutMember: checkoutMember,
            checkoutDate: checkoutDate,
            dueReturnDate: dueReturnDate,
            availability: availability,
            book: book
          }
        },
        updateCheckStatus: function (bookId, newStatus, checkoutDate, checkoutMember, newReturnDate) {
          var record = bookRecordDatabase[bookId];
          record.availability = newStatus;
          record.checkoutDate = checkoutDate;
          record.checkoutMember = checkoutMember;
          record.dueReturnDate = newReturnDate;
        },
        extendCheckoutPeriod: function (bookId, newReturnDate) {
          bookRecordDatabase[bookId].dueReturnDate = newReturnDate;
        },
        isPastDue: function (bookId) {
          var currDate = new Date();
          return currDate.getTime() > Date.parse(bookRecordDatabase[bookId].dueReturnDate);
        }
      };
    })();

    // test
    // isbn号是书籍的唯一标识,以下三条只会创建一个book对象
    BookRecordManager.addBookRecord(1, 'x', 'x', 'xx', 300, 10001, '100-232-32'); // Book {title: "x", author: "x", genre: "xx", pageCount: 300, publisherId: 10001, …}
    BookRecordManager.addBookRecord(1, 'xx', 'xx', 'xx', 300, 10001, '100-232-32');
    BookRecordManager.addBookRecord(1, 'xxx', 'xxx', 'xxx', 300, 10001, '100-232-32');

jQuery与享元模式

jQuery.single = (function(o){
    var collection = jQuery([1]); // Fill with 1 item, to make sure length === 1
    return function(element) {
        // Give collection the element:
        collection[0] = element;
        // Return the collection:
        return collection;
    };
}());
// window.$_ = jQuery.single;   // 定义别名
// test
jQuery('a').click(function(){
   var html = jQuery.single(this).next().html(); // Method chaining works!
    alert(html);
    // etc. etc.

});
维护了一个单例collection,避免多次用$()包裹同一个DOM对象带来的内存消耗(会创建多个jQuery对象),使用jQuery.single永远都只会创
建一个jQuery对象,节省了创建额外jQuery对象消耗的时间,还减少了内存开销,但这样做最大的问题可能是:jQuery.single返回的对象无法
被缓存。因为内部是单例实现,缓存的对象在下一次调用jQuery.single后可能会被改变,所以无法像$()一样随时缓存。但据说直接使用jQuery.single
获取单例要比缓存普通jQuery对象更快,但为了避免混乱,建议只在需要把DOM对象包裹成jQuery对象时才使用jQuery.single方法。

要对参数进行处理,多态的实现

   jQuery.extend = function () {
      if (arguments.length === 1) {
        for (const item in arguments[0]) {
          jQuery[item] = arguments[item];
        }
      } else {
        for (const item in arguments[1]) {
          arguments[0][item] = arguments[1][item]; // A={a:1,b:2} B={c:3,d:4} =>A.c=B.c A.d=B.d =>A={a:1,b:2,c:3,d:4}
        }
      }
    }
    //把for循环变成享元模式
    jQuery.extend = function () {
      var target = arguments[0] || {}; //健壮性的体现,出现错误不会阻塞
      if (target !== 'object') {
        target = {}
      }
      var length = arguments.length;
      var i = 1;
      if (length == 1) {
        target = this; //this指jQuery
        i--
      }
      for (var item in arguments[1]) {
        target[item] = arguments[1][item];
      }

    }

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

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)
})

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

思考:如果书的类别有40种,而作者只有10个,那么挑选哪个属性作为内部状态呢?
当然是作者,因为这样只需要创建10个享元对象就行了。
思考:为何不干脆定义一个没有内部状态的享元对象得了,那样只有一个享元对象用于共享?
这样当然是可以的,实际上变得跟单例模式很像,唯一的区别就是多了对外部状态的注入。
实际上内部状态越少,要注入的外部状态自然越多,而且为了代码的复用性,会让内部状态尽可能多。
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,性能优化是非常明显的。

享元模式的优缺点

优点
减少内存开销

提供了一种方便的管理大量相似对象的方法

缺点
享元模式需要分离内部状态和外部状态,会使逻辑变得更加复杂

外部状态被分离出去后,访问会产生轻微的额外时耗(时间换空间?)

P.S.如果项目中需要创建大量实例对象,就应该考虑一下享元模式是否适用
参考文章:
JavaScript设计模式
享元模式_JavaScript设计模式12

享元模式(Flyweight)

JavaScript享元模式与性能优化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章