【设计模式系列】五、享元模式

我们在之前的系列文章中,陆续介绍了:

今天继续给大家带来“享元模式”的介绍。

享元模式(Flyweight Pattern)是一种结构型设计模式,它摒弃了在每个对象中保存所有数据的方式,通过共享多个对象所共有的相同状态,让你能在有限的内存容量中载入更多对象。

享元模式用来尽可能减少内存使用量,以及与尽可能多的相似对象共享数据。

它适合用于当大量物件只是重复因而导致无法令人接受的使用大量内存。通常物件中的部分状态是可以分享。常见做法是把它们放在外部数据结构,当需要使用时再将它们传递给享元。

享元模式区分了内部状态外部状态,所以我们可以通过设置不同的外部状态,使得相同的对象可以具备一些不同的特性,而内部状态设置为相同部分。

如何利用享元模式呢?这里我们只需要将他们少部分不同的部分,当做参数移动到类实例的外部去,然后在方法调用的时候将他们传递过来就可以了。

那么如果在 JavaScript 中应用享元模式呢?

有两种方式:

  • 第一种是应用在数据层上,主要是应用在内存里大量相似的对象上;
  • 第二种是应用在DOM层上,享元可以用在中央事件管理器上用来避免给父容器里的每个子元素都附加事件句柄。

实现

享元模式在 JavaScript 中非常重要,它是用于性能优化的一种常见模式。

它依靠 减少创建对象实例的数量 以及 运用共享技术来有效支持大量细粒度的对象 这两种方式减少内存占用,以提高性能。

JavaScript 中,浏览器特别是移动端的浏览器,所占有的内存并不算多,因此合理利用享元模式,达到节省内存的目的,是一项非常有意义的优化。

在 Java 中有一个关键字:implements,它用于接入接口 interfaces,这在 JavaScript 语言中并不存在,但是我们仍然可以模拟一个:

Function.prototype.implementsFor = function (parentClassOrObject{
  if (parentClassOrObject.constructor === Function) {
    // Normal Inheritance

    this.prototype = new parentClassOrObject();
    this.prototype.constructor = this;
    this.prototype.parent = parentClassOrObject.prototype;
  } else {
    // Pure Virtual Inheritance

    this.prototype = parentClassOrObject;
    this.prototype.constructor = this;
    this.prototype.parent = parentClassOrObject;
  }

  return this;
};

我们看:implementsFor 作用于一个构造函数,它接受一个父类(function)或者一个 object,并继承该父类构造函数(function)或者指定的 object。上段代码并不难理解,我们看一个应用实例:

// Flyweight object

var CoffeeOrder = {
  // Interfaces

  serveCoffeefunction (context{},

  getFlavorfunction ({},
};

// ConcreteFlyweight object that creates ConcreteFlyweight

// Implements CoffeeOrder

function CoffeeFlavor(newFlavor{
  var flavor = newFlavor;

  // If an interface has been defined for a feature

  // implement the feature

  if (typeof this.getFlavor === 'function') {
    this.getFlavor = function ({
      return flavor;
    };
  }

  if (typeof this.serveCoffee === 'function') {
    this.serveCoffee = function (context{
      console.log(
        'Serving Coffee flavor ' +
          flavor +
          ' to table number ' +
          context.getTable()
      );
    };
  }
}

// Implement interface for CoffeeOrder

CoffeeFlavor.implementsFor(CoffeeOrder);

// Handle table numbers for a coffee order

function CoffeeOrderContext(tableNumber{
  return {
    getTablefunction ({
      return tableNumber;
    },
  };
}

function CoffeeFlavorFactory({
  var flavors = {},
    length = 0;

  return {
    getCoffeeFlavorfunction (flavorName{
      var flavor = flavors[flavorName];

      if (typeof flavor === 'undefined') {
        flavor = new CoffeeFlavor(flavorName);

        flavors[flavorName] = flavor;

        length++;
      }

      return flavor;
    },

    getTotalCoffeeFlavorsMadefunction ({
      return length;
    },
  };
}

// Sample usage:

// testFlyweight()

function testFlyweight({
  // The flavors ordered.

  var flavors = [],
    // The tables for the orders.

    tables = [],
    // Number of orders made

    ordersMade = 0,
    // The CoffeeFlavorFactory instance

    flavorFactory = new CoffeeFlavorFactory();

  function takeOrders(flavorIn, table{
    flavors.push(flavorFactory.getCoffeeFlavor(flavorIn));

    tables.push(new CoffeeOrderContext(table));

    ordersMade++;
  }

  takeOrders('Cappuccino'2);
  takeOrders('Cappuccino'2);
  takeOrders('Frappe'1);
  takeOrders('Frappe'1);
  takeOrders('Xpresso'1);
  takeOrders('Frappe'897);
  takeOrders('Cappuccino'97);
  takeOrders('Cappuccino'97);
  takeOrders('Frappe'3);
  takeOrders('Xpresso'3);
  takeOrders('Cappuccino'3);
  takeOrders('Xpresso'96);
  takeOrders('Frappe'552);
  takeOrders('Cappuccino'121);
  takeOrders('Xpresso'121);

  for (var i = 0; i < ordersMade; ++i) {
    flavors[i].serveCoffee(tables[i]);
  }

  console.log(' ');

  console.log(
    'total CoffeeFlavor objects made: ' +
      flavorFactory.getTotalCoffeeFlavorsMade()
  );
}

这个例子中,CoffeeFlavor 接入了 CoffeeOrder 的接口。接口的概念也许对于传统的 JavaScript 开发者有些陌生,我们再来看一个更加具有表现力的例子。

案例一

假设在图书管理系统中,每本书都有以下特性:

  • ID
  • Title
  • Author
  • Genre
  • Page count
  • Publisher ID
  • ISBN

同时我们需要以下属性来追踪每一本书时,记录它是否可用、归还时间等:

  • checkoutDate
  • checkoutMember
  • dueReturnDate
  • availability

那么 Book 这个类看上去就像:

var Book = function(id, title, author, genre, pageCount, publisherID, ISBN, checkoutDate, checkoutMember, dueReturnDate, availability{
  this.id = id
  this.title = title
  this.author = author
  this.genre = genre
  this.pageCount = pageCount
  this.publisherID = publisherID
  this.ISBN = ISBN
  this.checkoutDate = checkoutDate
  this.checkoutMember = checkoutMember
  this.dueReturnDate = dueReturnDate
  this.availability = availability
}

Book.prototype = {
  getTitlefunction({
    return this.title
  },

  getAuthorfunction({
    return this.author
  },

  getISBNfunction({
    return this.ISBN
  },

  updateCheckoutStatusfunction(bookID, newStatus, checkoutDate, checkoutMember, newReturnDate{
    this.id = bookID
    this.availability = newStatus
    this.checkoutDate = checkoutDate
    this.checkoutMember = checkoutMember
    this.dueReturnDate = newReturnDate
  },

  extendCheckoutPeriodfunction(bookID, newReturnDate{
    this.id = bookID
    this.dueReturnDate = newReturnDate
  },

  isPastDuefunction({
    var currentDate = new Date()
    return currentDate.getTime() > Date.parse(this.dueReturnDate)
  }
}

这么看上去并没有什么问题,但是当图书增多时,对于系统的压力会逐渐增多。

为此我们将书的属性分为两种:本身固有的外在特性。本身固有的属性包括 titleauthor 等,外在特性包括 checkoutMemberdueReturnDate 等。这样一来,我们简化书的构造函数为:

var Book = function (title, author, genre, pageCount, publisherID, ISBN{
  this.title = title;
  this.author = author;
  this.genre = genre;
  this.pageCount = pageCount;
  this.publisherID = publisherID;
  this.ISBN = ISBN;
};

我们将外在特性删去,check-outs 等信息将会被移动到一个新的类中,一个新的工厂函数也将出现:


var BookFactory = (function ({
  var existingBooks = {},
    existingBook;

  return {
    createBookfunction (title, author, genre, pageCount, publisherID, ISBN{
      // Find out if a particular book meta-data combination has been created before
      // !! or (bang bang) forces a boolean to be returned
      existingBook = existingBooks[ISBN];

      if (!!existingBook) {
        return existingBook;
      } else {
        // if not, let's create a new instance of the book and store it
        var book = new Book(title, author, genre, pageCount, publisherID, ISBN);
        existingBooks[ISBN] = book;

        return book;
      }
    },
  };
})();

在这个工厂函数中,我们将会检查当前需要创建的书籍是否已经存在,如果存在直接返回书实例;否则进行调用 Book 构造函数进行创建。这保证了所有的书都是唯一的,而不存在重复。

对于书的外在特性,我们创建 BookRecordManager 来维护每一本书的状态,并通过 bookId 与每一个本书进行关系创建。

var BookRecordManager = (function({
  var bookRecordDatabase = {}
  return {
    addBookRecordfunction(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
      }
    },

    updateCheckoutStatusfunction(bookID, newStatus, checkoutDate, checkoutMember, newReturnDate{
      var record = bookRecordDatabase[bookID]
      record.availability = newStatus
      record.checkoutDate = checkoutDate
      record.checkoutMember = checkoutMember
      record.dueReturnDate = newReturnDate
    },

    extendCheckoutPeriodfunction(bookID, newReturnDate{
      bookRecordDatabase[bookID].dueReturnDate = newReturnDate
    },

    isPastDuefunction(bookID{
      var currentDate = new Date()
      return currentDate.getTime() > Date.parse(bookRecordDatabase[bookID].dueReturnDate)
    }
  }
})()

书目所有的外在特性都被从书本身的特性中抽离,对于书借入/借出的操作也移动到了 BookRecordManager 当中,因为这些方法需要直接操作书的外在特性。如此一来,比一本书拥有全部属性的实现方式更加高效,也更利于维护。

比如有 30 本一样的书,在现有的模式下,只需要存储了一个实例。

案例二

享元模式在前端还有更多的应用,比如事件代理就是一个很典型的体现:

<div id="container">
  <div class="toggle" href="#">
    <span class="info">1</span>
  </div>
  <div class="toggle" href="#">
    <span class="info">2</span>
  </div>
</div>
// 我们集中将事件处理放到父容器上
var stateManager = {
  flyfunction({
    var self = this
    $('#container')
      .unbind()
      .on('click''div.toggle'function(e{
        self.handleClick(e.target)
      })
  },

  handleClickfunction(elem{
    $(elem)
      .find('span')
      .toggle('slow')
  }
}

总结说明

来总结下享元模式的优缺点。

优点:

  • 如果程序中有很多相似对象, 那么你将可以节省大量内存。

缺点:

  • 可能需要牺牲执行速度来换取内存,因为他人每次调用享元方法时,都需要重新计算部分情景数据。
  • 代码会变得更加复杂。团队中的新成员可能会问:“为什么要像这样拆分一个实体的状态?”。

最后

《前端面试题宝典》经过近一年的迭代,现已推出 小程序 和 电脑版刷题网站 (https://fe.ecool.fun/),欢迎大家使用~


同时,我们还推出了面试辅导的增值服务,可以为大家提供 “简历指导” 和 “模拟面试” 服务,感兴趣的同学可以联系小助手(微信号:interview-fe)进行报名。