原文:https://addyosmani.com/resources/essentialjsdesignpatterns/book/#detailflyweight

享元模式(Flyweight pattern)是一种经典的结构型模式,它通常用于优化重复性、慢和低效共享数据的代码。它的目标是通过尽可能多的共享相关对象间的数据(例如,应用的配置信息、状态等等)来减少应用的内存占用大小。

这个模式是由 Paul Calder 和 Mark Linton 在1990年首次提出,并以拳击重量级别命名,这个级别包含体重低于112磅的拳击手。Flyweight 这个名称就是从这个重量分类派生出来的,因为它表示的是这个模式的目标是帮助我们实现小的重量(内存占用)。

在实践中,Flyweight 的数据共享可能涉及获取由一组对象使用的几个相似的对象或数据结构,并将这些数据存储到一个额外的对象中去。我们可以把此对象传给那些依赖此数据的对象,而不是在每个对象中都存储相同的数据。

使用 Flyweight 模式

Flyweight 模式有两种应用方式。第一种是在数据层,我们处理存储在内存中的大量相似对象间的共享数据的概念。

第二种是在 DOM 层级,享元可以作为一个中心化的事件管理器,这样就可以避免为父容器中有相同行为的每个子元素都绑定事件处理器。

因为数据层通常是 Flyweight 模式最常使用的地方,所以我首先了解了解它。

Flyweight 和数据共享

对于这个应用,还有一些关于经典 Flyweight 模式的概念需要我们注意。在 Flyweight 模式中,有两种概念的状态:内在的和外在的。内在的信息可能是被我们对象中的内部方法中所使用,这些信息是不可缺失的。然而,外在的信息则可以被移除,存储到额外的地方。

拥有相同的内在数据的对象可以被一个独立的共享对象替代,这个共享对象由工厂方法创建。这使我们能够显著的减少存储的隐式数据的总量。

这样做的好处是我们能够密切关注已初始化的对象,以便只有在内在的状态与我们已有对象不同时才会创建副本。

我们使用一个管理器来处理外在的状态。它的实现能有很多方式,一种是让管理器对象维护一个外在状态的中心数据库以及它们所属的 Flyweight 对象。

实现经典的 Flyweight

因为近些年中 Flyweight 模式在 JavaScript 中并没有大量使用,大多数我们用到的实现版本的灵感来自 Java 和 C++。

我们首先看的 Flyweight 代码是我按照 Wikipedia 上 Flyweight 模式的 Java 版本示例实现的(http://en.wikipedia.org/wiki/Flyweight_pattern)。

在这个实现中,我们要使用三种类型的 Flyweight 组件,如下所列:

  • Flyweight:对应一个接口,flyweight 通过接口可以接收并响应外在的状态
  • Concrete Flyweight:实际实现 Flyweight 接口并存储内在的状态。Concrete Flyweight 要可共享的,并且能操作外在的状态
  • Flyweight 工厂:创建和管理 flyweight 对象。它保证我们的 flyweight 是共享的,并且可以当成一组对象来管理,以便如果我们需要引用单独的实例时支持查询。如果一个对象在组中已经被创建过,则返回该对象,否则要增加一个新的对象到这个池中,并返回它。

它们与我们实现中的相关定义对应对应如下:

  • CoffeeOrder: Flyweight
  • CoffeeFlavor: Concrete Flyweight
  • CoffeeOrderContext: Helper
  • CoffeeFlavorFactory: Flyweight Factory
  • testFlyweight: Utilization of our Flyweights

Duck Punching “实现”

Duck punching 让我们能够在不需要修改运行时资源的情况下拓展语言或者方案的能力。由于接下来的这个方案需要使用 Java 的关键字 ( implement )来实现接口,这个关键字目前在原生的 JavaScript 中是不支持的,让我们先来 duck punching 它。

Function.prototype.implementsFor 作用于对象的构造器,它接收一个父类(函数)或者对象,使用普通的继承(对于函数的情况)或者虚拟的继承(对于对象的情况)来继承它。

  1. // 为 JS 模拟纯虚拟继承 “实现” 关键字
  2. Function.prototype.implementsFor = function(parentClassOrObject) {
  3. if (parentClassOrObject.constructor === Function) {
  4. // 普通的继承
  5. this.prototype = new parentClassOrObject();
  6. this.prototype.constructor = this;
  7. this.prototype.parent = parentClassOrObject.prototype;
  8. } else {
  9. // 纯虚拟继承
  10. this.prototype = parentClassOrObject;
  11. this.prototype.constructor = this;
  12. this.prototype.parent = parentClassOrObject;
  13. }
  14. return this;
  15. };

我们可以通过让函数显示继承接口来补齐 implements 关键字的缺失。下面, CoffeeFlavor 实现 CoffeeOrder 接口,它必须包含它接口的方法,以便我们将这些实现功能的能力绑定到某个对象上。

  1. // Flyweight 对象
  2. var CoffeeOrder = {
  3. // 接口
  4. serveCoffee: function(context) {},
  5. getFlavor: function() {}
  6. };
  7. // Concrete Flyweight 对象
  8. // 用于创建实现 CoffeeOrder 接口的 ConcreteFlyweight
  9. function CoffeeFlavor(newFlavor) {
  10. var flavor = newFlavor;
  11. // 如果已经为某个功能定义了接口,则实现这个接口
  12. if (typeof this.getFlavor === 'function') {
  13. this.getFlavor = function() {
  14. return flavor;
  15. };
  16. }
  17. if (typeof this.serveCoffee === 'function') {
  18. this.serveCoffee = function(context) {
  19. console.log(
  20. 'Serving Coffee flavor ' +
  21. flavor +
  22. ' to table number ' +
  23. context.getTable()
  24. );
  25. };
  26. }
  27. }
  28. // 为 CoffeeOrder 实现接口
  29. CoffeeFlavor.implementsFor(CoffeeOrder);
  30. // 处理 coffee 订单的桌号
  31. function CoffeeOrderContext(tableNumber) {
  32. return {
  33. getTable: function() {
  34. return tableNumber;
  35. }
  36. };
  37. }
  38. function CoffeeFlavorFactory() {
  39. var flavors = {},
  40. length = 0;
  41. return {
  42. getCoffeeFlavor: function(flavorName) {
  43. var flavor = flavors[flavorName];
  44. if (typeof flavor === 'undefined') {
  45. flavor = new CoffeeFlavor(flavorName);
  46. flavors[flavorName] = flavor;
  47. length++;
  48. }
  49. return flavor;
  50. },
  51. getTotalCoffeeFlavorsMade: function() {
  52. return length;
  53. }
  54. };
  55. }
  56. // 示例用法:
  57. // testFlyweight()
  58. function testFlyweight() {
  59. // 下单的口味
  60. var flavors = [],
  61. // 订单的桌号
  62. tables = [],
  63. // 已经做了订单数量
  64. ordersMade = 0,
  65. // CoffeeFlavorFactory 实例
  66. flavorFactory = new CoffeeFlavorFactory();
  67. function takeOrders(flavorIn, table) {
  68. flavors.push(flavorFactory.getCoffeeFlavor(flavorIn));
  69. tables.push(new CoffeeOrderContext(table));
  70. ordersMade++;
  71. }
  72. takeOrders('Cappuccino', 2);
  73. takeOrders('Cappuccino', 2);
  74. takeOrders('Frappe', 1);
  75. takeOrders('Frappe', 1);
  76. takeOrders('Xpresso', 1);
  77. takeOrders('Frappe', 897);
  78. takeOrders('Cappuccino', 97);
  79. takeOrders('Cappuccino', 97);
  80. takeOrders('Frappe', 3);
  81. takeOrders('Xpresso', 3);
  82. takeOrders('Cappuccino', 3);
  83. takeOrders('Xpresso', 96);
  84. takeOrders('Frappe', 552);
  85. takeOrders('Cappuccino', 121);
  86. takeOrders('Xpresso', 121);
  87. for (var i = 0; i < ordersMade; ++i) {
  88. flavors[i].serveCoffee(tables[i]);
  89. }
  90. console.log(' ');
  91. console.log(
  92. 'total CoffeeFlavor objects made: ' +
  93. flavorFactory.getTotalCoffeeFlavorsMade()
  94. );
  95. }

转换代码到 Flyweight 模式

接下来,让我们通过实现一个图书馆中所有图书的管理系统来继续了解 Flyweight。每个图书关键的元数据可以被拆分成如下:

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

我们还需要如下这些属性来记录是哪个成员借出了某本书、他们借出时间以及期望的还书时间。

  • checkoutDate
  • checkoutMember
  • dueReturnDate
  • availability

因此在使用 Flyweight 模式进行优化之前,每本书表示如下:

  1. var Book = function(
  2. id,
  3. title,
  4. author,
  5. genre,
  6. pageCount,
  7. publisherID,
  8. ISBN,
  9. checkoutDate,
  10. checkoutMember,
  11. dueReturnDate,
  12. availability
  13. ) {
  14. this.id = id;
  15. this.title = title;
  16. this.author = author;
  17. this.genre = genre;
  18. this.pageCount = pageCount;
  19. this.publisherID = publisherID;
  20. this.ISBN = ISBN;
  21. this.checkoutDate = checkoutDate;
  22. this.checkoutMember = checkoutMember;
  23. this.dueReturnDate = dueReturnDate;
  24. this.availability = availability;
  25. };
  26. Book.prototype = {
  27. getTitle: function() {
  28. return this.title;
  29. },
  30. getAuthor: function() {
  31. return this.author;
  32. },
  33. getISBN: function() {
  34. return this.ISBN;
  35. },
  36. // 为了方便,未展示其他 getter
  37. updateCheckoutStatus: function(
  38. bookID,
  39. newStatus,
  40. checkoutDate,
  41. checkoutMember,
  42. newReturnDate
  43. ) {
  44. this.id = bookID;
  45. this.availability = newStatus;
  46. this.checkoutDate = checkoutDate;
  47. this.checkoutMember = checkoutMember;
  48. this.dueReturnDate = newReturnDate;
  49. },
  50. extendCheckoutPeriod: function(bookID, newReturnDate) {
  51. this.id = bookID;
  52. this.dueReturnDate = newReturnDate;
  53. },
  54. isPastDue: function(bookID) {
  55. var currentDate = new Date();
  56. return currentDate.getTime() > Date.parse(this.dueReturnDate);
  57. }
  58. };

在起初只有较少数量书籍的情况下它可能是适用的,然而随着图书馆库存拓展到一个更大的规模,每本书包含了多个版本和多个副本,我们可能会发现系统运行一次次的变慢。使用上千个书籍对象可能会导致内存溢出,但是我们可以使用 Flyweight 模式来改进这个问题。

现在我们可以按如下方式将数据分类成内在的和外在的状态:与书籍对象相关的数据( title 、 author 等等)是内在的,借阅数据( checkoutMember 、 dueReturnDate 等等)可以当做外在的。这就意味着每个书籍属性组合实际上只需要有一个书籍对象。虽然还是会有大量的对象,但显然要比之前少很多。

下面这个书籍元数据的实例将在拥有相同标题的多个书籍副本间共享。

  1. // Flyweight 优化后的版本
  2. var Book = function ( title, author, genre, pageCount, publisherID, ISBN ) {
  3. this.title = title;
  4. this.author = author;
  5. this.genre = genre;
  6. this.pageCount = pageCount;
  7. this.publisherID = publisherID;
  8. this.ISBN = ISBN;
  9. };

正如我们所见,在外的状态已经被移除。所有图书馆借阅的操作将被转移到一个管理器上,而且因为对象数据现在被分隔,需要一个用于实例化的工厂。

基本的工厂

现在我们来定义一个基础的工厂。我们需要工厂做的是检查指定标题书籍是否在系统中已经有创建过;如果有,我们将返回它;如果没有,一个新的书籍将会被创建和存储,这样随后它就可以被访问。这就确保了我们只为每个唯一的内在数据片段创建一个副本。

  1. // 书籍工厂单例
  2. var BookFactory = (function() {
  3. var existingBooks = {},
  4. existingBook;
  5. return {
  6. createBook: function(title, author, genre, pageCount, publisherID, ISBN) {
  7. // 检查是否已经创建过相同的书籍元数据组合
  8. // !! 或则 (bang bang)操作强制返回一个布尔类型值
  9. existingBook = existingBooks[ISBN];
  10. if (!!existingBook) {
  11. return existingBook;
  12. } else {
  13. // 如果没有,就创建一个新的书籍实例并存储它
  14. var book = new Book(title, author, genre, pageCount, publisherID, ISBN);
  15. existingBooks[ISBN] = book;
  16. return book;
  17. }
  18. }
  19. };
  20. })();

管理外在的状态

接下来,我们需要存储我们从书籍对象中移除的那些状态 - 幸运的是管理器(我们将把它定义成一个单例)可以用于封装它们。书籍对象的组合和借出它们的图书馆会员一起组成一条图书记录。我们的管理器将存储它们,并且包括我们使用 Flyweight 优化 Book 类时移除借阅相关的逻辑。

  1. // 图书记录管理器单例
  2. var BookRecordManager = (function() {
  3. var bookRecordDatabase = {};
  4. return {
  5. // 增加一本新书到图书馆系统
  6. addBookRecord: function(
  7. id,
  8. title,
  9. author,
  10. genre,
  11. pageCount,
  12. publisherID,
  13. ISBN,
  14. checkoutDate,
  15. checkoutMember,
  16. dueReturnDate,
  17. availability
  18. ) {
  19. var book = BookFactory.createBook(
  20. title,
  21. author,
  22. genre,
  23. pageCount,
  24. publisherID,
  25. ISBN
  26. );
  27. bookRecordDatabase[id] = {
  28. checkoutMember: checkoutMember,
  29. checkoutDate: checkoutDate,
  30. dueReturnDate: dueReturnDate,
  31. availability: availability,
  32. book: book
  33. };
  34. },
  35. updateCheckoutStatus: function(
  36. bookID,
  37. newStatus,
  38. checkoutDate,
  39. checkoutMember,
  40. newReturnDate
  41. ) {
  42. var record = bookRecordDatabase[bookID];
  43. record.availability = newStatus;
  44. record.checkoutDate = checkoutDate;
  45. record.checkoutMember = checkoutMember;
  46. record.dueReturnDate = newReturnDate;
  47. },
  48. extendCheckoutPeriod: function(bookID, newReturnDate) {
  49. bookRecordDatabase[bookID].dueReturnDate = newReturnDate;
  50. },
  51. isPastDue: function(bookID) {
  52. var currentDate = new Date();
  53. return (
  54. currentDate.getTime() >
  55. Date.parse(bookRecordDatabase[bookID].dueReturnDate)
  56. );
  57. }
  58. };
  59. })();

这三个修改带来的结果是所有从 Book 类分离出来的所有数据现在都作为属性存储在 BookManager 单例上(BookDatabase)- 一种相比我们之前通过大量对象的方式要更加高效的方式。书籍借阅相关的方法现在也同样基于这里,因为它们更多的是处理外在的数据而不是内在的数据。

这个过程确实为我们最终的方案增加了一些复杂度,但是相比我们前面关注的性能问题而言它就小的多了。数据方面,如果我们有同一本书的30个副本,我们现在只需要存储它一次。此外,每个函数也会占用内存。通过 flyweight 模式优化,这些函数存在一个地方(在管理器上)而不是每个对象上,因此也节省了内存的使用。对于上面提到的未使用 flyweight 优化的版本,我们只存储函数对象的链接,因为我们使用的是书籍的构造器原型,但是如果如果以其他方式声明,函数将会在每个书籍实例上创建。

Flyweight 模式与 DOM

DOM(Document Object Model)支持两种方式来让对象监听事件 - 可以是自上而下(事件捕获)或自下而上(事件冒泡)。

在事件捕获中,事件首先由最外层的元素捕获,并传播到最内层的元素上去。在事件冒泡中,事件则是由最内层的元素捕获,并传播到最外层元素上去。

在这种情况下描述 Flyweight 一个最佳的比喻是由 Gray Chisholm 提出的: 试着从池塘的角度来考虑 flyweight。鱼张开它的嘴巴(事件),气泡上升到水面(冒泡过程),当气泡到达水面时,坐在顶部的苍蝇飞走了(动作)。在这个例子中,我们可以简单的将鱼张开嘴巴转变成按钮被点击,气泡当做冒泡的作用,苍蝇飞走当做函数被执行。 冒泡被引入来处理单个事件(如 点击)可能被定义在 DOM 树上不同层级的多个事件处理器处理的情况。当它发生时,事件冒泡会先执行定义在最底层元素的事件处理函数。然后事件会在到达更高层元素前先冒泡到容器元素。

flyweight 可以用来进一步调整事件冒泡的过程,我们很快就会看到。

示例1:事件处理中心化

对于我们第一个例子,假设我们在 document 中有一些的元素,当发生与它们相关的用户行为(如 点击,鼠标移入)时,这些元素会做出相似的反应。

通常当构造手风琴组件、菜单或者其他基于列表的组件是,我们所做的通常是给父容器下每个链接元素绑定点击事件(如 $('ul li a').on(..) )。除了这种将点击事件绑定到多个元素上的方式外,我们还可以简单地向父容器附上一个 flyweight,它可以监听从下层传播上来的事件。然后可以根据需要简单或复杂的来处理这些事件。
因为上述提到的这些类型的组件通常每个部分都有着重复的标记(如手风琴的每个部分),很有可能被点击的每个元素行为会非常相似,并且附近层级的元素相似。我们将使用这个信息和下面的 Flyweight 来构建一个非常基础的手风琴。

stateManager 命名空间在这里是用于封装我们 flyweight 的逻辑,而 jQuery 是用于绑定初始的点击到容器 div 上。为了保证页面上的其他逻辑不会绑定相似的处理函数到容器上,首先要做的就是为容器解绑事件。

现在要确认是容器中哪个子元素被点击了,我们使用 target 来检查,它会提供一个被点击元素的引用,而不管它的父元素是谁。然后我们就可以使用这个信息来处理点击事件,而不需要在页面加载时将事件绑定到对应的子元素上。

HTML

  1. <div id="container">
  2. <div class="toggle" href="#">More Info (Address)
  3. <span class="info">
  4. This is more information
  5. </span></div>
  6. <div class="toggle" href="#">Even More Info (Map)
  7. <span class="info">
  8. <iframe src="http://www.map-generator.net/extmap.php?name=London&amp;address=london%2C%20england&amp;width=500...gt;"</iframe>
  9. </span>
  10. </div>
  11. </div>

JavaScript

  1. var stateManager = {
  2. fly: function() {
  3. var self = this;
  4. $('#container')
  5. .unbind()
  6. .on('click', 'div.toggle', function(e) {
  7. self.handleClick(e.target);
  8. });
  9. },
  10. handleClick: function(elem) {
  11. $(elem)
  12. .find('span')
  13. .toggle('slow');
  14. }
  15. };

这么做的好处是我们将很多相互独立的动作转换成了被共享单个动作(潜在地减少了内存开销)。

示例2:使用 Flyweight 来优化性能

在我们第二个例子中,我们将参考一些更深的性能优化,这些优化可以借助 jQuery 使用 Flyweight 实现。

James Padolsey 早先在名为 76 bytes for faster jQuery 的文章中提出 jQuery 每次触发回调时,不管是哪种类型(filter、each、事件处理函数),我们都可以通过 this 关键字来获取函数的执行环境(相关的 DOM 元素)。

不幸的是,我们大多数人习惯使用 $() 或 jQuery() 包裹 this ,这就意味着每次都会构造一个非必要的 jQuery 实例,而不是这样简单来做:

  1. $('div').on('click', function() {
  2. console.log('You clicked: ' + $(this).attr('id'));
  3. });
  4. // 我们应该避免使用 DOM 元素来创建一个 jQuery 对象(随之而来的是开销)
  5. // 应该像这样直接使用 DOM 元素
  6. $('div').on('click', function() {
  7. console.log('You clicked:' + this.id);
  8. });

James 曾想在下面的情况中使用 jQuery.text ,然而他不认可在每轮迭代中必须要创建一个新的 jQuery 对象。

  1. $('a').map(function() {
  2. return $(this).text();
  3. });

现在关于冗余的包装,尽可能的使用 jQuery 的工具方法,最好是使用 jQuery.methodName (例如 jQuery.text )而不是 jQuery.fn.methodName (如 jQuery.fn.text ),这里的方法名表示一个功能,如 each() 或 text 。这就避免了需要更高级别的抽象或构造新 jQuery 对象的必要,每次我们调用名为 jQuery.methodName 实际是库它自己在底层通过 jQuery.fn.methodName 支持的。

但是由于不是所有的 jQuery 的方法都有对应的单点函数,Padolsey 设计了一个 jQuery.single 工具的想法。

这个想法是只创建一个 jQuery 对象,在 jQuery.single 的每次调用中使用(实际就是只创建过一个 jQuery 对象)。它具体的实现就在下方,当我们将多个可能对象上的数据合并到一个更中心化的单一结构中时,从技术的角度看也是 Flyweight。

  1. jQuery.single = (function(o) {
  2. var collection = jQuery([1]);
  3. return function(element) {
  4. // 为集合分配元素:
  5. collection[0] = element;
  6. // 返回集合:
  7. return collection;
  8. };
  9. })();

使用链式调用的例子是:

  1. $('div').on('click', function() {
  2. var html = jQuery
  3. .single(this)
  4. .next()
  5. .html();
  6. console.log(html);
  7. });

注意:尽管我们可能相信简单的缓存我们的 jQuery 代码可能可以提供相等的性能提升,Padolsey 声称 $.single() 仍然值得使用,并且性能更好。这并不是说不要一点都不要使用缓存,只要记住这个方案是能有帮助的。关于 $.single 更多的细节,我推荐你阅读 Padolsey 的全文。