JavaScript版装饰者模式

本文是基于JavaScript的装饰者模式
由浅入深介绍JavaScript的装饰者模式,以及与传统面向对象语言的装饰者模式的区别。
包含大量代码示例以及注释
内容包括

  • 为什么需要装饰者模式
  • 传统面向对象的装饰者模式
  • JavaScript的装饰者
  • 装饰函数
  • 用AOP装饰函数
  • AOP的应用实例
  • 装饰者模式和代理模式的区别

为什么需要装饰者模式?

需要给对象动态添加职责

  • 天冷添衣
  • 需要飞行就加一双翅膀

即需要对对象进行拓展,传统的方式存在的缺点

  • 继承的方式对对象进行拓展
    • 这样会有较高的耦合性:当父类改变时,子类也跟着改变
    • 继承也称为”白箱复用”,即父类中的细节在子类中是可见的,破坏了封装性
    • 子类数量的爆炸
      • 例如,4辆自行车需要4个类,每辆自行车都需要前灯、后灯、铃铛,则创建子类的数量为4*3=12
      • 而如果是动态添加到自行车上只需要3个类

传统面向对象的装饰者模式

JS作为一门解释性语言,要给对象动态添加属性非常容易。

  • 虽然改动了对象本身,与传统面向对象的装饰者模式不一样,但更符合JS的语言特色
1
2
3
4
5
var obj = {
name: 'hh',
address: '广东省'
};
obj.address = obj.address + '广州市'; //改动了对象本身的属性

JS模拟传统面向对象的装饰者模式

假设我们在编写一个飞机大战的游戏,随着经验值的增加,我们操作的飞机对象可以升级成更厉害的飞机,一开始这些飞机只能发射普通的子弹,升到第二级时可以发射导弹,升到第三级时可以发射原子弹。

1
2
3
4
5
6
// 建立一架飞机
var Plane = function(){}
// 飞机有发射普通子弹的方法
Plane.prototype.fire = function(){
console.log( '发射普通子弹' );
}

装饰类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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( '发射原子弹' );// 拓展发射子弹方法
}

测试

1
2
3
4
5
var plane = new Plane();
plane = new MissileDecorator( plane );
plane = new AtomDecorator( plane );
plane.fire();
// 分别输出: 发射普通子弹、发射导弹、发射原子弹

image-20211116151638840

说明:

导弹类和原子弹类的构造函数保存plane对象

  • 在他们的fire方法中调用plane参数的fire方法,并进行扩展

这种方式动态给对象添加职责,并没有改变对象本身,而是将对象放入另一个对象中,对象以一条链的方式调用,形成一个聚合对象

  • 这些聚合对象必须提供统一的接口(上面例子中的fire方法)
    • 对使用这些对象的客户来说聚合则是透明的,不需要了解对象是否被装饰过

JavaScript的装饰者

JS并不需要用类来实现装饰者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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();
// 分别输出: 发射普通子弹、发射导弹、发射原子弹

装饰函数

JS中,几乎一切都是对象,函数也是对象。

  • 很容易给对象动态添加属性和方法,但很难在不改动源码的情况下给函数加一些额外的功能。

若直接改写函数,则违背开闭原则

1
2
3
4
5
6
7
8
var a = function(){
alert (1);
}
// 改成:
var a = function(){
alert (1);
alert (2);
}

很多时候我们都不希望去改动原有的函数,改动后可能发生意想不到的影响。

  • 可以通过保存原引用的方式对函数进行拓展
1
2
3
4
5
6
7
8
9
10
11
var a = function(){
alert (1);
}

var _a = a; // 保存原引用
a = function(){ //覆盖原来的函数
_a(); //调用原来的函数
alert (2); //进行拓展
}

a();

保存原引用的方式是开发中比较常见的方式

例如,我们想给window添加onload事件,但不确定这个事件是否已经被其他人绑定过,为了避免覆盖别人的window.onload,我们一般都会采用以下的方法

1
2
3
4
5
var _onload = window.onload || function(){}; //保存原来的方法,若无则用默认函数
window.onload = function(){
_onload();
alert (2); //拓展
}

以上方式符合开闭原则,但存在两个问题

  • 必须维护中间变量,例如上面的_onload
    • 装饰链越长,保存的变量就会越多,装饰函数也越多
    • 还可能发生this劫持(预期的this是a,结果在调用时变成了b)
      • 上面的onload没有,因为调用_onload()时,this也是指向window
1
2
3
4
5
6
var _getElementById = document.getElementById; // _getElementById是一个全局函数,当调用一个全局函数时,this是指向window
document.getElementById = function(id){
alert (1); // document.getElementById方法的内部实现需要使用this引用,this在这个方法内预期是指向document
return _getElementById(id); // 相当于window._getElementById(id)
}
var button = document.getElementById( 'button' );// 输出: Uncaught TypeError: Illegal invocation
1
2
3
4
5
6
7
// 对上面代码进行改进
var _getElementById = document.getElementById;
document.getElementById = function(){
alert (1);
return _getElementById.apply( document, arguments ); // this绑定
}
var button = document.getElementById( 'button' );

这样做显然很不方便,下面我们引入AOP,来提供一种完美的方法给函数动态增加功能

用AOP装饰函数

AOP(Aspect Oriented Programming):面向切面编程

  • 通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术
  • 利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低提高程序的可重用性,同时提高了开发的效率

(百度百科)

这里就不赘述

定义两个方法:

  • Function.prototype.before方法
    • 在原函数执行前执行
    • 接受一个函数当作参数,这个函数即为新添加的函数,它装载了新添加的功能代码
  • Function.prototype.after方法
    • 在原函数执行后执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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; // this 指向原函数(详情见下面示例)
return function(){
var ret = __self.apply( this, arguments ); // this 指向调用原函数的对象
afterfn.apply( this, arguments );
return ret;
}
};

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<html>
<button id="button"></button>
<script>
Function.prototype.before = function( beforefn ){
var __self = this; // this指向调用after的函数,即原函数
return function(){
beforefn.apply( this, arguments ); // this指向原函数的this
return __self.apply( this, arguments );
}
}
document.getElementById = document.getElementById.before(function(){ // this为document.getElementById,即原函数
alert (1);
});
var button = document.getElementById( 'button' ); // 调用的是装饰后的函数,this为document,即原函数的对象 console.log( button );
</script>
</html>

上面的onload也用该方法

1
2
3
4
5
6
7
8
9
10
window.onload = function(){    
alert (1);
}
window.onload = ( window.onload || function(){} ).after(function(){
alert (2);
}).after(function(){ //返回的函数可以继续进行装饰
alert (3);
}).after(function(){
alert (4);
});

上面的AOP方法会污染原型,因此许多人不喜欢这样做

可以做以下修改

  • 把原函数和新函数都作为参数传入before或者after方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var before = function( fn, beforefn ){    
return function(){
beforefn.apply( this, arguments ); //保证this不被修改
return fn.apply( this, arguments );
}
}
var a = before(
function(){
alert (3)
},
function(){
alert (2)
});
a = before( a, function(){alert (1);} );a();

AOP的应用实例

用AOP装饰函数的技巧在实际开发中非常有用。

不论是业务代码的编写,还是在框架层面,我们都可以把行为依照职责分成粒度更细的函数,随后通过装饰把它们合并到一起,这有助于我们编写一个松耦合高复用性的系统。

数据统计上报

分离业务代码和数据统计代码,无论在什么语言中,都是AOP的经典应用之一。

在项目开发的结尾阶段难免要加上很多统计数据的代码,这些过程可能让我们被迫改动早已封装好的函数。

比如页面中有一个登录button,点击这个button会弹出登录浮层,与此同时要进行数据上报,来统计有多少用户点击了这个登录button

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<html>
<button tag="login" id="button">点击打开登录浮层</button>
<script>
var showLogin = function(){ //一开始写完的登录代码
console.log( '打开登录浮层' );
// 登录逻辑

log( this.getAttribute( 'tag' ) ); // 后续添加的统计上报代码,修改了原函数
}

var log = function( tag ){
console.log( '上报标签为: ' + tag );
// 真正的上报代码
}
document.getElementById( 'button' ).onclick = showLogin;
</script>
</html>

在showLogin函数里,既要负责打开登录浮层,又要负责数据上报,这是两个层面的功能,在此处却被耦合在一个函数里。

对以上代码进行AOP分离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<html>
<button tag="login" id="button">点击打开登录浮层</button>
<script>
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;
</script>
</html>

用AOP动态改变函数的参数

1
2
3
4
5
6
7
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接收的参数列表自然也会变化。

  • 共用arguments
1
2
3
4
5
6
7
var func = function( param ){
console.log( param );
}
func = func.before( function( param ){
param.b = 'b';
});
func( {a: 'a'} ); // 传入的对象是{a: 'a'},AOP给对象动态添加了属性b,所以输出: {a: "a", b: "b"}

示例

有一个用于发起ajax请求的函数,这个函数负责项目中所有的ajax异步请求

1
2
3
4
var ajax = function( type, url, param ){
// 发送ajax请求的代码
};
ajax( 'get', 'http:// xxx.com/userinfo', { name: 'hh' } );

突然有一天,我们网站受到了CSRF攻击

  • 解决CSRF攻击最简单的一个办法就是在HTTP请求中带上一个Token参数

于是,写一个用于生成Token的函数

1
2
3
var getToken = function(){
return 'Token';
}

给每个ajax请求都加上Token参数

1
2
3
4
5
var ajax = function( type, url, param ){
param = param || {};
Param.Token = getToken();
// 发送ajax请求的代码
};

出现一些问题:ajax函数相对变得僵硬

  • 每个从ajax函数里发出的请求都自动带上了Token参数,虽然在现在的项目中没有什么问题,但如果将来把这个函数移植到其他项目上,或者把它放到一个开源库中供其他人使用,Token参数都将是多余的
  • 也许另一个项目不需要验证Token,或者是Token的生成方式不同

上面无论是哪种情况,都必须重新修改ajax函数

为了解决这个问题,先把ajax函数还原成一个干净的函数

1
2
3
var ajax= function( type, url, param ){
// 发送ajax请求的代码
};

把Token参数通过Function.prototyte.before装饰到ajax函数的参数param对象中

1
2
3
4
5
6
7
var getToken = function(){
return 'Token';
}
ajax = ajax.before(function( type, url, param ){
param.Token = getToken();
});
ajax( 'get', 'http:// xxx.com/userinfo', { name: 'hh' } );

用AOP的方式给ajax函数动态装饰上Token参数,保证了ajax函数是一个相对纯净的函数,提高了ajax函数的可复用性,它在被迁往其他项目的时候,不需要做任何修改

区分装饰者模式和代理模式

装饰者模式和代理模式的结构看起来非常相像,这两种模式都描述了怎样为对象提供一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送请求。

代理模式和装饰者模式最重要的区别在于它们的意图和设计目的。

  • 代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。
    • 换句话说,代理模式强调一种关系(Proxy与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。
  • 装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条长长的装饰链。

在虚拟代理实现图片预加载的例子中,本体负责设置img节点的src,代理则提供了预加载的功能,这看起来也是“加入行为”的一种方式,但这种加入行为的方式和装饰者模式的偏重点是不一样的。装饰者模式是实实在在的为对象增加新的职责和行为,而代理做的事情还是跟本体一样,最终都是设置src。但代理可以加入一些“聪明”的功能,比如在图片真正加载好之前,先使用一张占位的loading图片反馈给客户。

最后

通过数据上报、统计函数的执行时间、动态改变函数参数例子,我们了解了装饰函数,它是JavaScript中独特的装饰者模式。这种模式在实际开发中非常有用,除了上面提到的例子,它在框架开发中也十分有用。作为框架作者,我们希望框架里的函数提供的是一些稳定而方便移植的功能,那些个性化的功能可以在框架之外动态装饰上去,这可以避免为了让框架拥有更多的功能,而去使用 一些if、else语句预测用户的实际需要。

参考