本节书摘来自异步社区《JavaScript设计模式》一书中的第9章,第9.13节, 作者: 【美】Addy Osmani 译者: 徐涛 更多章节内容可以访问云栖社区“异步社区”公众号查看。
9.13 Flyweight(享元)模式
Flyweight模式是一种经典的结构型解决方案,用于优化重复、缓慢及数据共享效率较低的代码。它旨在通过与相关的对象共享尽可能多的数据来减少应用程序中内存的使用(如:应用程序配置、状态等,见图9-12)。
该模式最早是由Paul Calder和Mark Linton于1990年构思出来,它以拳击重量级别命名,它包括重量不到112磅的拳手。Flyweight这个名字是源自这一重量级别,因为它所指的是:模式旨在帮助我们实现的轻量级(内存占用)。
在实践中,Flyweight数据共享会涉及获取多个对象使用的若干相似对象或数据结构,以及将这些数据放到一个单一的外部对象中。我们可以将该对象传递给依赖这些数据的对象,而不是在每一个对象都存储相同的数据。
9.13.1 使用Flyweight模式
Flyweight模式的应用方式有两种。第一种是用于数据层,处理内存中保存的大量相似对象的共享数据。
第二种是用于DOM层,Flyweight可以用作中央事件管理器,来避免将事件处理程序附加到父容器中的每个子元素上,而是将事件处理程序附加到这个父容器上。
鉴于数据层是Flyweight模式最常使用的地方,我们首先要对它进行了解。
9.13.2 Flyweight和共享数据
对于该应用程序,还有一些经典Flyweight模式的概念我们需要注意。在Flyweight模式中,有个有关两个状态的概念—内部和外部。对象中的内部方法可能需要内部信息,没有内部信息,它们就绝对无法正常运行。但外部信息是可以被删除的或是可以存储在外部的。
具有相同内部数据的对象可以被替换为一个由factory方法创建的单一共享对象。这使我们可以极大减少存储隐式数据的总数量。
这么做的好处是,我们能够密切关注已经被实例化的对象,这样新副本就只需要创建与现有对象不同的部分就可以了。
我们使用管理器来处理外部状态。如何实现管理器是不固定的,但有一种方法就是让管理器对象包含一个外部状态的中央数据库以及这些外部状态所属的享元对象。
**
9.13.3 实现经典Flyweight(享元)**
由于近年来Flyweight模式还没有在JavaScript中大量使用,很多给我们带来启发的相关实现都是来自Java和C++。
在这个实现中我们将利用三种类型的Flyweight组件,它们是:
Flyweight(享元)
描述一个接口,通过这个接口flyweight可以接受并作用于外部状态。
Concrete flyweight(具体享元)
实现Flyweight接口,并存储内部状态。Concrete Flyweight对象必须是可共享的,并能够控制外部状态。
Flyweight factory(享元工厂)
创建并管理flyweight对象。确保合理地共享flyweight,并将它们当作一组对象进行管理,并且如果我们需要单个实例时,可以查询这些对象。如果该对象已经存在则直接返回,否则,创建新对象并返回。
它们与实现中的下列定义相对应:
CoffeeOrder:享元
CoffeeFlavor:具体享元
CoffeeOrderContext:辅助器
CoffeeFlavorFactory:享元工厂
testFlyweight:享元的应用
鸭子补丁“实现”
鸭子补丁(Duck punching)使我们无需修改运行时源,就可以扩展一种语言或解决方案的功能。由于下一个解决方案要求使用 Java 关键字(implements)来实现接口,并且无法在原生JavaScript中找到,所以让我们首先对它进行鸭子补丁。
Function.prototype.implementsFor作用于一个对象构造函数,并将接受一个父类(函数)或对象,或者使用普通继承(函数)或虚拟继承(对象)来继承它。
// 在JS里模拟纯虚拟继承 implement
Function.prototype.implementsFor = function (parentClassOrObject) {
if (parentClassOrObject.constructor === Function)
{
// 正常继承
this.prototype = new parentClassOrObject();
this.prototype.constructor = this;
this.prototype.parent = parentClassOrObject.prototype;
}
else {
// 纯虚拟继承
this.prototype = parentClassOrObject;
this.prototype.constructor = this;
this.prototype.parent = parentClassOrObject;
}
return this;
};
通过使一个函数显式地继承一个接口,可以用它来为缺少的implements关键字打上补丁。在下面的代码里,CoffeeFlavor实现了CoffeeOrder接口,且必须包含它的接口方法,以便将功能的实现赋值给对象。
// 享元对象
var CoffeeOrder = {
// 接口
serveCoffee: function (context) { },
getFlavor: function () { }
};
// 实现CoffeeOrder的具体享元对象
function CoffeeFlavor(newFlavor) {
var flavor = newFlavor;
// 如果已经为某一功能定义了接口,则实现该功能
if (typeofthis.getFlavor === "function") {
this.getFlavor = function () {
return flavor;
};
}
if (typeofthis.serveCoffee === "function") {
this.serveCoffee = function (context) {
console.log("Serving Coffee flavor "
+ flavor
+ " to table number "
+ context.getTable());
};
}
}
// 为CoffeeOrder实现接口
CoffeeFlavor.implementsFor(CoffeeOrder);
// 处理coffee订单的table数
function CoffeeOrderContext(tableNumber) {
return {
getTable: function () {
return tableNumber;
}
};
}
// 享元工厂对象
function CoffeeFlavorFactory() {
var flavors = [],
return {
getCoffeeFlavor: function (flavorName) {
var flavor = flavors[flavorName];
if (flavor === undefined) {
flavor = new CoffeeFlavor(flavorName);
flavors.pushc [flavorName],flavor]);
}
return flavor;
},
getTotalCoffeeFlavorsMade: function () {
return flavors.length;
}
}
};
// 样例用法:
// testFlyweight()
function testFlyweight() {
// 已订购的flavor.
var flavors = new CoffeeFlavor(),
// 订单table
tables = new CoffeeOrderContext(),
// 订单数量
ordersMade = 0,
//TheCoffeeFlavorFactory 实例
flavorFactory;
function takeOrders(flavorIn, table) {
flavors[ordersMade] = flavorFactory.getCoffeeFlavor(flavorIn);
tables[ordersMade++] = new CoffeeOrderContext(table);
}
flavorFactory = new CoffeeFlavorFactory();
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());
}
9.13.4 转换代码以使用Flyweight(享元)模式
接下来,通过实现一个系统来管理图书馆中的所有书籍,让我们来继续了解一下享元。每本书的重要元数据可以被分解成如下形式:
ID
Title
Author
Genre
Page count
Publisher ID
ISBN
我们还将需要使用以下属性来跟踪哪些成员已借出了哪些书籍,借书日期以及预计返还的日期。
checkoutDate
checkoutMember
dueReturnDate
availability
因此每本书在使用享元模式进行优化之前,都会按如下方式表示:
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 = {
getTitle: function () {
return this.title;
},
getAuthor: function () {
return this.author;
},
getISBN: function () {
returnthis.ISBN;
},
// 鉴于篇幅,其他属性就暂不列出了
updateCheckoutStatus: function (bookID, newStatus, checkoutDate,
checkoutMember, newReturnDate) {
this.id = bookID;
this.availability = newStatus;
this.checkoutDate = checkoutDate;
this.checkoutMember = checkoutMember;
this.dueReturnDate = newReturnDate;
},
extendCheckoutPeriod: function (bookID, newReturnDate) {
this.id = bookID;
this.dueReturnDate = newReturnDate;
},
isPastDue: function (bookID) {
var currentDate = new Date();
return currentDate.getTime() > Date.parse(this.dueReturnDate);
}
};
刚开始对于少量书籍可能是行得通的,但是,当图书馆扩大到拥有一个更大的库存,并且每本书都有多个版本和副本时,就会发现随着时间的推移,管理系统运行得越来越慢。使用数以千计的书籍对象可能会淹没可用内存,但可以使用享元模式优化系统来改善这个问题。
现在可以将数据分成内部和外部状态,如下所示:与书籍对象(title、author等)相关的数据是内部状态,而借出数据(checkoutMember、dueReturnDate等)是外部状态。实际上这意味着,每个书籍属性组合只需要有一个Book对象。它仍然要处理相当多的对象,但比以前处理的对象明显减少了。
下面书籍元数据组合的单个实例将在指定书名的书籍副本之间共享。
// 享元优化版本
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;
};
正如我们可以看到的,外部状态已被删除。图书馆借出有关的所有事情都将转移给管理器,由于对象数据现在已被分割,可以使用工厂进行实例化。
9.13.5 基本工厂
现在让我们来定义一个基本的工厂。首先,必须要检查一下指定书名的书是否已在系统内部创建。如果已经创建,则返回它;如果没有,就会创建并存储这本新书,以便以后可以访问它。这确保我们仅为每一个特定的内部数据块创建一个拷贝:
// 书籍工厂单例
var BookFactory = (function () {
var existingBooks = {}, existingBook;
return {
createBook: function (title, author, genre, pageCount, publisherID, ISBN) {
// 如果书籍之前已经创建,则找出并返回它
// !!强制返回布尔值
existingBook = existingBooks[ISBN];
if (!!existingBook) {
return existingBook;
} else {
// 如果没找到,则创建一个该书的新实例,并保存
var book = new Book(title, author, genre, pageCount, publisherID, ISBN);
existingBooks[ISBN] = book;
return book;
}
}
};
});
9.13.6 管理外部状态
接下来,我们需要存储从Book对象中删除的状态。幸运的是,可以使用管理器(我们会将它定义为一个单例)来封装它们。一个Book对象和借书成员的组合将被称为书籍记录。管理器会将它们存储起来,它还包括在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
};
},
updateCheckoutStatus: 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 currentDate = new Date();
return currentDate.getTime() > Date.parse(
bookRecordDatabase[bookID].dueReturnDate);
}
};
});
这些代码修改的结果是,从Book类中提取的所有数据,现在被存储在BookManager单例(BookDatabase)的属性中,这比我们以前使用大量对象时的效率要高很多。现在与书籍出借相关的方法在这里成为了基础,因为它们处理的是外部数据,而不是内部数据。
这个过程给我们的最终解决方案上增加了一点复杂性,但与它所解决的性能问题相比,这只是一个小问题。它具有数据智能性,如果有30本完全相同的书,我们现在只需要存储它一次。同时,每个函数都占用内存。通过使用Flyweight模式,这些函数在一个地方(在管理器上)存在,而不是在每个对象上存在,从而节约更多的内存。
9.13.7 Flyweight(享元)模式和DOM
文档对象模型(DOM)支持两种方式让对象检测事件:自上而下(事件捕捉)和自下而上(事件冒泡)。
在事件捕捉中,事件首先被最外层的元素捕捉,然后传播到最里面的元素。在事件冒泡中,事件被捕捉并传递给最里面的元素,然后传播到外部元素。
在这个上下文中描述享元的最好比喻之一是由Gary Chisholm编写的,它是类似这样的:
试着用池塘的方式思考一下享元。一条鱼张开它的嘴(事件),气泡升到表面(冒泡),当气泡到达表面(动作)时,一只坐在顶部的苍蝇飞走了。在本例中,我们可以很容易地把鱼张开嘴转换成点一个按钮,气泡转换成冒泡效应,苍蝇飞走可转换成运行一些功能。
引入冒泡用于处理这些情况:一个单一的事件(如一次点击)可能是由DOM层级的不同级别所定义的多个事件处理程序进行处理。上述事情发生时,事件冒泡先执行为最低层级特定元素定义的事件处理程序。此后,事件在冒泡到更高级元素之前,先冒泡到包含的这些元素上。
享元可以用来进一步调整事件冒泡过程,正如我们即将要看到的(示例9-9)。
在第一个实际示例中,假设一个文档中有一些相似的元素,在用户对它们执行用户动作(如:点击、鼠标悬停)时执行同样相似的行为。
通常在构建我们自己的accordion组件、菜单或其他基于列表的小部件时,我们要做的就是将一个点击事件绑定至父容器(如$('ul li a').on(..))中的每个链接元素上。其实不需将点击绑定至多个元素,我们就可以很容易地将享元附加到容器的顶部,它可以监听来自下面的事件。然后这些事情可以使用逻辑进行处理,逻辑与否简单取决于要求是否简单或复杂。
由于之前经常提到的组件类型的每个部分都有相同的重复标记(如accordion的每一节代码),有很大的机会是:被点击的每个元素的行为都和附近其他带有同名样式(class)元素的行为非常相似。利用这些信息,我们将使用享元来构建一个基本的accordion。
在jQuery用户将初始化点击绑定到一个容器div的同时,这里使用了一个stateManager命名空间来封装我们的享元逻辑。为了确保页面上没有其他相似逻辑处理程序附加在div容器上,刚开始就应用unbind事件。
现在要确定容器中的哪个子元素被点击,我们利用一个target检查,它提供了一个对被点击元素的引用,和父元素无关。然后,我们利用此信息来处理单击事件,而不是在页面加载时将事件绑定至特定的子元素上。
示例9-9 集中事件处理
如下是HTML代码:
如下是JavaScript代码:
var stateManager = {
fly: function () {
var self = this;
$("#container").unbind().on("click", function (e) {
var target = $(e.originalTarget || e.srcElement);
if (target.is("div.toggle")) {
self.handleClick(target);
}
});
},
handleClick: function (elem) {
elem.find("span").toggle("slow");
}
};
这里的好处在于,我们将很多独立的动作转变成一个共享的动作(可能会节省内存)。
在第二个示例中,我们可以通过使用具有jQuery的享元模式进一步提高性能。
James Padolsey之前写了一篇名为《76 bytes for faster jQuery》的文章,文中他提醒我们:每次jQuery触发一个回调,无论何种类型(过滤器、每个、事件处理程序),我们都能够通过this关键字访问函数的上下文(DOM元素与它相关)。
可惜的是,我们中的很多人都已经习惯了在$()或jQuery()中包装this这个想法,这意味着每次构建jQuery的新实例都不是必要的。而不是像如下这样做:(示例9-10)
示例9-10 使用Flyweight进行性能优化
$("div").on("click", function () {
console.log("You clicked: " + $(this).attr("id"));
});
// 我们需要避免使用DOM元素创建jQuery对象(像上面的代码那样),直接像下面这样使用DOM元素即可:
$("div").on("click", function () {
console.log("You clicked:" + this.id);
});
James希望在下列上下文中使用jQuery的jQuery.text;但是,他不同意的观点是:在每个迭代循环里创建新的jQuery对象。
$("a").map(function () {
return $(this).text();
});
在冗余的包装方面(这里可能是使用jQuery实用方法的情况下),最好使用jQuery.methodName(如:jQuery.text),而不是jQuery.fn.methodName(如:jQuery. fn.text)。其中,methodName代表一个实用程序,例如each()或text。这样做不需要在每次调用我们的函数时,都调用更高一级的抽象或创建一个新的jQuery对象,因为jQuery.methodName是库本身在底层抽象所使用的方法,以助力jQuery. fn. methodName。
因为不是所有的jQuery方法都有相应的单节点函数,所以Padolsey想出了jQuery. single工具这一概念。
这里的想法是:单一的jQuery对象被创建,用于每次对jQuery.single的调用(实际上意味着只有一个jQuery对象被创建)。可以在下面找到它的实现,由于我们是将多个可能对象的数据合并到一个更加集中的单一结构中,这在技术上讲也是享元。
jQuery.single = (function (o) {
var collection = jQuery([1]);
return function (element) {
// 将元素赋值给集合:
collection[0] = element;
// 返回集合:
return collection;
};
});
使用链接的示例如下所示:
虽然我们可能相信,简单缓存jQuery代码可能会提供相等的性能受益,Padolsey称仍然值得使用$.single,并且它可以表现的更好。这并不是说不需要使用任何缓存,只是要注意这种方法是对我们有帮助的。要进一步了解$.single方面的细节,我建议大家阅读Padolsey的完整文章。