装饰模式
装饰模式是一种结构型设计模式,是前端开发中算的上使用频率能排的上前三的设计模式了。如果你掌握好了装饰模式,能够有效的组织一些前端代码,从而提高代码的复用能力。
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
函数的实现方式,在实际开发中不推荐,比如某些团队明确禁止在原型上扩展内容,因为这种操作可能带来一些潜在的隐患。