【设计模式】装饰器模式在前端开发中的实践

293 阅读8分钟

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

当我们需要扩展对象的能力,通常有这几种方案:修改构造函数添加原型方法继承。前两种方式会直接修改内部代码。耦合性增加的同时,进一步提高了开发、维护成本。继承看似没有问题。但如果针对一个扩展场景就派生出一个新的子类。那针对多个扩展场景(包括多个混合的场景),会有很多重复代码,扩展性不好(做不到即插即用)

那么有没有一种方案既可以不侵入式的扩展功能,扩展的方式也会更加方便、优雅呢,答案就是装饰器模式

装饰器模式

装饰器模式的核心是:在不侵入原有代码逻辑的情况下,扩展其功能(或者是不改变对象的基础,扩展对象功能以满足业务新的功能)

相信大家都玩过类似DOTA2LOL的MOBA游戏,一个Dota2英雄对象的接口类型如下

type PrimaryAttribute = "Agility" | "Intelligence" | "Strength";

interface DotaHero {
  /**
   * 英雄的名称
   */
  name: string;
  /**
   * 英雄的主要属性类型:敏捷、智力、力量
   */
  primaryAttribute: PrimaryAttribute;
  /**
   * 英雄拥有的技能列表
   */
  abilities: Ability[];
  /**
   * 英雄的基础属性数据
   */
  info: HeroInfo;
}

interface Ability {
  /**
   * 技能的名称
   */
  name: string;
  /**
   * 技能的描述
   */
  description: string;
}

interface HeroInfo {
  /**
   * 英雄的力量属性
   */
  strength: number;
  /**
   * 英雄的敏捷属性
   */
  agility: number;
  /**
   * 英雄的智力属性
   */
  intelligence: number;
  /**
   * 英雄的基础攻击伤害
   */
  attackDamage: number;
  /**
   * 英雄的基础防御力
   */
  defense: number;
  /**
   * 英雄的基础移动速度
   */
  movementSpeed: number;
}

来看下具体的英雄类(实际会更加复杂,这里只是简化处理实现)

interface HeroBase {
  showInfo(): void;
}

class Hero implements HeroBase {
  public name: string = "";
  public abilities: Ability[] = [];
  constructor(public info: HeroInfo) {
    this.info = info;
  }
  showInfo() {
    console.log(`英雄${this.name}的各项能力数值为:${JSON.stringify(this.info)}`);
  }
}

class IntelligenceHero extends Hero {
  public primaryAttribute: PrimaryAttribute = "Intelligence";
  constructor(hero: Omit<DotaHero, "primaryAttribute">) {
    const { info, name, abilities } = hero;
    super(info);
    // 智力英雄智力基础点+ 10
    this.info.intelligence += 10;
    this.name = name;
    this.abilities = abilities;
  }
}

class AgilityHero extends Hero {
  public primaryAttribute: PrimaryAttribute = "Agility";
  constructor(hero: Omit<DotaHero, "primaryAttribute">) {
    const { info, name, abilities } = hero;
    super(info);
    // 敏捷英雄敏捷基础点+ 10
    this.info.strength += 10;
    this.name = name;
    this.abilities = abilities;
  }
}

class StrengthHero extends Hero {
  public primaryAttribute: PrimaryAttribute = "Strength";
  constructor(hero: Omit<DotaHero, "primaryAttribute">) {
    const { info, name, abilities } = hero;
    super(info);
    // 力量英雄力量基础点+ 10
    this.info.strength += 10;
    this.name = name;
    this.abilities = abilities;
  }
}

随后我们来创建一个英雄,比如,对,没错,就是猛犸(为什么不BAN猛犸!!)

const defaultHeroInfo: HeroInfo = {
  strength: 20,
  agility: 20,
  intelligence: 20,
  attackDamage: 60,
  defense: 10,
  movementSpeed: 300,
};

let Magnus = new StrengthHero({
  name: "Magnus",
  // 这里就省略了
  abilities: [],
  info: { ...defaultHeroInfo },
});

当英雄装备上装备后,攻击力、智力、力量、敏捷等都可能会得到提升,我们来定义下装备类型(这里简单定义,只关注各项能力数值带来的变化),并生成一个装备

type AdjustDataInfo = {
  [K in keyof HeroInfo as `${K}AdjustmentNum`]?: HeroInfo[K];
};
type Equipment = AdjustDataInfo & {
  name: string;
};

const butterfly = {
  name: "蝴蝶",
  agilityAdjustmentNum: 35,
  attackDamageAdjustmentNum: 25,
};

好了,接下来的一步是给英雄装备上“蝴蝶”,一种直接的方法就是在 HERO类里面定义getEquipment方法,在这个方法里面操作。类似这种

class Hero {
  // xxx
  getEquipment(equipment: Equipment) {
    const {
      strengthAdjustmentNum = 0,
      agilityAdjustmentNum = 0,
      intelligenceAdjustmentNum = 0,
      attackDamageAdjustmentNum = 0,
      defenseAdjustmentNum = 0,
      movementSpeedAdjustmentNum = 0,
    } = equipment;

    this.info.strength += strengthAdjustmentNum;
    this.info.agility += agilityAdjustmentNum;
    this.info.intelligence += intelligenceAdjustmentNum;
    this.info.attackDamage += attackDamageAdjustmentNum;
    this.info.defense += defenseAdjustmentNum;
    this.info.movementSpeed += movementSpeedAdjustmentNum;
  }
}

但仔细想一想,装备毕竟属于英雄的身外之物,英雄可以随时更换甚至丢弃装备。强行和 Hero类耦合在一起不好,另外侵入了原有逻辑代码。当我们添加完代码后需要确保没有影响到之前的功能(可能从这个例子看不太会影响原有的功能,但实际业务场景随着迭代越来越复杂,无法保证)。增加了维护成本。我们可以增加一个装备装饰类

class Decorate implements HeroBase {
  constructor(protected hero: Hero) {}
  adjustHeroStatus(adjustDataInfo: AdjustDataInfo) {
    const {
      strengthAdjustmentNum = 0,
      agilityAdjustmentNum = 0,
      intelligenceAdjustmentNum = 0,
      attackDamageAdjustmentNum = 0,
      defenseAdjustmentNum = 0,
      movementSpeedAdjustmentNum = 0,
    } = adjustDataInfo;
    this.hero.info.strength += strengthAdjustmentNum;
    this.hero.info.agility += agilityAdjustmentNum;
    this.hero.info.intelligence += intelligenceAdjustmentNum;
    this.hero.info.attackDamage += attackDamageAdjustmentNum;
    this.hero.info.defense += defenseAdjustmentNum;
    this.hero.info.movementSpeed += movementSpeedAdjustmentNum;
  }
  showInfo() {
    this.hero.showInfo();
  }
}

class EquipmentDecorate extends Decorate {
  constructor(protected hero: Hero, public equipment: Equipment) {
    super(hero);
    this.adjustHeroStatus(equipment);
  }
  showInfo() {
    if (!this.hero) return;
    console.log(`英雄${this.hero.name}装备了${this.equipment.name}`);
    super.showInfo();
  }
}

好了,接下来我们给“猛犸”装备上“蝴蝶”(实际游戏中猛犸是不会出蝴蝶的🐶,刀友忽略)

const MagnusGetButterfly = new EquipmentDecorate(Magnus, butterfly);
MagnusGetButterfly.showInfo();

image.png

可以发现,猛犸的敏捷和攻击力都增加了

另外,我们游戏中会有各种buff(急速、双倍攻击等),实现的思路完全一样(具体细节就不过多描述)

class BuffDecorate extends Decorate {
  xxxxx
}

装饰器语法糖

ES5及之前,我们只能通过这种办法实现装饰模式,未免有些繁琐不够直观简洁。ES6引入了Decorator语法糖,能够更加优雅简洁的实现

ES6装饰器(Decorator)处于ES提案流程的stage-3阶段,已经快到浏览器厂商去实现它了,TS去年的预览版本也已经更新了新的语法预览,但是本文以2020年的装饰器语法进行阐述。详细细节以及最新语法可以参考阮一峰大大的装饰器教程

给类本身添加装饰器

// 装饰器函数,它的第一个参数是目标类
function classDecorator(target) {
    target.hasDecorator = true
    return target
}

@classDecorator
class Base {
  // Base类的相关逻辑
}

// 验证装饰器是否生效
console.log('Base 是否被装饰了:', Base.hasDecorator) // true

上面的target其实就是Base类本身

给类的方法添加装饰器

function showDecorate(target, name, descriptor) {
  const originFunc = descriptor.value;
  descriptor.value = function () {
    console.log("show装饰器");
    return originFunc.apply(this, arguments);
  };
  return descriptor;
}

class Base {
  @showDecorate
  show() {
    console.log("Base 的  show");
  }
}

const b = new Base();
b.show();

image.png

在这里需要额外提下的是装饰函数showDecorate的三个形参targetnamedescriptor,我们可以打印看下

function showDecorate(target, name, descriptor) {
  console.log('target', target)
  console.log('name', name)
  console.log('descriptor', descriptor)
}

class Base {
  @showDecorate
  show () {
    console.log('Base 的  show')
  }
}

image.png

可见targetBase.prototype, name为装饰方法的名称,description为对应key的属性描述符。具体相关可以参考mdn

浏览器目前并不支持装饰器语法,上面这些代码无法在浏览器上直接运行,我们需要借助babel,具体可以参考 如何在构建环境中支持 decorator

回过头,我们将之前dota2英雄的例子使用装饰器语法糖改造看看效果

function  adjustHeroStatus(this: Omit<DotaHero, "primaryAttribute">, adjustDataInfo: AdjustDataInfo) {
  const {
    strengthAdjustmentNum = 0,
    agilityAdjustmentNum = 0,
    intelligenceAdjustmentNum = 0,
    attackDamageAdjustmentNum = 0,
    defenseAdjustmentNum = 0,
    movementSpeedAdjustmentNum = 0,
  } = adjustDataInfo;
  this.info.strength += strengthAdjustmentNum;
  this.info.agility += agilityAdjustmentNum;
  this.info.intelligence += intelligenceAdjustmentNum;
  this.info.attackDamage += attackDamageAdjustmentNum;
  this.info.defense += defenseAdjustmentNum;
  this.info.movementSpeed += movementSpeedAdjustmentNum;
}

function EquipmentDecorate(target: typeof Hero) {
  target.prototype.getEquipment = function (equipment: Equipment) {
    adjustHeroStatus.call(this, equipment)
    console.log(`英雄${this.name}装备了${equipment.name}`);
  }
}

// 添加装备装饰器
@EquipmentDecorate
class Hero implements HeroBase {
  // 之前的逻辑
  xxxx
}

// 猛犸装备蝴蝶
Magnus.getEquipment(butterfly)
Magnus.showInfo()

image.png

上面的例子相比之前更加的清爽简洁。我们没有修改hero相关内部的代码。只是扩展了它的功能。如果哪一天我们不需要了,把对应装饰器去除即可。做到了“即插即用”。同样将要又来一个需求要求给英雄buff功能 那么我们可以非常快速的完成

// 添加装备装饰器
@EquipmentDecorate
// 添加buff装饰器
@BuffDecorate
class Hero implements HeroBase {
  // 之前的逻辑
  xxxx
}

前端实际场景的运用

装饰器的UML图如下

image.png

相关具体规范实现代码如下

interface Component {
    operation(): string;
}

class ConcreteComponent implements Component {
    public operation(): string {
        return 'ConcreteComponent';
    }
}

class Decorator implements Component {
    protected component: Component;

    constructor(component: Component) {
        this.component = component;
    }

    /**
     * The Decorator delegates all work to the wrapped component.
     */
    public operation(): string {
        return this.component.operation();
    }
}

class ConcreteDecoratorA extends Decorator {
    public operation(): string {
        return `ConcreteDecoratorA(${super.operation()})`;
    }
}

class ConcreteDecoratorB extends Decorator {
    public operation(): string {
        return `ConcreteDecoratorB(${super.operation()})`;
    }
}

function clientCode(component: Component) {
    // ...
    console.log(`RESULT: ${component.operation()}`);
    // ...
}

const simple = new ConcreteComponent();
console.log('Client: I\'ve got a simple component:');
clientCode(simple);
console.log('');

const decorator1 = new ConcreteDecoratorA(simple);
const decorator2 = new ConcreteDecoratorB(decorator1);
console.log('Client: Now I\'ve got a decorated component:');
clientCode(decorator2);

上面的内容均来自:装饰器结构,感兴趣的小伙伴可以详细查看

对于js来说,函数是一等公民。具有极大的灵活性。我们可以很方便的实现装饰器模式,没有必要完全拘泥于UML规范。还是那句话:学习设计模式,重要的学习理解它的核心思想并且运用

权限控制

这是一个很常见的场景,在进行一些操作前需要判断该账号是否有对应操作权限。如果有则再进行后续业务逻辑。没有则有无权的交互

在我司业务代码中,操作按钮权限的判断逻辑相对有些复杂,需要结合主子账号以及应用各种判断,如果侵入式修改,针对每一个按钮操作函数内部加上这么一段逻辑,显然开发、维护成本直线上升。权限判断处理与操作逻辑为两块独立的功能。不应该将权限的判断处理逻辑作为操作逻辑的一个部分,我们可以运用装饰器模式处理

伪代码如下(实际场景比这还复杂很多,这里简化只关注思想)

/**
 * @param fn 操作逻辑
 * @param noPermissionHandler 无权限的处理方式
 * @param demoNoPermissionHandler demo应用无权限处理方式
 * @param snKey 操作sn码对应的key
 */
function permissionDecorate({
  fn,
  noPermissionHandler,
  demoNoPermissionHandler,
  snKey
}) {
  return function () {
    const sn = snConfigMap[snKey]
    // 如果是主账号或者子账号有对应操作权限
    if (!isChildAccount || snList.includes(sn)) {
      return fn.apply(this, arguments)
    }
    // demo应用没有操作权限
    if (isDemoApp) {
      return demoNoPermissionHandler.apply(this, arguments)
    }
    // 没有操作权限
    return noPermissionHandler.apply(this, arguments)
  }
}

使用的时候我们可以

permissionDecorate({
    fn() {xxx},
    noPermissionHandler() {xxx},
    demoNoPermissionHandlerFn() {xxx},
    snKey: 'xxx'
})

当然大部分情况下,没有权限、demo应用没有权限的处理逻辑一致,我们可以给一个缺省处理函数,没必要每次都传,具体看大家的业务了

另外的类似一些其他的按钮前置操作如埋点之类的,也可以采用类似方案

防抖

防抖、节流算是我们很熟悉的场景了,它们就是典型的装饰器模式

function debounceDecorate(delay: number): MethodDecorator {
  return function (
    target: any,
    name: PropertyKey,
    description: PropertyDescriptor
  ) {
    const originFunc = description.value;
    let timeId: number | undefined = undefined;

    description.value = function (...args: any[]) {
      if (timeId) {
        clearTimeout(timeId);
        return;
      }
      timeId = setTimeout(() => {
        originFunc.apply(this, args);
      }, delay);
    };

    return description;
  };
}

class Test {
  @debounceDecorate(3000)
  log(a: any, b: any) {
    console.log("a :>> ", a);
    console.log("b :>> ", b);
  }
}

如果不基于class也就是我们日常的写法如下

function debounce(fn: Function, delay: number) {
  let timer: number | undefined;
  return function (...args: any[]) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

function log(a: any, b: any) {
  console.log(a);
  console.log(b);
}

const debounceLog = debounce(log, 2000);
debounceLog(1, 2);

埋点应用

另一个常见的实际场景为埋点。有一些埋点事件(比如pageStay事件),我们需要监听路由变化,并在里面上报埋点

前端路由库一般有两种模式:hashhistory。这两种方式都可以实现更换页面url而不刷新页面。其中 hash的变化可以通过监听hashChange事件来捕获。 history模式会相对特殊些。引用mdn的说明

备注:  调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件

这意味着,原生并不支持以下事件的监听

window.addEventListener('pushState', () => { xxx })
window.addEventListener('replaceState', () => { xxx })

这就需要我们“装饰”浏览器原生API了

const historyEventDecorate = <T extends keyof History>(type: T) => {
  const originFunc = history[type]

  return function (this: any) {
    // eslint-disable-next-line prefer-rest-params
    const res = originFunc.apply(this, arguments)
    const e = new customEvent(type)
    window.dispatchEvent(e)
    return res
  }
}

window.addEventListener('DOMContentLoaded', () => {
  ['pushState', 'replaceState'].forEach(ht => historyEventDecorate(ht))
})

这样就可以监听到pushStatereplaceState事件了,然后在里面放上报埋点相关的逻辑,和之前class方法的装饰器写法是不是有些类似。其实本质都是一样的!

总结

前端领域中运用装饰器主要有以下几种方式

  1. 使用高阶函数结合闭包(防抖节流、对一些第三方或者原生API的扩展)

  2. 使用ECMAScript支持的Decorate语法,写法会更加简洁。但是它最大的问题是:用来增强 JavaScript 类(class)的功能。而我们前端开发大部分都是函数式编程。特别是现在vuereact项目中,我们代码大部分都是各种hook

  3. 借鉴后端AOP(面向切面编程)的理念

在编程领域中,AOP 代表面向切面编程(Aspect-Oriented Programming)。它是一种设计模式,旨在解决传统面向对象编程(OOP)中的一些缺陷。

AOP 的主要思想是将与业务无关的共同行为(如日志记录、事务管理、安全控制等)从核心业务逻辑中分离出来,并将这些共同行为封装为独立的切面(Aspect)。通过切面,开发者可以在不修改原有代码的情况下,将这些通用功能动态地"织入"到目标对象中

这里可以借鉴《JavaScript设计模式》中曾探老师提供的辅助函数

// 前置函数
Function.prototype.beforeExec = function (fn) {
  const _this = this;
  return function wrapper() {
    fn.apply(this, arguments);
    return _this.apply(this, arguments);
  };
};

// 后置函数
Function.prototype.afterExec = function (fn) {
  const _this = this;
  return function wrapper() {
    const response = _this.apply(this, arguments);
    fn.apply(this, arguments);
    return response;
  };
};

我们可以这样用

function buinessHandler() {
  console.log('我是业务处理代码 :>> ');
}

function someDataTransBeforeBuiness() {
  console.log('业务代码处理前的数据转化处理逻辑');
}

function someDataTransAfteruiness() {
  console.log('业务代码处理后的数据转化处理逻辑');
}

buinessHandler = buinessHandler.beforeExec(someDataTransBeforeBuiness)
buinessHandler = buinessHandler.afterExec(someDataTransAfteruiness)
buinessHandler()

image.png

但这种方法并不建议使用,扩展原生对象的原型可能会带来潜在的风险,容易产生不宜排查的bug

无论是哪种方式,我们都需要明白装饰器模式的本质就是一个函数。它的核心在于:不修改原本的内容,扩展其能力,可以“即插即用”

最后

还是那句话,设计模式重在理解它的思想,以及解决了什么样场景的问题。相信大家看到了这里,也会举一反三,联想出或者已经发觉自己之前就在很多场景用过装饰器模式了

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论