努力让学习成为一种习惯,自信来源于充分的准备
如果你觉得该文章对你有帮助,欢迎大家点赞、关注和分享
前言
当我们需要扩展对象的能力,通常有这几种方案:修改构造函数,添加原型方法,继承。前两种方式会直接修改内部代码。耦合性增加的同时,进一步提高了开发、维护成本。继承看似没有问题。但如果针对一个扩展场景就派生出一个新的子类。那针对多个扩展场景(包括多个混合的场景),会有很多重复代码,扩展性不好(做不到即插即用)
那么有没有一种方案既可以不侵入式的扩展功能,扩展的方式也会更加方便、优雅呢,答案就是装饰器模式
装饰器模式
装饰器模式的核心是:在不侵入原有代码逻辑的情况下,扩展其功能(或者是不改变对象的基础,扩展对象功能以满足业务新的功能)
相信大家都玩过类似DOTA2、LOL的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();
可以发现,猛犸的敏捷和攻击力都增加了
另外,我们游戏中会有各种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();
在这里需要额外提下的是装饰函数showDecorate的三个形参target、name、descriptor,我们可以打印看下
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')
}
}
可见target为Base.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()
上面的例子相比之前更加的清爽简洁。我们没有修改hero相关内部的代码。只是扩展了它的功能。如果哪一天我们不需要了,把对应装饰器去除即可。做到了“即插即用”。同样将要又来一个需求要求给英雄buff功能
那么我们可以非常快速的完成
// 添加装备装饰器
@EquipmentDecorate
// 添加buff装饰器
@BuffDecorate
class Hero implements HeroBase {
// 之前的逻辑
xxxx
}
前端实际场景的运用
装饰器的UML图如下
相关具体规范实现代码如下
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事件),我们需要监听路由变化,并在里面上报埋点
前端路由库一般有两种模式:hash、history。这两种方式都可以实现更换页面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))
})
这样就可以监听到pushState、replaceState事件了,然后在里面放上报埋点相关的逻辑,和之前class方法的装饰器写法是不是有些类似。其实本质都是一样的!
总结
前端领域中运用装饰器主要有以下几种方式
-
使用高阶函数结合闭包(防抖节流、对一些第三方或者原生API的扩展)
-
使用ECMAScript支持的Decorate语法,写法会更加简洁。但是它最大的问题是:用来增强 JavaScript 类(class)的功能。而我们前端开发大部分都是函数式编程。特别是现在
vue与react项目中,我们代码大部分都是各种hook -
借鉴后端
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()
但这种方法并不建议使用,扩展原生对象的原型可能会带来潜在的风险,容易产生不宜排查的bug
无论是哪种方式,我们都需要明白装饰器模式的本质就是一个函数。它的核心在于:不修改原本的内容,扩展其能力,可以“即插即用”
最后
还是那句话,设计模式重在理解它的思想,以及解决了什么样场景的问题。相信大家看到了这里,也会举一反三,联想出或者已经发觉自己之前就在很多场景用过装饰器模式了
到这里,就是本篇文章的全部内容了
如果你觉得该文章对你有帮助,欢迎大家点赞、关注和分享
如果你有疑问或者出入,评论区告诉我,我们一起讨论