第十五章 装饰者模式

在程序开发中,许多时候并不希望某个类天生就非常庞大,一次性包含许多职责。那么我们可以使用装饰者模式。装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。

在传统的面向对象语言中,给对象添加功能常常使用继承的方式,但是继承的方式并不是很灵活,还会带来许多问题:

  • 导致超类和子类之间存在强耦合性,当超类改变时,子类也会改变。
  • 继承这种功能的复用方式被称为白箱复用,白箱是相对可见性而言的,在继承方式中,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性
  • 在完成一些功能复用的同时,有可能创建出大量的子类。比如现在有 4 种型号的自行车,我们给每种自行车都定义一个单独的类。现在要给每种自行车都装上前灯、尾灯和铃铛这三种配件。如果使用继承的方式来给每种自行车创建子类,则需要 4x3=12 个子类。但是如果把前灯、尾灯、铃铛这些对象动态组合到自行车上,则只需要额外的 3 个类。

这种给对象动态添加职责的方式被称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。跟继承相比,装饰者是一种更轻便灵活的做法。

15.1 模拟传统面向对象语言的装饰者模式

作为一门解释执行的语言,给 JavaScript 的对象动态添加或者改变职责是一件再简单不过的事情。比如以下代码

js
1        var obj = {
2              name: 'sven',
3              address: ’深圳市’
4         };
5
6         obj.address = obj.address + ’福田区’;

传统面向对象语言中的装饰者模式在 JavaScript 中适用的场景并不多,下面我们模拟一下传统面向对象语言中的装饰者模式。

假设我们目前有一架飞机类,它的原始形态是这样的,可以发射普通子弹

js
1var 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   */

下面增加两个装饰类,分别是导弹和原子弹

js
1var 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 方法。

这种对象动态增加职责的方式,并没有真正改动对象自身,而是将对象放入另一个对象中,这些对象以一条链的方式进行引用,形成一个聚合对象。这些对象都拥有相同的接口方法,当请求达到链中的某个对象时,这个对象会执行自身的操作,随后把请求转发给链中的下一个对象。

因为装饰者对象和它所装饰的对象拥有一致的接口,所以他们对使用该对象的用户来说是透明的,被装饰的对象也并不需要了解它曾经被装饰过,这种透明性使得我们可以递归地嵌套任意多个装饰者对象。

img
js
1const 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 语言修改对象相当容易,我们并不需要类来实现装饰者模式,上面的代码我们可以这样改:

js
1var 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 中,几乎一切都是对象,其中函数又被称为一等对象。我们可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。在代码运行期间,我们很难切入某个函数的执行环境。

要想为函数添加一些功能,最简单粗暴的方式就是直接改写该函数,但这是最差的方法,直接违反了开放-封闭原则:

js
1var a = function () {
2  console.log(1)
3}
4//给这个函数添加额外的功能,需要直接修改源代码
5var a = function () {
6  console.log(0)
7  console.log(1)
8}

很多时候我们不能直接修改原函数,因为你不知道原函数的复杂实现,直接修改可能会造成程序出错。

在不改变原函数的情况下,我们需要给函数增加功能,这符合开放-封闭原则。

开发中很常见的方案是保存原引用来改写某个函数:

js
1var 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 执行。

js
1window.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 会被劫持

    js
    1const 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 就可以了

    js
    1btn.onclick = function () {
    2  _onclick.call(this) // btn
    3  console.log(2)
    4}

    这样做显然不够方便,下面我们引入 AOP 来给函数动态添加功能。

15.5 用 AOP 装饰函数

下面是 AOP 常用函数

js
1Function.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 的方式来给函数动态添加职责

js
1const 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

还可以这么玩

js
1document.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 封装成一个函数。

js
1var 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 弹出登录框,并且发送统计数据的场景。

html
1<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 分离后,代码更加清晰。

js
1const 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:

js
1Function.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函数中的参数也会发生变化:

js
1function log(params) {
2  console.log(params)
3}
4const log2 = log.before(function (params) {
5  params.push('b')
6})
7log2(['a']) // ['a','b']

假设现在有一个可以发起 ajax 请求的函数,这个函数负责项目中所有的 ajax 请求

js
1var 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 的函数我们已经有了

js
1function getToken() {
2  return 'token'
3}

如果此时动了 ajax 函数的源代码,则违反开发-封闭原则,而且代码会非常丑陋,比如:

js
1        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 上面修改参数:

js
1function 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 插件式的表单验证

我们经常在表单提交数据前做一些校验,比如验证用户输入账号密码是否合法。

js
1const 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 中。

js
1function 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 函数

js
1Function.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 函数进行组合,就可以将逻辑分离开来

js
1btn.onclick = submit.before(validator)

现在我们的提交与验证代码没有任何耦合关系,校验规则被动态地接在提交 ajax 请求之前,大大提升了 validator 函数与 submit 两个函数的复用性,我们可以将其用到不同的项目中。

15.7 装饰器模式和代理模式

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

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

代理模式的目的是,当直接访问本体不方便时或者不符合访问本体的条件时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问之前可以做一些额外的事情。代理强调的是一种关系(proxy 和实体之间的关系),这种关系一开始就被确定。

装饰器模式的作用就是为对象动态添加行为。它用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰器通常会形成一条长长的装饰链条。

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

15.8 小结

装饰函数是 JavaScript 中独特的装饰者模式,这种模式在实际开发中非常有用。我们可以在不修改原函数的情况下动态地添加一些个性的功能。