本节书摘来自异步社区《JavaScript设计模式》一书中的第9章,第9.12节, 作者: 【美】Addy Osmani 译者: 徐涛 更多章节内容可以访问云栖社区“异步社区”公众号查看。
9.12 Decorator(装饰者)模式
Decorator是一种结构型设计模式,旨在促进代码复用。与Mixin相类似,它们可以被认为是另一个可行的对象子类化的替代方案。
通常,Decorator提供了将行为动态添加至系统的现有类的能力。其想法是,装饰本身对于类原有的基本功能来说并不是必要的;否则,它就可以被合并到超类本身了。
装饰者可以用于修改现有的系统,希望在系统中为对象添加额外的功能,而不需要大量修改使用它们的底层代码。开发人员使用它们的一个共同原因是,应用程序可能包含需要大量不同类型对象的功能。想象一下,如果必须为一个JavaScript游戏定义数百种不同的对象构造函数会怎么样(见图9-11)。
对象构造函数可以代表不同的玩家类型,每个类型都有不同的功能。魔戒游戏可能需要Hobbit、Elf、Orc、Wizard、Mountain Giant、Stone Giant等构造函数,甚至有可能数以百计。如果我们把功能作为因素计算,可以想象必须为每个能力类型组合创建子类—如:HobbitWithRing、HobbitWithSword、HobbitWithRingAndSword等等。当计算越来越多的不同能力时,这并不是很实用,当然也是不可控的。
Decorator模式并不严重依赖于创建对象的方式,而是关注扩展其额外功能。我们使用了一个单一的基本对象并逐步添加提供额外功能的decorator对象,而不是仅仅依赖于原型继承。这个想法是:向基本对象添加(装饰)属性或方法,而不是进行子类化,因此它较为精简。
在JavaScript中向对象添加新属性是一个非常简单的过程,所以带着这种想法,可以实现一个非常简单的decorator,如下所示(-7和示例9-8):
示例9-7 使用新功能装饰构造函数
// 车辆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");
// 给truck装饰新的功能
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和Bates所著的一本名为《深入浅出设计模式》书籍,它围绕的是模拟购买苹果笔记本。
示例9-8 使用多个Decorator装饰对象
// 被装饰的对象构造函数
function MacBook() {
this.cost = function () { return 997; };
this.screenSize = function () { return 11.6; };
}
// Decorator 1
function Memory(macbook) {
var v = macbook.cost();
macbook.cost = function () {
return v + 75;
};
}
// Decorator 2
function Engraving(macbook) {
var v = macbook.cost();
macbook.cost = function () {
return v + 200;
};
}
// Decorator 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());
在这个示例中,Decorator重写MacBook()超类对象的.cost()函数来返回MacBook的当前价格加上特定的升级价格。
我们认为装饰作为并没有重写原始Macbook对象的构造函数方法(如screenSize()),为Macbook定义的其他属性也一样,依然保持不变并完好无损。
实际上在前面的示例中没有已定义的接口,从创建者移动到接收者时,我们转移了确保一个对象符合接口要求的职责。
9.12.1 伪经典Decorator(装饰者)
现在,我们要查看Dustin Diaz和Ross Harmes所著的《JavaScript设计模式》(PJDP)一书中提出的装饰者变体。
不像早些时候的一些示例,Diaz和Harmes更关注如何在其他编程语言(如Java或C++)中使用“接口”的概念实现装饰者,我们稍后将对其进行更详细地定义。
这个Decorator模式的特殊变体是用于引用目的。如果你发现它过于复杂,我建议选择前面介绍的较简单实现。
9.12.1.1 接口
PJDP将Decorator模式描述为一种用于在相同接口的其他对象内部透明地包装对象的模式。接口应该是对象定义方法的一种方式,但是,它实际上并不直接指定如何实现这些方法。
接口还可以定义接收哪些参数,但这些都是可选的。
那么,我们为什么要在JavaScript中使用接口呢?其想法是:它们可以自我记录,并能促进可复用性。理论上,通过确保实现类保持和接口相同的改变,接口可以使代码变得更加稳定。
下面是使用鸭子类型在JavaScript中实现接口的一个示例,这种方法帮助确定一个对象是否是基于其实现方法的构造函数/对象的实例。
// 用事先定义好的接口构造函数创建接口,该函数将接口名称和方法名称作为参数
// 在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 = 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提供了严格的功能检查。
接口的最大问题是,在JavaScript中没有为它们提供内置支持,试图模仿可能不太合适的另外一种语言特性是有风险的。可以在不花费大量性能成本的情况下使用享元接口,但我们将继续看一下使用相同概念的抽象装饰者。
9.12.1.2 抽象Decorator(抽象装饰者)
为了演示该版本Decorator模式的结构,假设我们有一个超类,再次模拟Macbook,以及模拟一个商店允许我们“装饰”苹果笔记本并收取增强功能的额外费用。
增强功能可以包括将内存升级到4GB或8GB、雕刻、Parallels或外壳。如果为每个增强选项组合使用单个子类来模拟它,看起来可能就是这样的:
var Macbook = function () {
//...
};
var MacbookWith4GBRam = function () { },
MacbookWith8GBRam = function () { },
MacbookWith4GBRamAndEngraving = function () { },
MacbookWith8GBRamAndEngraving = function () { },
MacbookWith8GBRamAndParallels = function () { },
MacbookWith4GBRamAndParallels = function () { },
MacbookWith8GBRamAndParallelsAndCase = function () { },
MacbookWith4GBRamAndParallelsAndCase = function () { },
MacbookWith8GBRamAndParallelsAndCaseAndInsurance = function () { },
MacbookWith4GBRamAndParallelsAndCaseAndInsurance = function () { };
…等等。
这将是一个不切实际的解决方案,因为每个可用的增强功能组合都需要一个新的子类。为了让事情变得简单点,而不需维护大量的子类,让我们来看看可以如何使用装饰者来更好地解决这个问题。
我们只需创建五个新的装饰者类,而不是需要之前看到的所有组合。在这些增强类上调用的方法将被传递给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 () {
returnthis.macbook.add8GBRam();
},
addCase: function () {
return this.macbook.addCase();
},
getPrice: function () {
return this.macbook.getPrice();
}
};
上面的示例演示的是:Macbook decorator接受一个对象作为组件。它使用了我们前面定义的Macbook接口,针对每个方法,在组件上会调用相同的方法。我们现在可以仅通过使用MacbookDecorator创建选项类;简单调用超类构造函数,必要时可以重写任何方法。
var CaseDecorator = function (macbook) {
// 接下来调用超类的构造函数
this.superclass.constructor(macbook);
};
// 扩展超类
extend(CaseDecorator, MacbookDecorator);
CaseDecorator.prototype.addCase = function () {
return this.macbook.addCase() + "Adding case to macbook";
};
CaseDecorator.prototype.getPrice = function () {
return this.macbook.getPrice() + 45.00;
};
正如我们可以看到的,其中的大部分内容都是很容易实现的。我们所做的是重写需要装饰的addCase()和getPrice()方法,首先执行该组件的原有方法,然后加上额外的内容(文本或价格)。到目前为止本节已展示了很多的信息了,让我们试着将所有内容整合到一个示例中,希望能够加强所学到的内容。
// 实例化macbook
var myMacbookPro = new MacbookPro();
// 输出: 900.00
console.log(myMacbookPro.getPrice());
// 装饰macbook
myMacbookPro = new CaseDecorator(myMacbookPro);
// 返回的将是945.00
console.log(myMacbookPro.getPrice());
由于装饰者可以动态地修改对象,因此它们是一种用于改变现有系统的完美模式。有时候,为对象创建装饰者比维护每个对象类型的单个子类要简单一些。可以让可能需要大量子类对象的应用程序的维护变得更加简单。
**
9.12.2 使用jQuery的装饰者**
与我们已经涉及的其他模式一样,也有一些使用jQuery实现的装饰者模式的示例。jQuery.extend()允许我们在运行时或者在随后一个点上动态地将两个或两个以上的对象(和它们的属性)一起扩展(或合并)为一个单一对象。
在这种情况下,一个目标对象可以用新功能来装饰,而不会在源/超类对象中破坏或重写现有的方法(虽然这是可以做到的)。
在接下来的示例中定义三个对象:defaults、options和settings。该任务的目的是为了装饰defaults对象,将options中的额外功能附加到defaults上。我们必须首先使defaults保持未接触状态,并且保持稍后可以访问其属性或函数的能力;然后,给defaults赋予使用装饰属性和函数的能力,这些装饰属性和函数是从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(", ") + " }";
}
};
// 合并defaults和options,没有显式修改defaults
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!"); } }
// options -- { validate: true, name: bar,
helloWorld: function (){ console.log("hello!"); } }
// defaults -- { validate: false, limit: 5, name: foo,
welcome: function (){ console.log("welcome!"); } }
9.12.3 优点和缺点
开发人员喜欢使用这种模式,因为它使用时可以是透明的,并且也是相当灵活的:正如我们所看到的,对象可以被新行为包装或“装饰”,然后可以继续被使用,而不必担心被修改的基本对象。在一个更广泛的上下文中,这种模式也使我们不必依靠大量的子类来获得同样的好处。
但是在实现该模式时,也有一些缺陷是我们应该要注意的。如果管理不当,它会极大地复杂化应用程序架构,因为它向我们的命名空间引入了很多小型但类似的对象。让人担心的是,除了对象变得难以管理,其他不熟悉这个模式的开发人员可能难以理解为什么使用它。
大量的评论或模式研究应该有助于解决后者的问题,但是,只要我们继续把握住在应用程序中使用装饰者的广度,在这两方面就应该可以做得很好。