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享元模式與性能優化

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