JavaScript设计模式之装饰者模式

本文深入探讨JavaScript中的装饰者模式,介绍其在动态添加职责、数据上报、改变函数参数及表单验证的应用,以及与代理模式的区别。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

定义

装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响这个类中派生的其他对象。装饰模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责,跟继承相比,装饰者更加轻便灵活。

使用面向对象实现装饰者模式

假设我们编写一个飞机大战的游戏,飞机会根据经验值的增加升级子弹的类型,一开始飞机只能发射普通子弹,升到二级可以发射导弹,升到三级可以发射原子弹。用代码实现如下:

var Plane = function () {}

Plane.prototype.fire = function () {
  console.log('发射子弹')
}

var MissleDecorator = function (plane) {
  this.plane = plane
}

MissleDecorator.prototype.fire = function () {
  this.plane.fire()
  console.log('发射导弹')
}

var AtomDecorator = function (plane) {
  this.plane = plane
}

AtomDecorator.prototype.fire = function () {
  this.plane.fire()
  console.log('发射原子弹')
}

// 应用
let plane = new Plane()
plane = new MissleDecorator(plane)
plane = new AtomDecorator(plane)
plane.fire() // 发送普通子弹、发送导弹、发送原子弹
复制代码

导弹和原子弹装饰类的构造函数都接受plane对象,并且保存这个参数,在它们的fire方法中,除了自身的操作,还要调用plane对象的fire方法。这种方式没有改变plane对象的自身,而是将对象传递给另一个对象,这些对象以一条链的方式进行引用,形成聚合对象。
可以看到装饰者对象和它所装饰的对象拥有一致的接口,所以它们对使用该对象的客户来说是透明的,被装饰对象也不需要知道它曾经被装饰过,这种透明性使得我们可以嵌套任意多个装饰对象。

JavaScript中的装饰者

JavaScript语言的动态性使得改变对象很容易,我们可以直接改写对象或者某个对象的方法,并不需要用“类”来装饰,使用JavaScript实现上面例子的代码如下:

const plane = {
  fire () {
    console.log('发射子弹')
  }
}

const missleDecorator = function () {
  console.log('发射导弹')
}

const atomDecorator = function () {
  console.log('发射原子弹')
}

const copyFire1 = plane.fire
plane.fire = function () {
  copyFire1()
  missleDecorator()
}

const copyFir2 = plane.fire
plane.fire = function () {
  copyFire2()
  atomDecorator()
}

plane.fire() // 发送普通子弹、发送导弹、发送原子弹
复制代码

装饰函数

在JavaScript中,几乎一切都是对象,函数又被称为一等对象。在JavaScript中可以很方便地修改对象的属性和方法,所以要为函数添加功能,最简单粗暴的方式是直接改写函数,但是这违反开放-封闭原则。比如下面的例子:

var a = function () {
  console.log(1)
}
// 改成
var a = function () {
  console.log(1)
  console.log(2)
}
复制代码

但是如果某个函数很复杂,而且之前可能也不是你维护,随便修改很可能产生难以预料的Bug,于是我们从装饰者模式中找到了一种答案,保存原函数的引用,然后添加新的功能:

var a = function () {
  console.log(1)
}

var _a = a

a = function () {
  _a()
  console.log(2)
}
复制代码

在实际开发中,这也是一种常见的做法。比如我们想给window绑定onload事件,但是不确定这个事件是不是被其他人绑定过,于是为了之前的函数不被覆盖,有如下代码:

window.onload = function () {
  console.log(1)
}

var _onload = window.onload || function () {}
window.onload = function () {
  _onload()
  console.log(2)
}
复制代码

这样的代码是符合开放-封闭原则的,我们增加新的功能的时候,没有修改原来的代码。但这种方式有一些缺点:

  • 必须维护_onload这个中间变量,如果函数的装饰链较长,或者装饰的函数变多,这些中间变量的数量也会越来越多。
  • 还会有this被劫持的问题,在上面的例子中没有问题,因为调用普通函数_onload,this也指向window,现在把window.onload改成document.getElementById,代码如下:
var _getElementById = document.getElementById

document.getElementById = function (id) {
  console.log(1)
  return _getElementById(id)
}

var button = document.getElementById('button')
复制代码

执行这段代码,控制台在打印1后,抛出如下异常:

// Uncaught TypeError: Illegal invocation
复制代码

异常的原因就是此时_getElementById是一个全局函数,调用全局函数时,this指向window的,而document.getElementById内部this预期的指向是document。所以我们需要改进代码:

var _getElementById = document.getElementById

document.getElementById = function (id) {
  console.log(1)
  return _getElementById.call(document, id)
}

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)

    return self.apply(this, arguments)
  }
}

Function.prototype.after = function (afterFn) {
  var self = this

  return function () {
    const ret = self.apply(this, arguments)
    afterFn.apply(this, arguments)

    return ret
  }
}
复制代码

Function.prototype.before接受一个函数作为参数,这个函数即为要添加的装饰函数,它里面有需要添加的新功能的代码。
接着把当前的this保存起来,这个this指向原函数,返回一个代理函数。这个代理函数的作用是把请求分别转发给新添加的函数和原函数,并且保证它们的执行顺序,让新添加的函数在原函数之前执行,也叫前置装饰,这样就实现了动态装饰的效果。
因为我们在函数中保存了this,通过apply函数绑定正确的this,保证函数在被装饰之后,this不会被劫持。于是前面的例子,我们可以这样写:

 document.getElementById = document.getElementById.before(function () {
   console.log(1)
 })

 console.log(document.getElementById('button'))
复制代码

AOP应用

数据统计上报

分离业务代码和数据统计代码,无论在什么语言中,都是AOP的经典应用之一。在项目的开发结尾的时候,我们一般需要加一些统计数据的代码,这些过程可能让我们被迫改动已经封装好的函数。比如,页面中有登录按钮,点击登录按钮,弹出登录弹窗的同时还要上报数据,来统计有多少用户点击了登录按钮。下面简单的代码实现:

function showLoginModal () {
  console.log('打开登录弹窗')
  log('传入一些按钮信息')
}

function log (info) {
  console.log('上报用户信息和按钮信息到服务器')
}

document.getElementById('loginBtn').onclick = showLoginModal
复制代码

可以看到,在showLogin函数里,既要负责打开弹窗的功能,又要负责数据上报,这两个不同层面的代码耦合在一起,我们可以使用AOP进行优化:

var showLoginModal = function () {
  console.log('打开登录弹窗')
  log('传入一些按钮信息')
}

function log (info) {
  console.log('上报用户信息和按钮信息到服务器')
}

showLoginModal = showLoginModal.after(log)  // 打开弹窗之后上报数据

document.getElementById('loginBtn').onclick = showLoginModal
复制代码
使用AOP动态改变函数的参数

观察Function.prototype.before函数:

Function.prototype.before = function (beforeFn) {
  var self = this

  return function () {
    beforeFn.apply(this, arguments)

    return self.apply(this, arguments)
  }
}
复制代码

可以看到beforeFn函数和原函数共用参数arguments,所以我们在beforeFn中修改参数后,原函数接收的参数也会发生变化。
现在有一个用于ajax请求的函数,它负责项目中所有的ajax异步请求:

var ajax = function (type, url, param) {
  console.dir(param)

  // 这里是发送请求的代码
}

ajax('get', 'http://xxx.com/userInfo', { name: 'uzi' })
复制代码

上面代码表示向服务端发起一个获取用户信息的请求,传递的参数是{ name: 'uzi' }。ajax函数在项目中一直工作良好,突然有有一天,网站遭受了CSRF攻击,解决CSRF的一个办法就是在所有HTTP请求中带上一个token参数。于是我们定义了一个生成token的函数:

var getToken = function () {
  return 'token'
}
复制代码

下面给所有请求加上token参数:

var ajax = function (type, url, param) {
  param = param || {}
  param.token = getToken()

  // 这里是发送请求的代码
}
复制代码

这样问题就解决了,但是ajax函却变得僵硬了,虽然每个ajax请求都自动带上了token参数,在当前项目是没有什么问题。但是,如果将来要将这个ajax函数封装到公司的通用库里,那这个token参数可能就是多余的了,也许另一个项目不需要token参数,或者生成token的算法不一样,无论怎么样,都需要修改这个ajax函数。我们用AOP来解决这个问题:

var ajax = function (type, url, param) {
  console.dir(param)

  // 这里是发送请求的代码
}

var getToken = function () {
  return 'token'
}

// 使用Function.prototype.before装饰ajax函数
ajax = ajax.before(function (type, url, param) {
  param.token = getToken()
})

ajax('get', 'http://xxx.com/userInfo', { name: 'uzi' })  // { name: 'uzi', token: 'token'}
复制代码

这样我们就保证了ajax函数的干净,提高了ajax函数的复用性,并且也满足了添加token的需求。

插件式的表单验证

表单验证在web开发中是一个很常见的需求,比如在一个登录页面,我们在把用户的数据,比如用户名、密码等信息提交给服务器之前,就会经常需要做校验,假设我们现在只需要校验字段是否为空,于是有如下代码:

var username = document.getElementById('username')
var password = document.getElementById('password')
var submitBtn = document.getElementById('submitBtn')

function submitHandler () {
  if (username.value === '') {
    return alert('用户名不能为空')
  }
  if (password.value === '') {
    return alert('密码不能为空')
  }
  ajax('post', 'http://xxx.com/login', { username: username.value, password: password.value })
}

submitBtn.onclick = submitHandler
复制代码

上面的submitHandler在此处承担了两个职责,除了ajax的请求之外,还要验证用户输入的合法性,这种函数首先一旦校验的字段很多,代码就会臃肿,而且函数职责也很混乱,无法复用。 下面使用AOP进行优化,首先分离校验相关的代码:

function validateField () {
   if (username.value === '') {
    alert('用户名不能为空')
    return false
  }
  if (password.value === '') {
    alert('密码不能为空')
    return false
  }
}

function submitHandler () {
  var params = { username: username.value, password: password.value }
  ajax('post', 'http://xxx.com/login', params)
}
复制代码

改写前面的Function.prototype.before:

Function.prototype.before = function (beforeFn) {
  var self = this

  return function () {
    const ret = beforeFn.apply(this, arguments)
    if (ret === false) {
      return
    }

    return self.apply(this, arguments)
  }
}
复制代码

再用validateField前置装饰submitHandler:

submitHandler = submitHandler.before(validateField)

submitBtn.onclick = submitHandler
复制代码

这样我们就完美将校验的代码和提交ajax请求的代码完全分离开来,它们不再有耦合关系,这样我们在项目中可以把一些校验函数封装起来,达到复用的目的。

装饰者模式和代理模式

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

代码模式和装饰者模式最重要的区别是在于它们的意图和设计目的。代理模式的目的是,当直接访问本体不方便时或者不合符需求时,为本体提供一个替代者。本体定义了核心的功能,而代理提供的作用一个是直接拒绝一些访问,另一个就是在本体之前做一些额外的事情。而装饰者的作用是给对象动态添加行为,可以说代理模式强调一种本体和代替者的一种可以静态表达的关系,这种关系在一开始就基本被确定了。而装饰者模式一开始并不能确定所有的功能,在不同的场景中,可能会根据需要添加不同的装饰者,这些装饰者可以形成一条长长的装饰链。

总结

通过上面的三个应场景:数据上报、动态改变函数参数以及表单校验,我们可以看到在JavaScript中,我们了解了装饰函数,了解了AOP,他们就是JavaScript中独特的装饰者模式,这种模式在实际开发中非常有用。

转载于:https://juejin.im/post/5cf1f4f05188256aa76bb079

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值