设计模式在前端开发中的实践(四)——装饰模式

装饰模式

装饰模式是一种结构型设计模式,是前端开发中算的上使用频率能排的上前三的设计模式了。如果你掌握好了装饰模式,能够有效的组织一些前端代码,从而提高代码的复用能力。

1、基本概念

装饰模式是为已有功能动态的添加更多功能的一种方式,装饰模式可以算的上是AOP(面向切面编程)的一种实现方式。

当系统需要新功能的时候,是向旧的类中添加新的代码,这些新加的代码通常装饰了原有类的核心职责或者主要行为。

上述UML图含义如下:Decorator不仅需要实现Component类,并且其内部还需要依赖一个Component类(承载内部Component对象链的起始点),具体的业务根据需要来继承Decorator

2、代码示例

ts 复制代码
interface Component {
  run(): void;
}

class Decorator implements Component {
  protected component: Component | null;

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

  run(): void {
    if (this.component) {
      this.component.run();
    }
  }

  decorate(com: Component): Component {
    this.component = com;
    return this;
  }
}

class GotoWork implements Component {
  run(): void {
    console.log("去上班了,不上班没有钱啊");
  }
}

class TakePhotoDecorator extends Decorator {
  run(): void {
    console.log("拍照,记录下春天美美的花草");
    super.run();
  }
}

class LookBeautyDecorator extends Decorator {
  run(): void {
    console.log("男人至死是少年,不看美女怎么行呢");
    super.run();
  }
}

let work = new GotoWork();
const take = new TakePhotoDecorator();
const look = new LookBeautyDecorator();
work = take.decorate(work);
work = look.decorate(work);
work.run();

上述代码中,我稍微做了一点儿处理,Decorator类的装饰方法返回的是Component,这样就可以把装饰好的结果赋值给最开始的对象(work = take.decorate(work);,改写了初始化的work对象,而不用最终调用look.run方法)最好执行方法的时候,逻辑上看起来比较好理解一些。

或者,我又思考了另外一种实现:

ts 复制代码
interface Component {
  run(): void;
}

class DecorateAbility implements Component {
  /**
   * 可以根据自己的需要用数组还是用Set来存储装饰类
   */
  protected components: Set<Component> = new Set();

  run(): void {
    this.components.forEach((com) => {
      com.run();
    });
  }

  decorate(com: Component) {
    // 保证唯一值
    this.components.add(com);
  }
}

class TakePhotoComponent implements Component {
  run(): void {
    console.log("拍照,记录下春天美美的花草");
  }
}

class LookBeautyComponent implements Component {
  run(): void {
    console.log("男人至死是少年,不看美女怎么行呢");
  }
}

class Work extends DecorateAbility {
  run(): void {
    super.run();
    console.log("要上班啊,不上班怎么有钱呢?");
  }
}

const work = new Work();
const take = new TakePhotoComponent();
const look = new LookBeautyComponent();
work.decorate(take);
work.decorate(look);
work.run();

这样,当每次调用decorate方法时,就是在给Work类的实例增加能力,不需要再考虑实例,逻辑上更好理解。

3、在前端开发中的实践

上述代码范式看起来比较复杂,但因为JS的函数的特性,使得在前端开发中使用装饰模式要比上面给出的UML图简单很多。

3.1 利用装饰模式放置覆盖

装饰模式的思想我们已经领悟到了其关键点--->不修改原本的内容,扩展其能力

如果有学习过Spring这类框架的同学一定对AOP(面向切面编程)有所了解,装饰模式就是这个思想,那抓住重点,我们就可以使用AOP来实现装饰模式。

以下是摘录自《JavaScript设计模式》中曾探老师给出的辅助函数:

js 复制代码
/**
 * 增加前置执行的函数
 */
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;
  };
};

为原型绑定这两个函数之后,比如在多人合作开发一个项目时,我们其实不知道window.onload上目前挂载了什么业务逻辑需要处理,但是又不敢贸然直接给window.onload赋值一个新函数,此刻,上述装饰模式的实现方式就派上了大用处。

js 复制代码
function onLoad() {
  console.log("我想处理一些业务逻辑");
}
// 不需要担心覆盖其它开发者增加的onload事件
window.onload =
  typeof window.onload === "function"
    ? window.onload.beforeExec(onLoad)
    : onLoad;

并且,再执行这个操作,仍然可以扩展window.onload回调函数的能力不用担心覆盖之前的内容。

3.2 自动增加请求时的loading提示

以下是使用装饰模式处理axios请求时增加loading提示通用实现的例子:

js 复制代码
import axios from "axios";
import Vue from "vue";
/**
 * 为请求注入loading
 * @param {Function} fn 请求后端的函数
 * @param {String} msg loading提示信息
 * @returns
 */
function decorate(fn, msg = "") {
  return function enhance() {
    Vue.prototype.$loading.show(msg);
    const result = fn.apply(this, arguments);
    if (result && typeof result.then === "function") {
      return result
        .then((resp) => {
          Vue.prototype.$loading.hide();
          return resp;
        })
        .catch(() => {
          Vue.prototype.$loading.hide();
        });
    }
    return result;
  };
}

/**
 * 获取活动配置
 */
export const getAppInfo = decorate(function() {
  return axios("/api/y2023/index");
});

经过这个装饰函数之后,业务侧只需要关心数据处理逻辑,不用再关注处理页面的提示信息,简化调用。而对于一些不需要loading提示,或者处理比较复杂的loading就不走这个decorate装饰函数,根据业务需要灵活的控制。

ES5及之前,我们只能通过这种办法实现装饰模式,但是ES6引入了一个新的语法:Decorator(不仅可以增加原对象的能力,还可以削弱原对象的能力),同样可以实现上述方式。

ES6装饰器(Decorator)处于ES提案流程的stage-3阶段,已经快到浏览器厂商去实现它了,TS去年的预览版本也已经更新了新的语法预览,但是本文以2020年的装饰器语法进行阐述:

ts 复制代码
function log(target: Object, name: string, descriptor: PropertyDescriptor) {
  var oldValue = descriptor.value;
  descriptor.value = function () {
    console.log(`Calling ${name} with`, arguments);
    return oldValue.apply(this, arguments);
  };
  return descriptor;
}

class MyClass {
  @log
  handleClick() {
    console.log("用户点击了按钮");
  }
}

其实它的思路和我们写的AOP函数是一样的,在NestJS中,广泛的使用了装饰器记录元数据,利用元数据的依赖关系实现的依赖注入。

3.3 节流和防抖

用装饰模式实现防抖,以下是直接使用装饰器:

ts 复制代码
/**
 * 防抖装饰函数
 * @param delay 延迟的时间
 * @returns
 */
function debounce(delay: number): MethodDecorator {
  return function (
    target: any,
    propertyKey: string | symbol,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const originalMethod = descriptor.value;
    let timerId: NodeJS.Timeout | null = null;

    descriptor.value = function (...args: any[]) {
      if (timerId) {
        clearTimeout(timerId);
      }
      timerId = setTimeout(() => originalMethod.apply(this, args), delay);
    };

    return descriptor;
  };
}

class MyClass {
  @debounce(500)
  myMethod() {
    console.log("Debounced method called");
  }
}

不使用装饰器也可以,比如:

ts 复制代码
/**
 * 防抖
 * @param fn 原函数
 * @param delay 延迟时间
 * @returns
 */
function debounce(fn: Function, delay: number) {
  let timer: NodeJS.Timer;
  return function debounced() {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, delay);
  };
}

let counter = 0;
const fn = () => {
  counter++;
};
const enhancedFn = debounce(fn, 300);

节流也是一样的道理,大家可以根据我演示的思路自行实现。

3.4 SinglePromise或防止重复点击

在前端中,还有这样的场景,点击某个按钮以后,需要发送请求到服务器,这种情况跟防抖和节流的要求是差不多的,但是也有一点儿区别,因为如果你的节流时间设置的过短,而用户一直都在频繁的点击,还是有可能造成非预期的额外请求的。因此,我们也可以利用装饰模式来做一个函数来实现这样的通用逻辑。

关于SinglePromise的释义是什么呢?就是说如果一个函数是请求服务端耗时的操作,每次执行它,返回一个Promise,假设这个Promise将会在3S之后状态变成fulfilled,如果在3S以内的这个函数重复执行,结果返回的所有Promise的值都是第一个真实请求到服务端的返回值。

以下是我的实现:

ts 复制代码
let hasExecuteFn = false;

type Resolve = (value: unknown) => void;

type Reject = (reason?: any) => void;

type Executor = "resolve" | "reject";

const queue: Array<{
  resolve: Resolve;
  reject: Reject;
}> = [];

function flushQueue(exec: Executor, val: unknown) {
  while (queue.length) {
    const node = queue.shift();
    const executor = node![exec];
    executor(val);
  }
}

export function singlePromise<T extends unknown[], R>(fn: (...args: T) => R, ctx?: unknown): (...args: T) => Promise<R> {
  return function decorate(...args: T) {
    return new Promise((resolve, reject) => {
      if (hasExecuteFn) {
        queue.push({ resolve: resolve as Resolve, reject });
      } else {
        // @ts-ignore
        const p = fn.apply(ctx || this, args);
        hasExecuteFn = true;
        Promise.resolve(p)
          .then((res) => {
            hasExecuteFn = false;
            resolve(res);
            flushQueue("resolve", res);
          })
          .catch((err) => {
            hasExecuteFn = false;
            reject(err);
            flushQueue("reject", err);
          });
      }
    });
  };
}

使用:

ts 复制代码
const fn = () => axios('https://baidu.com');
const enhancedFn = singlePromise(fn);

const promise1 = enhancedFn();
const promise2 = enhanceFn();
const res1 = await promise1();
const res2 = await promise2();
// res1 === res2,并且只会真正请求一次服务器。

这个能力其实在一个知名的开源库alova.js中有集成,有兴趣的同学可以查看它的API文档,或者直接将我的代码复制下来使用。

难的都能写出来,简单的就更容易写了,哈哈哈。以下就是我实现的一个防止重复点击的代码了:

ts 复制代码
export function fastClickPrevent<T extends unknown[], R>(
  fn: (...args: T) => R,
  ctx?: unknown
): (...args: T) => Promise<R> {
  let prevent = false;
  return function decorate(...args: T) {
    if (prevent) {
      return Promise.resolve(null as R);
    }
    return new Promise((resolve, reject) => {
      // @ts-ignore
      const p = fn.apply(ctx || this, args);
      prevent = true;
      Promise.resolve(p)
        .then((res) => {
          prevent = false;
          resolve(res);
        })
        .catch((err) => {
          prevent = false;
          reject(err);
        });
    });
  };
}

然后在Vue或者React的组件中可以直接使用,下面是一个Vue组件的使用例子:

vue 复制代码
<template>
    <button @click='sendRequest'>点击发送请求</button>
</template>

<script>
    import axios from 'axios';
    export default {
        name: "Demo",
        methods: {
            // 这儿不能写成箭头函数,让fastClickPrevent的入参函数随上下文自行确定,在template的调用作用域是Vue,因此就不会出现任何问题。
            sendRequest: fastClickPrevent(async function () {
                const resp = await axios('https://api.xxx.com')
                if(resp.code === 1) {
                    this.$toast('请求成功')
                }
            })
        }
    }
</script>

总结

在实际项目中,凡是你想增强或者削弱一个函数提供的能力的时候,都可以使用装饰模式。

在编写装饰模式的代码的时候,有两三种编码思路,一种是利用闭包编写高阶函数,这种场景是任何函数都可以,没有什么限制,但是写法并不是特别优雅。

另外一种方式是利用ES6提供的装饰器语法,但是这个有使用的条件限制,必须是基于类的代码才能使用装饰器的语法,在目前前端的开发中,基本上都是函数式的开发思想,所以这种写法的使用场景较少,不过在Nodejs开发中,大量的使用OOP思想进行开发,比如NestJS,这种业务场景下使用装饰器实现要比高阶函数好,写法上看起来更加的优雅。

最后就是给原型上追加AOP函数的实现方式,在实际开发中不推荐,比如某些团队明确禁止在原型上扩展内容,因为这种操作可能带来一些潜在的隐患。

相关推荐
汪子熙21 分钟前
Angular 服务器端应用 ng-state tag 的作用介绍
前端·javascript·angular.js
Envyᥫᩣ29 分钟前
《ASP.NET Web Forms 实现视频点赞功能的完整示例》
前端·asp.net·音视频·视频点赞
Мартин.4 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。6 小时前
案例-表白墙简单实现
前端·javascript·css
数云界6 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd6 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常6 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer6 小时前
Vite:为什么选 Vite
前端
小御姐@stella6 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing6 小时前
【React】增量传输与渲染
前端·javascript·面试