参考资料
- 曾探《JavaScript设计模式与开发实践》;
- JavaScript 设计模式之装饰者模式
- javascript 设计模式之装饰者模式
定义
装饰者模式(Decorator Pattern)是一种结构型设计模式,给对象动态地增加职责的方式称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。跟继承相比,装饰者是一种更轻便灵活的做法。
装饰者是一种“即付即用”的方式,比如天冷了就多穿一件外套。
在前端中,装饰者模式经常被用于扩展或修改组件的行为或样式。
装饰者模式有以下特点:
- 添加功能时不改变原对象结构。
- 装饰对象和原对象提供的接口相同,方便按照源对象的接口来使用装饰对象。
- 装饰对象中包含原对象的引用。即装饰对象是真正的原对象包装后的对象。
实际上,装饰着模式的一个比较方便的特征在于其预期行为的可定制和可配置特性。从只有基本功能的普通对象开始,不断增强对象的一些功能,并按照顺序进行装饰。
使用场景:
- 添加日志;
- 数据统计上报;
- ajax请求增加token、参数;
- 插件式的表单验证;
为啥函数需要装饰者模式
在开发过程中,想要为函数添加一些功能,最简单粗暴的方式就是直接修改该函数,但是这是最不好的方法,直接违反了开放-封闭原则。
很多时候我们也不想去修改原函数,只因原函数是其他同事开发的,里面的逻辑复杂无比,怕修改出问题来,根据开放-封闭原则一般这么解决这类问题。
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();
// 分别输出: 发射普通子弹、发射导弹、发射原子弹
装饰函数
在 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(a);
}
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
时一样。
用 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
来增加新的 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();
AOP 的应用实例
用 AOP 装饰函数的技巧在实际开发中非常有用。不论是业务代码的编写,还是在框架层面,我们都可以把行为依照职责分成粒度更细的函数,随后通过装饰把它们合并到一起,这有助于我们编写一个松耦合和高复用性的系统。
数据统计上报
分离业务代码和数据统计代码,无论在什么语言中,都是 AOP 的经典应用之一。 比如页面中有一个登录 button
,点击这个 button
会弹出登录浮层,与此同时要进行数据上报,来统计有多少用户点击了这个登录 button
:
<html>
<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:// xxx.com/report?tag=' + tag; // 真正的上报代码略
}
document.getElementById('button').onclick = showLogin;
</script>
</html>
我们看到在 showLogin
函数里,既要负责打开登录浮层,又要负责数据上报,这是两个层面的功能,在此处却被耦合在一个函数里。使用 AOP 分离之后,代码如下:
<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动态改变函数的参数
我们需要给 ajax
请求增加一个 token
。现在有一个用于生成 token
的函数:
var getToken = function () {
return 'Token'
}
现在我们给每个 ajax
请求上加上 token
参数:
var ajax = function(type, url, param) {
param = param || {}
param.token = getToken
// 发送 ajax 的代码略……
}
虽然已经解决了问题,但我们的 ajax
函数相对变得僵硬了,每个从 ajax 函数里发出的请求都自动带上了 token
参数,虽然在现在的项目中没有什么问题,但如果将来把这个函数移植到其他项目上,或者把它放到一个开源库中供其他人使用,token
参数都将是多余的。
为了解决这个问题,先把 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
函数的可复用性,它在被迁往其他项目的时候,不需要做任何修改。
插件式的表单验证
我们很多人都写过许多表单验证的代码,在一个 Web 项目中,可能存在非常多的表单,如注册、登录、修改用户信息等。在表单数据提交给后台之前,常常要做一些校验,比如登录的时候需要验证用户名和密码是否为空。
我们现在要做的是分离校验输入和提交 ajax
请求的代码,要使 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
}
}
var formSubmit = function () {
var param = {
username: username.value,
password: password.value
}
ajax('http://xxx.com/login', param)
}
formSubmit = formSubmit.before(validata)
submitBtn.onclick = function () {
formSubmit()
}
值得注意的是,因为函数通过 Function.prototype.before
或者 Function.prototype.after
被装饰之后,返回的实际上是一个新的函数,如果在原函数上保存了一些属性,那么这些属性会丢失。 代码如下:
var func = function(){
alert( 1 );
}
func.a = 'a';
func = func.after( function(){ alert( 2 ) });
alert ( func.a ); // 输出:undefined
另外,这种装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些影响。
ES7 中的装饰器
装饰类不带参数
// 装饰器函数,它的第一个参数是目标类
function classDecorator(target) {
target.hasAdd = true
// return target // 可有可无, 默认就是返回 this 的
}
// 将装饰器"安装"到 Button 类上
@classDecorator
class Button {}
// 验证装饰器是否生效
alert('Button 是否被装饰了:' + Button.hasAdd)
等价于
function classDecorator(target) {
target.hasAdd = true
return target // 此时一定要用, 因为这时是作为函数使用,而非构造函数
}
class Button {}
Button = classDecorator(Button)
// 验证装饰器是否生效
alert('Button 是否被装饰了:' + Button.hasAdd)
说明装饰器的原理:
@decorator
class A{}
//等同于
A = decorator(A) || A
表明 ES7 中的装饰器也是个语法糖
装饰类带参数
// 装饰器要接收参数时,就要返回个函数,该函数的第一个参数是目标类
function classDecorator(name) {
return function (target) {
target.btnName = name
}
}
// 将装饰器"安装"到 Button 类上
@classDecorator('登录')
class Button {}
// 验证装饰器是否生效
alert('按钮名称:' + Button.btnName)
等同于
// 装饰器要接收参数时,就要返回个函数,该函数的第一个参数是目标类
function classDecorator(name) {
return function (target) {
target.btnName = name
return target
}
}
// 将装饰器"安装"到 Button 类上
class Button {}
Button = classDecorator('登录')(Button)
// 验证装饰器是否生效
alert('按钮名称:' + Button.btnName)
装饰者模式和代理模式
装饰者模式和代理模式的结构看起来非常相像,这两种模式都描述了怎样为对象提供一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送请求。
代理模式和装饰者模式区别为:
- 它们的意图和设计目的。
- 代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。
- 装饰者模式的作用就是为对象动态加入行为。
- 代理模式强调一种关系(Proxy 与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。
- 代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条长长的装饰链。
优缺点
优点:
- 装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。
- 相比生成子类更为灵活。
缺点:
- 原函数的属性可能会丢失。
- 多层装饰比较复杂。如果装饰的链条很过长,性能上也会受到一些影响。
总结
通过上面的三个应场景:数据上报、动态改变函数参数以及表单校验,我们可以看到在JavaScript中,我们了解了装饰函数,了解了AOP,他们就是JavaScript中独特的装饰者模式,这种模式在实际开发中非常有用。