第15章 装饰者模式
15.1 模拟传统面向对象语言的装饰者模式
首先要提出来的是,作为一门解释执行语言,给javascript中的对象动态添加或者改变职责是一件再简单不过的事情,虽然这种做法改动了对象自身,跟传统定义中的装饰者模式并不一样,但这无疑更符合javascript的语言特色:
var obj = {
name:’sven’,
address:’深圳市’
}
obj.address = obj.address+’福田区’;
传统面向对象语言中的装饰者模式在javascript中适用的场景并不多,如上面的代码所示,通常我们并不太介意改动对象自身。尽管如此,本节我们还是稍微模拟一下传统面向对象语言中的装饰者模式实现。
假设我们在编写一个飞机大战的游戏,随着经验值的增加,我们操作的飞机对象可以升级成更厉害的飞机,一开始这些飞机只能发射普通的子弹,升到第二级时可以发射,升到第三级时可以发射原子弹。原始飞机类:
var Plane = function(){}
Plane.prototype.fire= function(){
console.log('发射普通子弹');
}
接下来增加两个装饰类,分别是导弹和原子弹
var MissileDecorator= function(plane){
this.plane= plane;
}
MissileDecorator.prototype.fire= function(){
this.plane.fire();
console.log('发射导弹');
}
var AtomDecorator = function(plane){
this.plane= plane;
}
AtomDecorator.prototype.fire= function(){
this.plane.fire();
console.log('发射原子弹');
}
导弹类和原子弹类的构造函数都接受参数plane对象,并且保存好这个参数,在它们的fire方法中,除了执行自身的操作之外,还调用plane对象的fire方法。
这种给对象动态增加职责的方式,并没有真正地改动对象自身,而是将对象放入另一个对象之中,这些对象以一条链的方式进行引用,gadng个聚合对象。这些对象都拥有相同的接口(fire)当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中下一个对象。
因为装饰者对象和它所装饰的对象拥有一致的接口,所以它们对使用该对象的客户来说是透明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意多个装饰者对象。
var plane = new Plane();
plane = new MissileDecorator(plane);
plane = new AtomDecorator(plane);
plane.fire(); //分别输出:发射普通子弹、发射导弹、发射原子弹
15.2 装饰者也是包装器
在《设计模式》成书之前,GoF原想把装饰者模式称为包装器模式。
从功能上而言,decorator能很好地描述这个模式,但从结构上看,wrapper的说法更加贴切。装饰者模式将一个对象嵌入另一个对象之中,实际上相当于这个对象被另一个对象包装起来,形成一条包装链。请求随着这条链依次传递到所有对象,每个对象都有处理这条请求的机会。
15.3 回到javascript的装饰者
javascript语言动态改变对象相当容易,我们可以直接改写对象或对象的某个方法,并不需要使用“类”来实现装饰者模式如下:
var plane= {
fire:function(){
console.log('发射普通子弹');
}
}
var missileDecorator = function(){
console.log("发射导弹");
}
var atomDecorator = function(){
console.log("发射原子弹");
}
var fire1= plane.fire;
plane.fire= function(){
fire1();
missileDecorator();
}
var fire2= plane.fire;
plane.fire= function(){
fire2();
atomDecorator();
}
plane.fire();//分别输出:发射普通子弹、发射导弹、发射原子弹
15.4 装饰函数
在javascript中,几乎一切都是对象,其中函数又被称为一等对象。在平时的开发工作中,也许大部分时间都在和函数打交道。在javascript中可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能,在代码的运行期间我们很难切入某个函数的执行环境。
要想为函数添加一些功能,最简单粗暴的方式就是直接改写该函数,但这是最差的办法,直接违反了开放—封闭原则。
var a = function(){
alert(1);
}
//改成
var a = function(){
alert(1);
alert(2);
}
很多时候我们不想去碰原函数,也许原函数是由其他同事编写的,里面的实现非常杂乱。甚至在一个古老的项目中,这个函数的源代码被隐藏在一个我们不愿意碰触的阴暗角落里。现在需要一个办法,在不改变函数源代码的情况下,给函数增加功能,这正是开发—封闭原则给我们指出的光明道路。
其实我们在上一节中,我们已经找到一种答案,通过保存原引用方式就可以改写某个函数:
var a = function(){
alert(1);
}
var _a = a;
a = function(){
_a();
alert(2);
}
a();
这是在实际开发中很常见的一种做法,比如我们想给window绑定onload事件,但是又不确定这个事件是不是已经被其他人绑定过,为了避免覆盖之前的window.onload函数的行为,我们一般都会先保存好原先的window.onload,把它放入新的window.onload里执行:
window.onload = function(){
alert(1);
}
var _onload = window.onload||function(){};
window.onload = function(){
_onload();
alert(2);
}
这样的代码当然是符合开放—封闭原则的,我们在增加新功能的时候,确实没有修改原window.onload代码,但是这种方式存在以下两个问题:
o 必须维护_onload这个中间变量,虽然看起来并不起眼,但如果函数的装饰链较长,或者需要装饰的函数变多,这些中间变量的数量也会越来越多。
o 其实还遇到了this被劫持的问题,在window.onload的例子中没有这个烦恼,是因为调用普通函数_onload时,this也指向window,跟调用window.onload时一样。现在把window.onload换成document.getElementById,代码如下:
var_getElementById = document.getElementById;
document.getElementById= function(id){
alert(1);
return _ getElementById(id); //会抛出异常的
}
发生异常处的_ getElementById是一个全局函数,当调用一个全局函数时,this是指向window的,而document.getElementById方法的内部实现需要使用this引用,this在这个方法内预期是指向document,而不是window,这是错误发生的原因,所以使用现在的方式给函数增加功能并不保险。
改进后的代码可以满足需求,我们要手动把document作为上下文this传入_ getElementById:
var _getElementById = document.getElementById;
document.getElementById= function(){
alert(1);
return _ getElementById.apply(document,arguments);//会抛出异常的
}
15.5 用AOP装饰函数
首先给出Function.prototype.before方法和Function.prototype.after方法:
Function.prototype.before =function(beforefn){
var __self = this;
return function(){
beforefn.apply(this,arguments);
return__self.apply(this,arguments);
}
}
Function.prototype.after =function(afterfn){
var __self = this;
return function(){
var ret =__self.apply(this,arguments);
afterfn.apply(this,arguments);
return ret;
}
}
再回到window.onload的例子,看看Function.prototype.before来增加新的window.onload事件是多么简单:
window.onload = function(){
alert(1);
}
window.onload =(window.onload||function(){}).after(function(){
alert(2);
}).after(function(){
alert(3);
}).after(function(){
alert(4);
});
值得提到的是,上面的AOP实现是在Function.prototype上添加before和after方法,但许多人不喜欢这种污染原型的方式,那么我们可以做一些变通,把原函数和新函数都作为参数传入before,after方法
var before = function(fn,beforefn){
return function(){
beforefn.apply(this,arguments);
return fn.apply(this,arguments);
}
}
var a = before(function(){alert(3),alert(2)});
a = before(a,function(){alert(1)});
a();
15.6 AOP的应用实例
用AOP装饰函数的技巧在实际开发中非常有用。不论是业务代码编写,还是在框架层我们都可以把行为依照职责分成粒度更细的函数,随后通过装饰把它们合并到一起,这有助于我们编写一个松耦合和高利用性的系统。
15.6.1 数据统计上报
分离业务代码和数据统计代码,无论在什么语言中,都是AOP的经典应该之一。在项目开发的结尾阶段难免要加上很多统计数据的代码,这些过程可能让我们被迫改动早已封装好的函数。
比如页面中有一个登录button,点击这个button会弹出登录浮层,与此同时要进行数据上报来统计有多少用户点击了这个登录button。
<html>
<body>
<button tag=”login” id = “button”>点击打开登录浮层</button>
<script>
var showLogin = function(){
console.log(‘打开登录浮层’);
log(this.getAttribute(‘tag’));
}
var log = function(tag){
console.log(‘上报标签为:’+tag);
//(new Image).src = ‘http://xxxxx…’+tag
}
</script>
</body>
</html>
我们看到在showLogin函数里,即要负责打开登录浮层,又要负责数据上报,这是两个层面的功能,在此处被耦合在一个函数里,使用AOP分离之后如下:
Function.prototype.after =function(afterfn){
var __self = this;
return function(){
var ret =__self.apply(this,arguments);
afterfn.apply(this,arguments);
return ret;
}
}
var showLogin = function(){
console.log(‘打开登录浮层’);
}
var log = function(){
console.log(‘上报标签为:’+this.getAttribute(‘tag’);
}
showLogin = showLogin.after(log); //打开登录浮层之后上报数据
15.6.2 用AOP动态改变函数的参数
观察Function.prototype.before方法
Function.prototype.before =function(beforefn){
var __self = this;
return function(){
beforefn.apply(this,arguments);//(1)
return__self.apply(this,arguments); //(2)
}
}
从这段代码的(1)处和(2)处可以看到,beforefn和原函数__self共用一组参数列表arguments,当我们在beforefn的函数体内改变arguments的时候,原函数__self接收的参数列表自然也会变化。
下面的例子展示了如何通过Fuction.prototype.before方法给函数func的参数param动态添加属性b:
var func = function(param){
console.log(param);
}
func = func.before(function(param){
param.b = ‘b’;
});
func({a:’a’}); //{a:’a’,b:’b’}
现在有一个用于发起ajax请求的函数,这个函数负责项目中所有的ajax异步请求:
var ajax = function(type,url,param){
console.dir(param);
//ajax略
}
ajax(‘get’,’http://xxx.com/userinfo’,{name:’sven’});
上面的代码表示向后台cgi发起一个请求来获取用户信息,传递给cgi的参数是{name:’sven’}。
ajax函数在项目中一直运转良好,跟cgi的合作也很愉快。直到有一天,我们的网站遭受了CSRF攻击。解决CSRF攻击最简单的一个办法就是在http请求中带上一个Token对象。
假设我们已经有一个用于生成Token的函数
var getToken = function(){
return ‘Token’;
}
现在的任务就是给每个ajax请求都加上Token参数
var ajax = function(){
param = param||{};
param.Token =getToken();
}
虽然已经解决问题,但我们的ajax函数相对变得僵硬了,每个从ajax函数里发出的请求都自动带上了Token参数,虽然在现在的项目中没有什么问题,但如果将业把这个函数移植到其他项目上,或者把它放到一个开源库中供其他人使用,Token参数都将是多余的。
也许另一个项目不需要验证Token,或者是Token的生成方式不同,无论是哪种情况,都必须重新修改ajax函数。
我们还原ajax函数
var ajax = function(type,url,param){
console.log(param);
}
然后所Token参数通过Function.prototype.before装饰到ajax函数的参数param对象中:
ajax =ajax.before(function(type,url,param){
param.Token = getToken();
});
ajax(‘get’,’http://xx.com/usrinfo’,{name:’sven’});
15.6.3 插件式的表单验证
var username= document.getElementById('username');
var password= document.getElementById('password');
var submitbtn= document.getElementById('submitbtn');
var formSubmit = function(){
if(username.value===''){
return alert('用户名不能为空');
}
if(password.value===''){
return alert('密码不能为空')
}
var param= {
username:username,
password:password
}
ajax('http://xxx.login',param);
}
submitbtn.onclick = function(){
formSubmit();
}
//本节的目的是分离校验输入和提交ajax请求的代码,我们把校验输入的逻辑放到validate函数中并约定validate函数返回false的时候,表壳校验未通过
var validata= function(){
if(username.value===''){
return alert('用户名不能为空');
return false;
}
if(password.value===''){
return alert('密码不能为空')
return false;
}
}
var formSubmit = function(){
if(validate()===false){
return false;
}
var param= {
username:username,
password:password
}
ajax('http://xxx.login',param);
}
//再进一步优化
var username= document.getElementById('username');
var password= document.getElementById('password');
var submitbtn= document.getElementById('submitbtn');
var validata = function(){
if(username.value===''){
return alert('用户名不能为空');
return false;
}
if(password.value===''){
return alert('密码不能为空')
return false;
}
}
var formSubmit = function(){
var param= {
username:username,
password:password
}
ajax('http://xxx.login',param);
}
formSubmit = formSubmit.before(validata);
submitbtn.onclick = function(){
formSubmit();
}
var username= document.getElementById('username');
var password= document.getElementById('password');
var submitbtn= document.getElementById('submitbtn');
var formSubmit = function(){
if(username.value===''){
return alert('用户名不能为空');
}
if(password.value===''){
return alert('密码不能为空')
}
var param= {
username:username,
password:password
}
ajax('http://xxx.login',param);
}
submitbtn.onclick = function(){
formSubmit();
}
//本节的目的是分离校验输入和提交ajax请求的代码,我们把校验输入的逻辑放到validate函数中并约定validate函数返回false的时候,表壳校验未通过
var validata= function(){
if(username.value===''){
return alert('用户名不能为空');
return false;
}
if(password.value===''){
return alert('密码不能为空')
return false;
}
}
var formSubmit = function(){
if(validate()===false){
return false;
}
var param= {
username:username,
password:password
}
ajax('http://xxx.login',param);
}
//再进一步优化
var username= document.getElementById('username');
var password= document.getElementById('password');
var submitbtn= document.getElementById('submitbtn');
var validata = function(){
if(username.value===''){
return alert('用户名不能为空');
return false;
}
if(password.value===''){
return alert('密码不能为空')
return false;
}
}
var formSubmit = function(){
var param= {
username:username,
password:password
}
ajax('http://xxx.login',param);
}
formSubmit = formSubmit.before(validata);
submitbtn.onclick = function(){
formSubmit();
}
如同把检验规则动态接在formSubmit函数之前,validata成为一个即插即用的函数,它甚至可能被写成配置文件的形式,这有利于我们分开维护这两个函数。
15.7 装饰者模式和代理模式
代理模式和装饰者模式的最重要区别在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便或不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问。或者在访问本体之前做一些额外的事情。装饰者模式的作用就为对象动态加入行为。换名话说,代理模式强调一种关系,这种关系可以静态表达,也就是说,这种关系在一天始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时,代理模式只有一层代理本体的引用,而装饰者模式经常会形成一条长长的装饰链。