第十五章 装饰者模式
在程序开发中,许多时候并不希望某个类天生就非常庞大,一次性包含许多职责。那么我们可以使用装饰者模式。装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。
在传统的面向对象语言中,给对象添加功能常常使用继承的方式,但是继承的方式并不是很灵活,还会带来许多问题:
- 导致超类和子类之间存在强耦合性,当超类改变时,子类也会改变。
- 继承这种功能的复用方式被称为白箱复用,白箱是相对可见性而言的,在继承方式中,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性
- 在完成一些功能复用的同时,有可能创建出大量的子类。比如现在有 4 种型号的自行车,我们给每种自行车都定义一个单独的类。现在要给每种自行车都装上前灯、尾灯和铃铛这三种配件。如果使用继承的方式来给每种自行车创建子类,则需要 4x3=12 个子类。但是如果把前灯、尾灯、铃铛这些对象动态组合到自行车上,则只需要额外的 3 个类。
这种给对象动态添加职责的方式被称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。跟继承相比,装饰者是一种更轻便灵活的做法。
15.1 模拟传统面向对象语言的装饰者模式
作为一门解释执行的语言,给 JavaScript 的对象动态添加或者改变职责是一件再简单不过的事情。比如以下代码
js1 var obj = { 2 name: 'sven', 3 address: ’深圳市’ 4 }; 5 6 obj.address = obj.address + ’福田区’;
传统面向对象语言中的装饰者模式在 JavaScript 中适用的场景并不多,下面我们模拟一下传统面向对象语言中的装饰者模式。
假设我们目前有一架飞机类,它的原始形态是这样的,可以发射普通子弹
js1var Plane = function () {} 2Plane.prototype.fire = function () { 3 console.log('发射普通子弹') 4} 5/*下面是class写法 6 7class Plane { 8 constructor() {} 9 fire() { 10 console.log('发射普通子弹') 11 } 12} 13 14 */
下面增加两个装饰类,分别是导弹和原子弹
js1var MissileDecorator = function (plane) { 2 this.plane = plane 3} 4MissileDecorator.prototype.fire = function () { 5 this.plane.fire() 6 console.log('发射导弹') 7} 8 9var AtomDecorator = function (plane) { 10 this.plane = plane 11} 12MissileDecorator.prototype.fire = function () { 13 this.plane.fire() 14 console.log('发射原子弹') 15} 16 17/* 下面是class写法 18class MissileDecorator { 19 constructor(plane) { 20 this.plane = plane; 21 } 22 fire() { 23 this.plane.fire() 24 console.log("发射导弹"); 25 } 26} 27class AtomDecorator { 28 constructor(plane) { 29 this.plane = plane; 30 } 31 fire() { 32 this.plane.fire() 33 console.log("发射原子弹"); 34 } 35} 36*/
导弹类和原子弹类的构造函数都接受参数 plane 对象,并且保存好这个参数,在它们的 fire 方法中,除了自身的操作之外,还调用了 plane 对象的 fire 方法。
这种对象动态增加职责的方式,并没有真正改动对象自身,而是将对象放入另一个对象中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口方法,当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的下一个对象。
因为装饰者对象和它所装饰的对象拥有一致的接口,所以他们对使用该对象的用户来说是透明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意多个装饰者对象。
js1const plane = new Plane() 2const missile = new MissileDecorator(plane) 3const atom = new AtomDecorator(missile) 4atom.fire() 5// 发射普通子弹 6// 发射导弹 7// 发射原子弹
15.2 装饰者也是包装器
在《设计模式》成书之前,GoF 想把装饰者模式(decorator)称为包装器(wrapper)模式。
从功能上而言,decorator 能很好地描述这个模式,但从结构来看,wrapper 的说法更加贴切。
装饰者模式将一个对象嵌入到另一个对象中,实际上相当于这个对象被另一个对象包装起来,形成一条包装链,请求随着这条链依次传递到所有的对象,每个对象都有处理这条请求的机会。
15.3 回到 JavaScript 的装饰者
JavaScript 语言修改对象相当容易,我们并不需要类来实现装饰者模式,上面的代码我们可以这样改:
js1var plane = { 2 fire() { 3 console.log('发射普通子弹') 4 }, 5} 6var missileDecorator = function () { 7 console.log('发射导弹') 8} 9var atomDecorator = function () { 10 console.log('发射原子弹') 11} 12const fire1 = plane.fire 13plane.fire = function () { 14 fire1() 15 missileDecorator() 16} 17const fire2 = plane.fire 18plane.fire = function () { 19 fire2() 20 atomDecorator() 21} 22plane.fire() 23// 发射普通子弹 24// 发射导弹 25// 发射原子弹
15.4 装饰函数
在 JavaScript 中,几乎一切都是对象,其中函数又被称为一等对象。我们可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。在代码运行期间,我们很难切入某个函数的执行环境。
要想为函数添加一些功能,最简单粗暴的方式就是直接改写该函数,但这是最差的方法,直接违反了开放-封闭原则:
js1var a = function () { 2 console.log(1) 3} 4//给这个函数添加额外的功能,需要直接修改源代码 5var a = function () { 6 console.log(0) 7 console.log(1) 8}
很多时候我们不能直接修改原函数,因为你不知道原函数的复杂实现,直接修改可能会造成程序出错。
在不改变原函数的情况下,我们需要给函数增加功能,这符合开放-封闭原则。
开发中很常见的方案是保存原引用来改写某个函数:
js1var a = function () { 2 console.log(1) 3} 4var _a = a 5a = function () { 6 console.log(0) 7 _a() 8}
比如我们希望给 window 绑定 onload 事件,但是又不确定是否被其他人绑定过,为了避免覆盖之前的 window.onload 函数中的行为,我们一般都会先保存好 window.onload,把它放在新的 window.onload 执行。
js1window.onload = function () { 2 console.log(1) 3} 4const _onload = window.onload || function () {} //给个保底 5window.onload = function () { 6 _onload() 7 console.log(2) 8}
虽然符合开放-封闭原则,因为我们没有修改原来的代码,但这种方式可能存在以下问题:
-
必须维护
_onload这个变量,而且如果需要装饰的函数越来越多,这些中间变量数量会增多 -
this 绑定可能被劫持问题
下面的例子很好地说明 this 会被劫持
js1const btn = document.querySelector('#btn') 2btn.onclick = function () { 3 console.log(this.id) 4} 5const _onclick = btn.onclick 6btn.onclick = function () { 7 _onclick() //undefined 8 console.log(2) 9}当 btn.onclick 被变量
_onclick所获取时,this 会丢失,此时如果调用_onclick方法,this 会默认指向 window。我们在调用时手动绑定上 this 就可以了
js1btn.onclick = function () { 2 _onclick.call(this) // btn 3 console.log(2) 4}这样做显然不够方便,下面我们引入 AOP 来给函数动态添加功能。
15.5 用 AOP 装饰函数
下面是 AOP 常用函数
js1Function.prototype.before = function (beforeFn) { 2 const _self = this 3 return function (...rest) { 4 beforeFn.call(this, ...rest) 5 return _self.call(this, ...rest) 6 } 7} 8 9Function.prototype.after = function (afterFn) { 10 const _self = this 11 return function (...rest) { 12 _self.call(this, ...rest) 13 return afterFn.call(this, ...rest) 14 } 15} 16function fn1() { 17 console.log(1) 18} 19function fn2() { 20 console.log(2) 21} 22function fn3() { 23 console.log(3) 24} 25const fn = fn1.before(fn2).after(fn3) 26fn() 27// 2 1 3
Function.prototype.before接受一个函数作为参数,这个函数即为新添加的函数,它装载了新添加的功能代码。
接下来把当前的 this 保存起来,这个 this 指向原函数。然后返回一个代理函数,这个代理函数只是结构上像代理而已,并不承担代理的职责(控制对象的访问)。它的工作是把请求分别转发给新添加的函数和原函数,且负责他们之间的执行顺序,让新添加的函数可以在原函数前执行(前置装饰),这样就实现了动态装饰的效果。
通过call来动态绑定 this,确保 this 不会被劫持。
Function.prototype.after的原理跟Function.prototype.before差不多,唯一的区别就是新添加的函数在原函数之后执行。
现在我们用 AOP 的方式来给函数动态添加职责
js1const btn = document.querySelector('#btn') 2btn.onclick = function () { 3 console.log(this.id) 4} 5 .before(function () { 6 console.log('before') 7 console.log(this.id) 8 }) 9 .after(function () { 10 console.log('after') 11 console.log(this.id) 12 }) 13// before btn btn after btn
还可以这么玩
js1document.querySelector = document.querySelector 2 .before(function () { 3 console.log('before') 4 }) 5 .after(function () { 6 console.log('after') 7 }) 8const btn = document.querySelector('#btn') // before after
如果我们不希望在 Function.prototype 添加 before 和 after 函数的话,我们可以将 before 封装成一个函数。
js1var before = function (beforefn, fn) { 2 return function () { 3 beforefn.apply(this, arguments) 4 return fn.apply(this, arguments) 5 } 6} 7var fn = before( 8 function () { 9 console.log(2) 10 }, 11 function () { 12 console.log(3) 13 }, 14) 15fn = before(function () { 16 console.log(1) 17}, fn) 18fn() // 1 2 3
15.6 AOP 的应用
用 AOP 装饰函数的技巧在实际开发中非常有用。我们可以把行为依照职责分成颗粒度更细的函数,随后通过装饰将它们合并到一起,这有助于我们编写一个松耦合和高复用性的系统。
15.6.1 数据统计上报
分离业务代码和数据统计代码,无论在什么语言中,都是 AOP 经典的应用之一。在项目开发的结尾阶段难免要加上很多统计数据的代码,这些过程可能让我们被迫改动早已封装好的函数。
下面是一个点击 button 弹出登录框,并且发送统计数据的场景。
html1<button id="btn">上报</button> 2<script> 3 const btn = document.querySelector('#btn') 4 function log() { 5 console.log('上报数据') 6 } 7 function showLogin() { 8 console.log('弹出登录框') 9 log() 10 } 11 btn.onclick = showLogin 12</script>
我们可以看到 showLogin 中,又要打开登录框,又要上报数据,这是两个层面的功能,但是却耦合在一个函数里。使用 AOP 分离后,代码更加清晰。
js1const btn = document.querySelector('#btn') 2function log() { 3 console.log('上报数据') 4} 5function showLogin() { 6 console.log('弹出登录框') 7 log() 8} 9//此处省略Function.prototype.after的代码 10btn.onclick = showLogin.after(log)
15.6.2 用 AOP 动态改变函数的参数
我们的 AOP 函数可以共享原函数的 arguments:
js1Function.prototype.before = function (beforeFn) { 2 const _self = this 3 return function (...rest) { 4 //这里的rest可以被共享 5 beforeFn.call(this, ...rest) 6 return _self.call(this, ...rest) 7 } 8}
当我们在 beforeFn 中动态修改参数,那么_self函数中的参数也会发生变化:
js1function log(params) { 2 console.log(params) 3} 4const log2 = log.before(function (params) { 5 params.push('b') 6}) 7log2(['a']) // ['a','b']
假设现在有一个可以发起 ajax 请求的函数,这个函数负责项目中所有的 ajax 请求
js1var ajax = function (type, url, param) { 2 console.dir(param) 3 // 发送ajax请求的代码略 4} 5 6ajax('get', 'http://xxx.com/userinfo', { name: 'sven' })
上面的伪代码表示向后台发送一个{name:'sven'}的数据。
现在假设在某种情况下,我在动用 ajax 函数时,需要仅限于在某个小模块中额外向服务器发送 token,获取 token 的函数我们已经有了
js1function getToken() { 2 return 'token' 3}
如果此时动了 ajax 函数的源代码,则违反开发-封闭原则,而且代码会非常丑陋,比如:
js1 var ajax = function( type, url, param ){ 2 console.dir(param); 3 //如果存在于某个特定模块中,则需要在param中添加token 4 if(){ 5 param.token=getToken() 6 } 7 // 发送ajax请求的代码略 8 };
此时只是加了一个 if 判断,但是随着项目需求的增多,我们可能需要在 ajax 函数中做大量的逻辑判断,使其变得非常臃肿。如果将来将这个函数移植到其他项目中,或者放到开源库中供其他人使用,token 参数将非常多余,显然,ajax 函数的复用性变差了。
为了解决这些问题,我们不要修改 ajax 函数的源代码,我们只需要在特定的模块中给 ajax 绑定上 before 装饰函数,在 before 上面修改参数:
js1function getToken() { 2 return 'token' 3} 4var ajax = function (type, url, param) { 5 console.dir(param) 6 // 发送ajax请求的代码略 7} 8const spacialAjax = ajax.before(function (type, url, param) { 9 param.token = getToken() 10}) 11spacialAjax('get', 'http://xxx.com/userinfo', { name: 'sven' }) 12// {name:'sven',token:'token'}
明显可以看到,用 AOP 的方式给 ajax 函数动态装饰上 Token 参数,保证了 ajax 函数是一个纯净的函数,而且还可以复用到其他项目中,并不需要做任何修改。
15.6.3 插件式的表单验证
我们经常在表单提交数据前做一些校验,比如验证用户输入账号密码是否合法。
js1const username = document.querySelector('#input1') 2const password = document.querySelector('#input2') 3function submit() { 4 if (username.value === '') { 5 alert('验证失败了') 6 return false 7 } 8 if (password.value === '') { 9 alert('验证失败了') 10 return false 11 } 12 //省略提交逻辑 13 console.log('提交了') 14}
此处的 submit 函数做了两个不同的逻辑,分别是校验用户输入是否合法和提交 ajax 请求给服务器。
这种代码会造成函数臃肿,职责混乱,复用性差。
我们将校验用户输入跟提交 ajax 的函数进行分离,校验用户输入的操作放在 validator 函数中,提交 ajax 请求的逻辑留在 submit 中。
js1function submit() { 2 //省略提交逻辑 3 console.log('提交了') 4} 5function validator() { 6 if (username.value === '') { 7 alert('验证失败了') 8 return false 9 } 10 if (password.value === '') { 11 alert('验证失败了') 12 return false 13 } 14 return true 15}
上面的代码约定当验证成功时,validator 返回 true,那么 submit 可以提交。
此时我们需要改写一下 Function.prototype.before 函数
js1Function.prototype.before = function (beforeFn) { 2 const _self = this 3 return function (...rest) { 4 const result = beforeFn.call(this, ...rest) 5 //当beforeFn返回的结果为真时才执行后续函数 6 if (result) return _self.call(this, ...rest) 7 } 8}
然后使用 AOP 将 submit 函数与 validator 函数进行组合,就可以将逻辑分离开来
js1btn.onclick = submit.before(validator)
现在我们的提交与验证代码没有任何耦合关系,校验规则被动态地接在提交 ajax 请求之前,大大提升了 validator 函数与 submit 两个函数的复用性,我们可以将其用到不同的项目中。
15.7 装饰器模式和代理模式
装饰器模式跟代理模式非常像,这两种模式都描述了怎样为对象提供一定程度的间接引用,它们的实现部分都保留了对另一个对象的引用,并且向那个对象发送请求。
代理模式和装饰器模式最重要的区别在于它们的意图和设计目的。
代理模式的目的是,当直接访问本体不方便时或者不符合访问本体的条件时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问之前可以做一些额外的事情。代理强调的是一种关系(proxy 和实体之间的关系),这种关系一开始就被确定。
装饰器模式的作用就是为对象动态添加行为。它用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰器通常会形成一条长长的装饰链条。
在虚拟代理实现图片预加载的例子中,本体负责设置 img 节点的 src,代理则提供了预加载的功能,这看起来也是“加入行为”的一种方式,但这种加入行为的方式和装饰者模式的偏重点是不一样的。装饰者模式是实实在在的为对象增加新的职责和行为,而代理做的事情还是跟本体一样,最终都是设置 src。但代理可以加入一些“聪明”的功能,比如在图片真正加载好之前,先使用一张占位的 loading 图片反馈给客户。
15.8 小结
装饰函数是 JavaScript 中独特的装饰者模式,这种模式在实际开发中非常有用。我们可以在不修改原函数的情况下动态地添加一些个性的功能。