前言

这是关于设计模式的系列文章,在每篇文章中将对常见设计模式进行讲解,因为针对前端方向,而且前端常用语言 JavaScript 本身是弱类型,面向对象(模拟面向对象)编程的实现相较于其他强类型语言实现更为繁琐,所以代码主要以 JavaScript 表现。

系列文章链接:

装饰器模式概念

“装饰器模式” 是结构型模式之一,在不改变原有对象结构的前提下,给对象添加新功能,也可以理解 “装饰器模式” 是将一个对象嵌入另一个对象之中,相当于一个对象被另一个对象包装,包装其他对象的对象被称为 “装饰器”。


装饰器模式 UML 图
装饰器模式 UML 图


装饰器模式和适配器模式

/* 装饰器模式案例 */
// 类 Duck
class Duck {
  constructor(name) {
    this.name = name;
  }
  eat(food) {
    console.log(this.name + '吃' + food);
  }
}

// 装饰器类 TangDuck,装饰 Duck 类
class TangDuck {
  constructor(name) {
    this.duck = new Duck(name);
  }
  eat(food) {
    this.duck.eat(food);
    console.log('说谢谢');
  }
}

const tangDuck = new TangDuck('唐老鸭');
tangDuck.eat('苹果');
// 唐老鸭吃苹果
// 说谢谢
/* 适配器模式案例 */
// 类 Power
class Power {
  charge() {
    return '220V';
  }
}

// 适配器
class Adaptor {
  constructor(Power) {
    this.power = new Power();
  }
  chargeTransform() {
    return this.power.charge() + ' => 22v';
  }
}

// 类 Power 的使用者
class Notepad {
  constructor(Power) {
    this.adaptor = new Adaptor(Power);
  }
  use() {
    console.log(this.adaptor.chargeTransform());
  }
}

上面分别是 “装饰器模式” 和 “适配器模式” 的案例,但直接看代码可能会将两者混淆,原因是 “适配器” 和 “装饰器” 的类都存在了一个被装饰或者适配转换的类的引用,不同的是,“装饰器” 仅仅是对某一个类进行包装,并不会改变原来类的结构,而 “适配器” 的作用更多是去建立一个类和另一个类之间的关系和转换。

装饰器模式和继承

通过上一节,我们已经知道了什么是 “装饰器模式”,下面有一个更直观的例子,我们有一个基础类 Coffee,组成是咖啡加水,这个基础上可以加奶、糖、冰,需求是可以组合加入上面的其他原料,并计算出对应的价格,大家可能第一时间想到的是继承的方式实现。

/* 继承的实现方式 */
// 水 + 咖啡
class Coffee {
  make(water) {
    return water + ' + 咖啡'
  }
  cost() {
    return 10;
  }
}

// 水 + 奶 + 咖啡
class MilkCoffee extends Coffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 奶';
  }
  cost() {
    return super.cost() + 3;
  }
}

// 水 + 糖 + 咖啡
class SugarCoffee extends Coffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 糖';
  }
  cost() {
    return super.cost() + 2;
  }
}

// 水 + 糖 + 奶 + 咖啡
class SugarMilkCoffee extends SugarCoffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 奶';
  }
  cost() {
    return super.cost() + 3;
  }
}

// 水 + 奶 + 糖 + 咖啡
class MilkSugarCoffee extends MilkCoffee {
  constructor() {
    super();
  }
  make(water) {
    return super.make(water) + ' + 糖';
  }
  cost() {
    return super.cost() + 2;
  }
}

从继承的代码看,虽然可以实现给咖啡任意加入其他原料,但是每一种不同的排列组合都需要单独创建类,当原料种类众多时,则难以管理代码,下面是 “装饰器模式” 的实现。

/* 装饰器模式的实现方式 */
class Coffee {
  make(water) {
    return water + ' + 咖啡';
  }
  cost() {
    return 10;
  }
}

class MilkCoffee {
  constructor(parent) {
    this.parent = parent;
  }
  make(water) {
    return this.parent.make(water) + ' + 奶';
  }
  cost() {
    return this.parent.cost() + 3;
  }
}

class SugarCoffee {
  constructor(parent) {
    this.parent = parent;
  }
  make(water) {
    return this.parent.make(water) + ' + 糖';
  }
  cost() {
    return this.parent.cost() + 2;
  }
}

class IceCoffee {
  constructor(parent) {
    this.parent = parent;
  }
  make(water) {
    return this.parent.make(water) + ' + 冰';
  }
  cost() {
    return this.parent.cost() + 1;
  }
}

const coffee = new Coffee();
const milkCoffee = new MilkCoffee(coffee);
const sugarCoffee = new SugarCoffee(milkCoffee);
const iceCoffee = new IceCoffee(sugarCoffee);

console.log(milkCoffee.make('水'), milkCoffee.cost());
console.log(sugarCoffee.make('水'), sugarCoffee.cost());
console.log(iceCoffee.make('水'), iceCoffee.cost());

// 水 + 咖啡 + 奶 13
// 水 + 咖啡 + 奶 + 糖 15
// 水 + 咖啡 + 奶 + 冰 16

从 “装饰器模式” 的实现代码来看,我们只需要创建和原料相同多的类就可以了,其他的方式加料只需要对上一个类进行包装即可,部分加料的顺序,当类的种类越多时,“装饰器” 的意义则体现的越明显。

装饰器模式有时候会优于继承,尤其是很多的类通过继承存在排列组合的关系时,则使用 “装饰器模式” 可以更好更高效的解决问题。

装饰器模式和 AOP 编程

在软件业,AOPAspect Oriented Programming 的缩写,意为面向切面编程,通过预编译方式和运行其动态代理实现程序功能统一维护的一种技术。

JavaScript 中的 AOP 就是在函数之前或之后添加一些额外的逻辑,而不需要修改函数本身逻辑。

/* AOP 编程的案例 */
// 给函数扩展 before 方法
Function.prototype.before = function (beforeFn) {
  let _this = this;
  return function () {
    beforeFn.apply(this, arguments);
    _this.apply(this, arguments);
  }
}

// 给函数扩展 after 方法
Function.prototype.after = function (afterFn) {
  let _this = this;
  return function () {
    _this.apply(this, arguments);
    afterFn.apply(this, arguments);
  }
}

// 原函数
function buy(money, goods) {
  console.log('花' + money + '元钱买' + goods);
}

// 使用 before 方法给函数增加前切面
buy = buy.before(function () {
  console.log('向媳妇要1元钱');
});

// 使用 before 方法给函数增加后切面
buy = buy.after(function () {
  console.log('还给媳妇0.2元钱');
})

buy(0.8, '盐');
// 向媳妇要1元钱
// 花0.8元钱买盐
// 还给媳妇0.2元钱

AOP 编程是由 “装饰器模式” 进化而来,或者说 “装饰器模式” 属于 AOP 编程的一种。

装饰器模式的应用

监控埋点

埋点分析,是网站分析的一种常用的数据采集方法,埋点主要分为服务器层面的埋点和客户端层面的埋点,服务器层面的埋点主要是通过客户端的请求进行分析,客户端层面的埋点分为代码埋点、自动化埋点,第三方埋点(百度、友盟等)。

<!-- 一个埋点的简单案例 -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>埋点</title>
</head>
<body>
  <button data-name="wetermelon" id="wetermelon">西瓜</button>
  <button data-name="apple" id="apple">苹果</button>
  <script>
    const wetermelon = document.getElementById('wetermelon');
    const apple = document.getElementById('apple');

    // 添加切面
    Function.prototype.after = function (afterFn) {
      let _this = this;
      return function () {
        _this.apply(this, arguments);
        afterFn.apply(this, arguments);
      }
    }

    // 事件处理函数
    function click() {
      console.log('你点击了' + this.dataset.name);
    }

    click = click.after(function () {
      // 向服务器发送统计数据
      const img = new Image();
      img.src = 'http://localhost:3000/report?name=' + this.dataset.name;
    });

    // 给所有的
    Array.from(document.querySelectorAll('button')).forEach(button => {
      button.addEventListener('click', click);
    });
  </script>
</body>
</html>
/* 负责统计点击次数的服务 */
const express = require('express');
const app = express();

// 存储按钮的点击次数
const goods = {};

app.get('/report', function (req, res) {
  const name = req.query.name;
  if (goods[name]) {
    goods[name]++;
  } else {
    goods[name] = 1;
  }

  res.json(goods);
});

app.listen(3000, function () {
  console.log('server start 3000');
});

上面的埋点就是通过 AOP 的方式在点击事件后添加了切面,用来向服务器发送请求,符合 “单一职责原则”,可以使点击事件和埋点逻辑进行 “解耦”,服务器在接收到请求之后立即对点击次数进行统计并储存,也可以通过调用 report 接口来获取当前各个按钮的点击次数。

表单校验

“装饰器模式” 的思想同样可以用在表单校验,通常表单校验逻辑是在 submit 事件触发时提交之前发生的,我们经常会将校验逻辑和提交逻辑写在一起,形成 “强耦合”,下面我们使用 AOP 的方式来实现表单校验,对校验逻辑和提交逻辑进行 “解耦”。

<!-- 应用于表单校验 -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>表单校验</title>
</head>
<body>
  用户名:<input type="text" id="username">
  密码:<input type="text" id="password">
  <button id="submit-btn">提交</button>
  <script>
    const submitBtn = document.getElementById('submit-btn');

    // 添加切面函数
    Function.prototype.before = function (beforeFn) {
      let _this = this;
      return function () {
        let result = beforeFn.apply(this, arguments);
        result && _this.apply(this, arguments);
      }
    }

    // 表单提交事件
    function submit() {
      console.log('提交表单');
    }

    // 验证用户名
    submit = submit.before(function () {
      const username = document.getElementById('username').value;
      if (!username) {
        return alert('请输入用户名');
      }
      return true;
    });

    // 验证
    submit = submit.before(function () {
      const password = document.getElementById('password').value;
      if (!password) {
        return alert('请输入密码');
      }
      return true;
    });

    submitBtn.addEventListener('click', submit);
  </script>
</body>
</html>

总结

JavaScript 中 “装饰器模式” 和 AOP 编程非常相似,应用也非常多,如 axios 中对请求、响应的拦截方法,Koa 中间件,都包含这样的编程思想,而在 ES6 之后 JavaScript 已经支持了原生的 “装饰器” 语法,使用起来更方便,最后附上 案例地址