装饰者模式
玩魔兽争霸的任务关时,对 15 级乱加技能点的野生英雄普遍没有好感,而是喜欢留着
技能点,在游戏的进行过程中按需加技能。同样,在程序开发中,许多时候都并不希望某个类天
生就非常庞大,一次性包含许多职责。那么我们就可以使用装饰者模式。装饰者模式可以动态地
给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。
在传统的面向对象语言中,给对象添加功能常常使用继承的方式,但是继承的方式并不灵活,
还会带来许多问题:一方面会导致超类和子类之间存在强耦合性,当超类改变时,子类也会随之
改变;另一方面,继承这种功能复用方式通常被称为“白箱复用”,“白箱”是相对可见性而言的,
在继承方式中,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性。
使用继承还会带来另外一个问题,在完成一些功能复用的同时,有可能创建出大量的子类,
使子类的数量呈爆炸性增长。比如现在有 4种型号的自行车,我们为每种自行车都定义了一个单
独的类。现在要给每种自行车都装上前灯、尾
灯和铃铛这 3种配件。如果使用继承的方式来给
每种自行车创建子类,则需要 4×3 = 12 个子类。
但是如果把前灯、尾灯、铃铛这些对象动态组
合到自行车上面,则只需要额外增加 3个类。
这种给对象动态地增加职责的方式称为装
饰者(decorator)模式。装饰者模式能够在不改
变对象自身的基础上,在程序运行期间给对象
动态地添加职责。跟继承相比,装饰者是一种
更轻便灵活的做法,这是一种“即用即付”的
方式,比如天冷了就多穿一件外套,需要飞行
时就在头上插一支竹蜻蜓,遇到一堆食尸鬼时
就点开 AOE(范围攻击)技能。
1 模拟传统面向对象语言的装饰者模式
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 方法。
这种给对象动态增加职责的方式,并没有真正地改动对象自身,而是将对象放入另一个对象
之中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口( fire
方法),当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的
下一个对象。
因为装饰者对象和它所装饰的对象拥有一致的接口,所以它们对使用该对象的客户来说是透
明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意
多个装饰者对象。
最后看看测试结果:
var plane = new Plane();
plane = new MissileDecorator( plane );
plane = new AtomDecorator( plane );
plane.fire();
// 分别输出: 发射普通子弹、发射导弹、发射原子弹
2 装饰者也是包装器
在《设计模式》成书之前,GoF原想把装
饰者(decorator)模式称为包装器(wrapper)
模式。
从功能上而言,decorator能很好地描述这
个模式,但从结构上看,wrapper 的说法更加
贴切。装饰者模式将一个对象嵌入另一个对象
之中,实际上相当于这个对象被另一个对象包
装起来,形成一条包装链。请求随着这条链依
次传递到所有的对象,每个对象都有处理这条
请求的机会。
3 回到 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();
// 分别输出: 发射普通子弹、发射导弹、发射原子弹
4 装饰函数
在 JavaScript中,几乎一切都是对象,其中函数又被称为一等对象。在平时的开发工作中,
也许大部分时间都在和函数打交道。在 JavaScript中可以很方便地给某个对象扩展属性和方法,
但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。在代码的运行期间,
我们很难切入某个函数的执行环境。
要想为函数添加一些功能,最简单粗暴的方式就是直接改写该函数,但这是最差的办法,直
接违反了开放封闭原则:
var a = function(){
alert (1);
}
// 改成:
var a = function(){
alert (1);
alert (2);
}
很多时候我们不想去碰原函数,也许原函数是由其他同事编写的,里面的实现非常杂乱。甚
至在一个古老的项目中,这个函数的源代码被隐藏在一个我们不愿碰触的阴暗角落里。现在需要
一个办法,在不改变函数源代码的情况下,能给函数增加功能,这正是开放封闭原则给我们指
出的光明道路。
其实在 3节的代码中,我们已经找到了一种答案,通过保存原引用的方式就可以改写某个
函数:
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);
}
必须维护 _onload 这个中间变量,虽然看起来并不起眼,但如果函数的装饰链较长,或者
需要装饰的函数变多,这些中间变量的数量也会越来越多。
其实还遇到了 this 被劫持的问题,在 window.onload 的例子中没有这个烦恼,是因为调用
普通函数 _onload 时, this 也指向 window ,跟调用 window.onload 时一样(函数作为对象的
方法被调用时, this 指向该对象,所以此处 this 也只指向 window )。现在把 window.onload
换成 document.getElementById ,代码如下:
var _getElementById = document.getElementById;
document.getElementById = function( id ){
alert (1);
return _getElementById( id ); // (1)
}
var button = document.getElementById( 'button' );
执行这段代码,我们看到在弹出 alert(1) 之后,紧接着控制台抛出了异常:
// 输出: Uncaught TypeError: Illegal invocation
异常发生在(1) 处的 _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 );
}
var button = document.getElementById( 'button' );
但这样做显然很不方便,下面我们用 AOP,来提供一种完美的方法给
函数动态增加功能。
首先给出 Function.prototype.before 方法和 Function.prototype.after 方法:
Function.prototype.before = function( beforefn ){
var __self = this; // 保存原函数的引用
return function(){ // 返回包含了原函数和新函数的"代理"函数
beforefn.apply( this, arguments ); // 执行新函数,且保证 this 不被劫持,新函数接受的参数
// 也会被原封不动地传入原函数,新函数在原函数之前执行
return __self.apply( this, arguments ); // 执行原函数并返回原函数的执行结果,
// 并且保证 this 不被劫持
}
}
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
Function.prototype.before 接受一个函数当作参数,这个函数即为新添加的函数,它装载了
新添加的功能代码。
接下来把当前的 this 保存起来,这个 this 指向原函数,然后返回一个“代理”函数,这个
“代理”函数只是结构上像代理而已,并不承担代理的职责(比如控制对象的访问等)。它的工作
是把请求分别转发给新添加的函数和原函数,且负责保证它们的执行顺序,让新添加的函数在原
函数之前执行(前置装饰),这样就实现了动态装饰的效果。
我们注意到,通过 Function.prototype.apply 来动态传入正确的 this ,保证了函数在被装饰
之后, this 不会被劫持。
Function.prototype.after 的原理跟 Function.prototype.before 一模一样,唯一不同的地方在
于让新添加的函数在原函数执行之后再执行
下面来试试用 Function.prototype.before 的威力:
<button id="button"></button>
Function.prototype.before = function( beforefn ){
var __self = this;
return function(){
beforefn.apply( this, arguments );
return __self.apply( this, arguments );
}
}
document.getElementById = document.getElementById.before(function(){
alert (1);
});
var button = document.getElementById( 'button' );
console.log( button );
再回到 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)},
function(){alert (2)}
);
a = before( a, function(){alert (1);} );
a();
6 AOP 的应用实例
用 AOP装饰函数的技巧在实际开发中非常有用。不论是业务代码的编写,还是在框架层面,
我们都可以把行为依照职责分成粒度更细的函数,随后通过装饰把它们合并到一起,这有助于我
们编写一个松耦合和高复用性的系统。
介绍几个例子,带大家进一步理解装饰函数的威力。
1 数据统计上报
分离业务代码和数据统计代码,无论在什么语言中,都是 AOP的经典应用之一。在项目开发
的结尾阶段难免要加上很多统计数据的代码,这些过程可能让我们被迫改动早已封装好的函数。
比如页面中有一个登录 button,点击这个 button会弹出登录浮层,与此同时要进行数据上报,
来统计有多少用户点击了这个登录 button:
<button tag="login" id="button">点击打开登录浮层</button>
var showLogin = function(){
console.log( '打开登录浮层' );
log( this.getAttribute( 'tag' ) );
}
var log = function( tag ){
console.log( '上报标签为: ' + tag );
// (new Image).src = 'http:// xxx.com/report?tag=' + tag; // 真正的上报代码略
}
document.getElementById( 'button' ).onclick = showLogin;
我们看到在 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 ); // 打开登录浮层之后上报数据
document.getElementById( 'button' ).onclick = showLogin;
我们看到在 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 ); // 打开登录浮层之后上报数据
document.getElementById( 'button' ).onclick = showLogin;
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 接收的参数列
表自然也会变化。
下面的例子展示了如何通过 Function.prototype.before 方法给函数 func 的参数 param 动态地
添加属性 b :
var func = function( param ){
console.log( param ); // 输出: {a: "a", b: "b"}
}
func = func.before( function( param ){
param.b = 'b';
});
func( {a: 'a'} );
现在有一个用于发起 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 参数。
.
var getToken = function(){
return 'Token';
}
现在的任务是给每个 ajax请求都加上 Token 参数:
var ajax = function( type, url, param ){
param = param || {};
Param.Token = getToken(); // 发送 ajax 请求的代码略...
};
虽然已经解决了问题,但我们的 ajax 函数相对变得僵硬了,每个从 ajax 函数里发出的请求
都自动带上了 Token 参数,虽然在现在的项目中没有什么问题,但如果将来把这个函数移植到其
他项目上,或者把它放到一个开源库中供其他人使用, Token 参数都将是多余的。
也许另一个项目不需要验证 Token ,或者是 Token 的生成方式不同,无论是哪种情况,都必
须重新修改 ajax 函数。
为了解决这个问题,先把 ajax 函数还原成一个干净的函数:
var ajax= function( type, url, param ){
console.log(param); // 发送 ajax 请求的代码略
};
然后把 Token 参数通过 Function.prototyte.before 装饰到 ajax 函数的参数 param 对象中:
var getToken = function(){
return 'Token';
}
ajax = ajax.before(function( type, url, param ){
param.Token = getToken();
});
ajax( 'get', 'http:// xxx.com/userinfo', { name: 'sven' } );
从 ajax 函数打印的 log可以看到, Token 参数已经被附加到了 ajax 请求的参数中:
{name: "sven", Token: "Token"}
明显可以看到,用 AOP 的方式给 ajax 函数动态装饰上 Token 参数,保证了 ajax 函数是一
个相对纯净的函数,提高了 ajax 函数的可复用性,它在被迁往其他项目的时候,不需要做任何
修改。
6.插件式的表单验证
我们很多人都写过许多表单验证的代码,在一个 Web 项目中,可能存在非常多的表单,如
注册、登录、修改用户信息等。在表单数据提交给后台之前,常常要做一些校验,比如登录的时
候需要验证用户名和密码是否为空,代码如下:
用户名:<input id="username" type="text"/>
密码: <input id="password" type="password"/>
<input id="submitBtn" type="button" value="提交">
var username = document.getElementById( 'username' ),
password = document.getElementById( 'password' ),
submitBtn = document.getElementById( 'submitBtn' );
var formSubmit = function(){
if ( username.value === '' ){
return alert ( '用户名不能为空' );
}
if ( password.value === '' ){
return alert ( '密码不能为空' );
}
var param = {
username: username.value,
password: password.value
}
ajax( 'http:// xxx.com/login', param ); // ajax 具体实现略
}
submitBtn.onclick = function(){
formSubmit();
}
formSubmit 函数在此处承担了两个职责,除了提交 ajax请求之外,还要验证用户输入的合法
性。这种代码一来会造成函数臃肿,职责混乱,二来谈不上任何可复用性。
本节的目的是分离校验输入和提交 ajax 请求的代码,我们把校验输入的逻辑放到 validata
函数中,并且约定当 validata 函数返回 false 的时候,表示校验未通过,代码如下:
var validata = function(){
if ( username.value === '' ){
alert ( '用户名不能为空' );
return false;
}
if ( password.value === '' ){
alert ( '密码不能为空' );
return false;
}
}
var formSubmit = function(){
if ( validata() === false ){ // 校验未通过
return;
}
var param = {
username: username.value,
password: password.value
}
ajax( 'http:// xxx.com/login', param );
}
submitBtn.onclick = function(){
formSubmit();
}
现在的代码已经有了一些改进,我们把校验的逻辑都放到了 validata 函数中,但 formSubmit
函数的内部还要计算 validata 函数的返回值,因为返回值的结果表明了是否通过校验。
接下来进一步优化这段代码,使 validata 和 formSubmit 完全分离开来。首先要改写 Function.
prototype.before , 如果 beforefn 的执行结果返回 false ,表示不再执行后面的原函数,代码如下:
Function.prototype.before = function( beforefn ){
var __self = this;
return function(){
if ( beforefn.apply( this, arguments ) === false ){
// beforefn 返回 false 的情况直接 return,不再执行后面的原函数
return;
}
return __self.apply( this, arguments );
}
}
var validata = function(){
if ( username.value === '' ){
alert ( '用户名不能为空' );
return false;
}
if ( password.value === '' ){
alert ( '密码不能为空' );
return false;
}
}
formSubmit = formSubmit.before( validata );
submitBtn.onclick = function(){
formSubmit();
}
在这段代码中,校验输入和提交表单的代码完全分离开来,它们不再有任何耦合关系,
formSubmit = formSubmit.before( validata ) 这句代码,如同把校验规则动态接在 formSubmit 函数
之前, validata 成为一个即插即用的函数,它甚至可以被写成配置文件的形式,这有利于我们分
开维护这两个函数。再利用策略模式稍加改造,我们就可以把这些校验规则都写成插件的形式,
用在不同的项目当中。
var func = function(){
alert( 1 );
}
func.a = 'a';
func = func.after( function(){
alert( 2 );
});
alert ( func.a ); // 输出:undefined
另外,这种装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些
影响。
7 装饰者模式和代理模式
装饰者模式和
代理模式的结构看起来非常相像,这两种模式都描述了怎样为对象提供
一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送
请求。
代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。代理模式的目的是,当直
接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代
理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对
象动态加入行为。换句话说,代理模式强调一种关系(Proxy与它的实体之间的关系),这种关系
可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确
定对象的全部功能时。代理模式通常只有一层代理本体的引用,而装饰者模式经常会形成一条
长长的装饰链。
在虚拟代理实现图片预加载的例子中,本体负责设置 img 节点的 src,代理则提供了预加载
的功能,这看起来也是“加入行为”的一种方式,但这种加入行为的方式和装饰者模式的偏重点
是不一样的。装饰者模式是实实在在的为对象增加新的职责和行为,而代理做的事情还是跟本体
一样,最终都是设置 src。但代理可以加入一些“聪明”的功能,比如在图片真正加载好之前,
先使用一张占位的 loading图片反馈给客户。
小结
通过数据上报、统计函数的执行时间、动态改变函数参数以及插件式的表单验证这 4个
例子,我们了解了装饰函数,它是 JavaScript中独特的装饰者模式。这种模式在实际开发中非常
有用,除了上面提到的例子,它在框架开发中也十分有用。作为框架作者,我们希望框架里的函
数提供的是一些稳定而方便移植的功能,那些个性化的功能可以在框架之外动态装饰上去,这可
以避免为了让框架拥有更多的功能,而去使用一些 if 、 else 语句预测用户的实际需要。