原文:https://addyosmani.com/resources/essentialjsdesignpatterns/book/#decoratorpatternjavascript
装饰器是一种结构型设计模式,它旨在提升代码的复用性。与 Mixins 类似,它们可以当作对象子类化的另一种可行的替代方案。
装饰器典型的用法是是动态地向系统中已有类增加行为的能力。这个想法是装饰器本身并不是这个类的不可或缺基本功能,否则它将融入超类本身。
它们可以用来修改现有系统,我们希望能在不大改底层代码的情况下为系统中的对象添加额外的特性。开发者使用它们的一个通用原因就是他们的应用程序中可能包含需要大量不同类型对象的功能。想象一下,必须要定义数百个不同对象构造器的情况,比如一个 JavaScript 游戏。
对象的构造器可以表示不同玩家的类型,每一个类型都有不同的能力。 指环王 这个游戏需要为 Hobbit
、 Elf
、 Orc
、 Wizard
、 Mountain Giant
、 Giant
等等角色提供构造器,但很容易就有上百个这样的角色。如果我们考虑到能力,想象一下还要为与每个类型能力的组合再创建子类,如 HobbitWithRing
、 HobbitWithSword
、 HobbitWithRingAndSword
等等。当我们考虑到越来越多不同的能力时,这个做法就变得不是很实际,当然也无法管理。
装饰器模式与创建对象的方式并没有太大的关系,而是侧重于拓展其功能的问题。我们以单个基础对象,逐步地向其添加能提供额外能力的装饰器对象,而不仅仅是依赖原型继承。它的思想是我们添加(装饰)属性或者方法到一个基础对象上,而不是子类化,这样它就更加简洁。
在 JavaScript 中向对象添加属性是一个非常直接的过程,考虑到这一点,可以实现一个非常简单的装饰器:如下所示:
示例1:使用新功能来装饰构造函数
// 一个 vehicle 的构造函数
function Vehicle(vehicleType) {
// 一些常规的默认值
this.vehicleType = vehicleType || 'car';
this.model = 'default';
this.license = '00000-000';
}
// 测试基础 vehicle 的实例
var testInstance = new Vehicle('car');
console.log(testInstance);
// 输出:
// vehicle: car, model:default, license: 00000-000
// 让我们创建一个新的 vehicle 实例,它将用来被装饰
var truck = new Vehicle('truck');
// 我们装饰 vehicle 的新功能
truck.setModel = function(modelName) {
this.model = modelName;
};
truck.setColor = function(color) {
this.color = color;
};
// 测试值的读写是否正确
truck.setModel('CAT');
truck.setColor('blue');
console.log(truck);
// 输出:
// vehicle:truck, model:CAT, color: blue
// 示范 “vehicle" 仍然是未修改的
var secondInstance = new Vehicle('car');
console.log(secondInstance);
// 输出:
// vehicle: car, model:default, license: 00000-000
这种简单的实现是功能性的,但是它并没有真正展示装饰器所有的能力。为此,我们来看看我从 Freeman, Sierra and Bates 编写的一本名为 Head First Design Petterns 的优秀书籍中 Coffee 示例的改编,书中是以购买 Macbook 为模式。
示例2:使用多个装饰器来装饰对象
// 要被装饰的构造函数
function MacBook() {
this.cost = function() {
return 997;
};
this.screenSize = function() {
return 11.6;
};
}
// 装饰器1
function memory(macbook) {
var v = macbook.cost();
macbook.cost = function() {
return v + 75;
};
}
// 装饰器2
function engraving(macbook) {
var v = macbook.cost();
macbook.cost = function() {
return v + 200;
};
}
// 装饰器3
function insurance(macbook) {
var v = macbook.cost();
macbook.cost = function() {
return v + 250;
};
}
var mb = new MacBook();
memory(mb);
engraving(mb);
insurance(mb);
// 输出:1522
console.log(mb.cost());
// 输出:11.6
console.log(mb.screenSize());
在上面的例子中,我们的装饰器覆盖了 MacBook()
超类对象的 .cost()
函数,使其返回当前 Macbook
的价格加上指定升级所用的费用。
因为原始的 MacBook
对象的构造器的方法没有被覆盖 (例如: screenSize()
)以及我们可能定义为 macbook
的一部分的任何其他属性也没变,所以它被认为是装饰。
在上面的例子中并没有真正定义的接口 ,并且我们正在转移确保一个对象从创建这移动到接收者时符合接口规范的职责。
伪古典装饰器
我们现在将测验一下由 Dustin Diaz 和 Ross Harmes 在 Pro JavaScript Design Pattern (PJDP) 首先提出的装饰器一个变体。
与我们之前一些示例不同,Diaz 和 Harmes 更关注装饰器在其他语言中(例如 Java 和 C++)是如何使用“接口”概念来实现的,我们随后将详细的讲解它。
注意: 这种装饰器模式的变体仅供参考使用。如果觉得它太复杂的话,我推荐选择之前介绍过的更简单的一个实现。
接口
PJDP 将装饰器描述成一个用于透明地将对象包装在同一接口的其他对象中的模式。接口是定义对象 应该 拥有的方法的方式,但是它并不是直接声明这些方法应该如何被实现。
它们还可以声明这些方法接受什么样的参数,但这个声明是可选的。
所以,为什么我们应该在 JavaScript 中使用接口呢?思路是它们是自带文档化的,并且可以提升可复用性。理论上,接口还能通过确保对实现它们的对象也必须进行更改,以提升代码的稳定性。
下面是使用鸭子类型来实现 JavaScript 版本接口的示例, 鸭子类型是一种用于根据对象的实现方式来确定对象是否是构造函数/对象的实例的方法。
// 使用已经定义好的 Interface 构造器创建接口
// 这个构造器接受接口名和方法的结构并导出
// 在我们的 reminder 示例中,
// summary() 和 placeOrder() 表示接口应该支持的方法
var reminder = new Interface( "List", ["summary", "placeOrder"] );
var properties = {
name: "Remember to buy the milk",
date: "05/06/2016",
actions:{
summary: function (){
return "Remember to buy the milk, we are almost out!";
},
placeOrder: function (){
return "Ordering milk from your local grocery store";
}
}
};
// 现在创建一个实现上述属性和方法的构造器
function Todo( config ){
// 说明我们希望支持的方法已经接口实例以及要被检查的接口实例
Interface.ensureImplements( config.actions, reminder );
this.name = config.name;
this.methods = config.actions;
}
// 创建一个 Todo 构造器的新实例
var todoItem = new Todo( properties );
// 最后测试一下这些函数是否正确
console.log( todoItem.methods.summary() );
console.log( todoItem.methods.placeOrder() );
// 输出:
// Remember to buy the milk, we are almost out!
// Ordering milk from your local grocery store
上例中, Interface.ensureImplements
提供严格的功能检测,这些代码和 Interface
构造器都在这里。
接口最大的问题是,原生 JavaScript 不支持它,当我们尝试模拟另一种语言的特性来实现接口时,可能有与 JavaScript 不是完美的契合的风险。
然而使用轻量级接口,并且不会有很高的性能成本,接下来我们将使用同样的概念来研究 抽象装饰器 。
抽象装饰器
为了演示这个版本的装饰器模式结构,我们假设我们又有一个模拟 Macbook
的超类,还有一个商店允许我们通过一些增强项“装饰”我们的 Macbook,但需要额外的费用。
增强项可以是 Ram 升级到 4GB 或者 8GB 、激光篆刻、Parallers(一个软件)或者包装盒。现在,如果我们使用独立的子类来对每种增强项组合进行建模,它看起来会是这个样子:
var Macbook = function() {
//...
};
var MacbookWith4GBRam = function() {},
MacbookWith8GBRam = function() {},
MacbookWith4GBRamAndEngraving = function() {},
MacbookWith8GBRamAndEngraving = function() {},
MacbookWith8GBRamAndParallels = function() {},
MacbookWith4GBRamAndParallels = function() {},
MacbookWith8GBRamAndParallelsAndCase = function() {},
MacbookWith4GBRamAndParallelsAndCase = function() {},
MacbookWith8GBRamAndParallelsAndCaseAndInsurance = function() {},
MacbookWith4GBRamAndParallelsAndCaseAndInsurance = function() {};
等等。
这是一个不太现实的方案,因为需要为所有可能的增强项组合创建新的子类。因为我们更倾向于在不需要维护大量子类的情况下让事情变得更简单,让我们看看如何用装饰器来更好地解决这个问题。
我们只需要简单的创建5个新的装饰器类,而不需要我们之前列出看到的那些所有的组合创建类。这些增强项的类上调用的方法将传递给我们的 Macbook
类。
在接下来的例子中,装饰器透明地包裹着他们的组件,并且可以随意的互换,因为他们使用相同的接口。
下面是我们将用于定义 Macbook 的接口:
var Macbook = new Interface( "Macbook",
["addEngraving",
"addParallels",
"add4GBRam",
"add8GBRam",
"addCase"]);
// 因此,Macbook Pro 可能会变成这样:
var MacbookPro = function(){
// 实现 Macbook
};
MacbookPro.prototype = {
addEngraving: function(){
},
addParallels: function(){
},
add4GBRam: function(){
},
add8GBRam:function(){
},
addCase: function(){
},
getPrice: function(){
// 基础价格
return 900.00;
}
};
为了便于我们日后根据需要增加更多的选项,抽象装饰器类是用实现 Macbook
接口所需的默认方法定义的,剩下的选项将是子类。抽象装饰器保证我们可以在不同的组合中根据需要用任意多的装饰器自主地装饰基类(记得前面的那个例子吧?),而不需要为每种可能的组合派生类。
// Macbook 装饰器抽象装饰类
var MacbookDecorator = function(macbook) {
Interface.ensureImplements(macbook, Macbook);
this.macbook = macbook;
};
MacbookDecorator.prototype = {
addEngraving: function() {
return this.macbook.addEngraving();
},
addParallels: function() {
return this.macbook.addParallels();
},
add4GBRam: function() {
return this.macbook.add4GBRam();
},
add8GBRam: function() {
return this.macbook.add8GBRam();
},
addCase: function() {
return this.macbook.addCase();
},
getPrice: function() {
return this.macbook.getPrice();
}
};
上例中展示的是 Macbook
装饰器接受一个对象(Macbook)作为我们的基础组件。它使用我们早先定义好的 Macbook
接口,并且每个方法都只是简单地调用组件上的同名方法。我们现在可以使用 Macbook
装饰器来为可添加内容创建选项类。
// 首先,定义一个将对象 b 上的属性拓展到对象 a 上的方法。
// 我们很快就会用到它。
function extend(a, b) {
for (var key in b) if (b.hasOwnProperty(key)) a[key] = b[key];
return a;
}
var CaseDecorator = function(macbook) {
this.macbook = macbook;
};
// 现在我们使用 MacbookDecorator 来拓展(装饰) CaseDecorator
extend(CaseDecorator, MacbookDecorator);
CaseDecorator.prototype.addCase = function() {
return this.macbook.addCase() + 'Adding case to macbook';
};
CaseDecorator.prototype.getPrice = function() {
return this.macbook.getPrice() + 45.0;
};
我们这里做的是覆盖要被装饰的 addCase()
和 getPrice()
方法,具体的实现方式是通过先调用 macbook
上同名的原始方法,再然后将字符或者数值(如 45.00)简单的附加到它们上,。
到目前为止,本章已经提供了大量的信息,让我们来尝试将所有的内容整合到一起,希望能够突出我们所学的内容。
// 实例化 macbook
var myMacbookPro = new MacbookPro();
// 输出:900.00
console.log(myMacbookPro.getPrice());
// 装饰 macbook
var decoratedMacbookPro = new CaseDecorator(myMacbookPro);
// 这里会返回 945.00
console.log(decoratedMacbookPro.getPrice());
因为装饰器能够动态的修改对象,它是用于修改已有系统的最佳设计模式。有时,在对象周围创建装饰器比在每个对象类型维护独立的子类的麻烦更少。这就让维护需要大量子类对象的应用更直观。
这个示例的可用版本可以在 JSBin 上查看。
jQuery 的装饰器
与我们讲到的其他模式一样,同样会提供一个可以用 jQuery 实现的装饰器模式的示例。 jQuery.extend()
方法让我们能够在运行时将两个多更多个对象(以及它们的属性)拓展(或者合并)到一个对象中来。
在这种背景下,目标对象能够在不需要破坏或者覆盖源/超类对象中已有的方法(虽然可以做到)的情况下被装饰上新的功能。
在下面的例子中,我们定义了三个对象:defaults、options 和 settings。这个任务的目标是使用 optionssetings
中额外的功能来装饰 default
对象。我们必须做到:
- 让 “default” 保持未改变的状态,随后我们也不会失去读取它的属性或者方法的能力
- 获得使用从 “options” 中被装饰的属性和方法的能力
var decoratorApp = decoratorApp || {};
// 定义我们将要用到的对象
decoratorApp = {
defaults: {
validate: false,
limit: 5,
name: 'foo',
welcome: function() {
console.log('welcome!');
}
},
options: {
validate: true,
name: 'bar',
helloWorld: function() {
console.log('hello world');
}
},
settings: {},
printObj: function(obj) {
var arr = [],
next;
$.each(obj, function(key, val) {
next = key + ': ';
next += $.isPlainObject(val) ? printObj(val) : val;
arr.push(next);
});
return '{ ' + arr.join(', ') + ' }';
}
};
// 在不修改 default 的情况下合并 defaults 和 options
decoratorApp.settings = $.extend(
{},
decoratorApp.defaults,
decoratorApp.options
);
// 我们这里所做的是以一种方式装饰 defaults,
// 这种方式使我们能够获取访问它拥有的属性和功能的能力(以及装饰器 options 上的)。
// 并且 defaults 本身保持不变。
$('#log').append(
decoratorApp.printObj(decoratorApp.settings) +
+decoratorApp.printObj(decoratorApp.options) +
+decoratorApp.printObj(decoratorApp.defaults)
);
// settings -- { validate: true, limit: 5, name: bar, welcome: function (){ console.log( "welcome!" ); },
// helloWorld: function (){ console.log( "hello world" ); } }
// options -- { validate: true, name: bar, helloWorld: function (){ console.log( "hello world" ); } }
// defaults -- { validate: false, limit: 5, name: foo, welcome: function (){ console.log("welcome!"); } }
优缺点
开发者喜欢这个模式是因为它能够被透明地使用,并且相当的灵活 - 正如我们所看到的,对象可以用新的行为包裹或“装饰”,然后可以继续使用而不必担心基础对象被改变。在更广泛的背景下,这个模式还能让我们在不依赖大量的子类的情况下达到对应的效果。
但是在实现模式的时候还是有一些值得我们注意的缺点。如果管理不慎,它会显著的复杂化我们的项目结构,因为它引入了小却相似的对象到我们的命名空间中。这里的问题是除了增加管理难度外,其他不熟悉该模式的开发者可能很难掌握使用它们的原因。
详尽的注释或者模式研究有助于解决后一个问题,然而只要我们控制好应用中使用装饰器的程度,两个问题应该都不是问题。