【前端八股】设计模式之二: 结构型模式

87 阅读13分钟

三、结构型模式(Structural Pattern)

结构型模式描述如何将类或者对象结合在一起形成更大的结构,就像搭积木,可以通过简单积木的组合形成复杂的、功能更为强大的结构。

结构型模式可以分为类结构型模式和对象结构型模式:

  • 类结构型模式关心类的组合,由多个类可以组合成一个更大的结构,在类结构型模式中一般采用继承机制来组织接口和类。
  • 对象结构型模式关心类与对象的组合,釆用组合或聚合来组合对象,然后通过该对象调用其方法。

由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象结构型模式比类结构型模式具有更大的灵活性。

介绍三个使用频率较高的结构型模式:

  • 装饰器模式(Decorator Pattern)
  • 适配器模式(Adapter Pattern)
  • 代理模式(Proxy Pattern)

1. 装饰器模式(Decorator Pattern)

在我们日常的开发过程中,一个最常见的场景就是在已有的基础上新增功能,常规的做法有以下几种:

  • 修改已有的类:违背开闭原则。
  • 增加新的子类:每次都得新增大量对应的类,随着功能的增加,子类越来越膨胀。

在此场景下,装饰器模式就可以体现出它的优势了,它允许在不修改原有对象的前提下,灵活的扩展已有类的功能。就像美颜APP一样,我们使用很多滤镜对照片进行功能增强。这一个个的滤镜就嵌套在原有的照片上,就像一个个功能增强类嵌套在原有的基础类上一样。装饰模式的结构性就体现在这种嵌套关系上,一层层的嵌套组成了一种结构。

下面是装饰器模式的一个通用的类图:

image.png

装饰模式包含如下角色:

  • 抽象组件(Component): 可以是接口或者抽象类,它定义了具体类以及装饰器所拥有的方法。
  • 具体组件(ConcreteComponent):具体的组件,实现或者继承自抽象组件。可以理解成上述场景中已存在的类。
  • 抽象装饰器(Decorator): 通常为抽象类,持有一个被装饰的对象,定义了具体装饰器的方法。此类非必须也可以没有,具体装饰器也可直接继承或者实现抽象组件。
  • 具体装饰器(ConcreteDecoratorA, ConcreteDecoratorB): 具体的装饰器,继承自抽象装饰器(也可直接继承自抽象组件),扩展了抽象组件的某些功能。

代码.png 代码实现

// 定义一个咖啡的基类(Component) 
class Coffee { 
    getDescription() { 
        return 'Coffee'; 
    }

    getCost() {
        return 5; // 假设基础咖啡的价格是5元 
    } 
} 

// 定义一个装饰器基类(Decorator),它扩展了Coffee类 
class CoffeeDecorator extends Coffee { 
    constructor(coffee) { 
        super(); // 用于访问和调用一个对象上的父对象上的函数
        this.coffee = coffee; // 持有对原始咖啡对象的引用 
    } 
} 

// 具体的装饰器类:加糖装饰器(SugarDecorator)
class SugarDecorator extends CoffeeDecorator { 
    getDescription() { 
        return this.coffee.getDescription() + ', Sugar'; 
    } 
    getCost() { 
        return this.coffee.getCost() + 1; // 加糖增加1元 
    } 
}

// 具体的装饰器类:加牛奶装饰器(MilkDecorator) 
class MilkDecorator extends CoffeeDecorator { 
    getDescription() { 
        return this.coffee.getDescription() + ', Milk'; 
    } 
    getCost() { 
        return this.coffee.getCost() + 2; // 加牛奶增加2元 
    }
}

// 使用装饰器来创建不同口味的咖啡 
let simpleCoffee = new Coffee();
console.log(simpleCoffee.getDescription()); // 输出: Coffee 
console.log(simpleCoffee.getCost()); // 输出: 5

let sugarCoffee = new SugarDecorator(simpleCoffee); 
console.log(sugarCoffee.getDescription()); // 输出: Coffee, Sugar 
console.log(sugarCoffee.getCost()); // 输出: 6 

let milkCoffee = new MilkDecorator(sugarCoffee);
console.log(milkAndSugarCoffee.getDescription()); // 输出: Coffee, Milk, Sugar 
console.log(milkAndSugarCoffee.getCost()); // 输出: 8

// 也可以先加牛奶再加糖 
let milkAndSugarCoffee = new SugarDecorator(new MilkDecorator(simpleCoffee)); 
console.log(milkAndSugarCoffee.getDescription()); // 输出: Coffee, Milk, Sugar 
console.log(milkAndSugarCoffee.getCost()); // 输出: 8

优_点赞.png 优点

  • 装饰模式与继承关系的目的都是要扩展对象的功能,但是装饰模式可以提供比继承更多的灵活性。
  • 可以通过一种动态的方式来扩展一个对象的功能,通过配置文件可以在运行时选择不同的装饰器,从而实现不同的行为。
  • 通过使用不同的具体装饰类以及这些装饰类的排列组合,可以创造出很多不同行为的组合。可以使用多个具体装饰类来装饰同一对象,得到功能更为强大的对象。
  • 具体构件类与具体装饰类可以独立变化,用户可以根据需要增加新的具体构件类和具体装饰类,在使用时再对其进行组合,原有代码无须改变,符合“开闭原则”。

缺点.png 缺点

  • 使用装饰模式进行系统设计时将产生很多小对象,这些对象的区别在于它们之间相互连接的方式有所不同,而不是它们的类或者属性值有所不同,同时还将产生很多具体装饰类。这些装饰类和小对象的产生将增加系统的复杂度,加大学习与理解的难度。
  • 这种比继承更加灵活机动的特性,也同时意味着装饰模式比继承更加易于出错,排错也很困难,对于多次装饰的对象,调试时寻找错误可能需要逐级排查,较为烦琐。

2. 适配器模式(Adapter Pattern)

适配器模式用于将一个类的接口转换成客户端所期望的另一个接口。适配器模式允许不兼容的接口能够一起工作。

如果去欧洲国家去旅游的话,他们的插座如下图最左边,是欧洲标准。而我们使用的插头如下图最右边的。因此我们的笔记本电脑,手机在当地不能直接充电。所以就需要一个插座转换器,转换器第1面插入当地的插座,第2面供我们充电,这样使得我们的插头在当地能使用。生活中这样的例子很多,手机充电器(将220v转换为5v的电压),读卡器等,其实就是使用到了适配器模式。

image.png

适配器模式有对象适配器和类适配器两种实现:

对象适配器:

../_images/Adapter.jpg

类适配器:

../_images/Adapter_classModel.jpg

适配器模式角色组成:

  • 目标接口(Target Interface):客户端所期望的接口,适配器将其转换成适配者的接口。
  • 适配者(Adaptee):需要被适配的接口或类。
  • 适配器(Adapter):将适配者的接口转换成目标接口的类。

代码.png 代码实现

// 假设有一个旧的接口,老旧的打印机类 
class OldPrinter { 
    printOld(text) { 
        console.log(`Old Printer: ${text}`); 
    } 
} 

// 新接口,现代打印机类(假设客户端代码使用这个接口) 
class ModernPrinter { 
    print(text) { 
        // 假设这是现代打印机应该实现的方法 
        console.log(`Modern Printer: ${text}`); 
    } 
} 

// 适配器类,它将OldPrinter适配为ModernPrinter 
class PrinterAdpter {
    constructor(OldPrinter) {
        this.OldPrinter = OldPrinter;
    }
    
    // 实现ModernPrinter的print方法,但内部调用OldPrinter的printOld方法 
    print(text) {
        this.oldPrinter.printOld(text);
    }
}


// 客户端代码,它期望使用ModernPrinter接口 
function printDocument(printer, text) { 
    printer.print(text); 
} 

// 创建OldPrinter实例 
const oldPrinter = new OldPrinter(); 
// 使用适配器将OldPrinter适配为ModernPrinter 
const adapter = new PrinterAdapter(oldPrinter);
// 客户端代码不知道它正在使用一个适配器 
printDocument(adapter, "Hello, this is a test document."); 
// 输出: Old Printer: Hello, this is a test document.

在这个示例中:

  • OldPrinter 类代表了一个具有旧接口的类,它的 printOld 方法用于打印文本。
  • ModernPrinter 类代表了一个具有新接口的类,它的 print 方法是客户端代码所期望的。
  • PrinterAdapter 类是一个适配器,它接受一个 OldPrinter 实例作为参数,并实现了 ModernPrinter 接口的 print 方法。在 print 方法内部,它调用了 OldPrinter 的 printOld 方法。
  • printDocument 函数是一个客户端函数,它期望接收一个实现了 ModernPrinter 接口的对象,并调用其 print 方法。

通过适配器,客户端代码可以无缝地使用旧接口的对象,而无需知道适配器或旧接口的存在。这种方式使得旧代码可以与新系统或新接口兼容,从而延长了旧代码的使用寿命。

优_点赞.png 优点

  • 将目标类和适配者类解耦,通过引入一个适配器类来重用现有的适配者类,而无须修改原有代码。
  • 增加了类的透明性和复用性,将具体的实现封装在适配者类中,对于客户端类来说是透明的,而且提高了适配者的复用性。
  • 灵活性和扩展性都非常好,通过使用配置文件,可以很方便地更换适配器,也可以在不修改原有代码的基础上增加新的适配器类,完全符合“开闭原则”。

类适配器模式还具有如下优点:

  • 由于适配器类是适配者类的子类,因此可以在适配器类中置换一些适配者的方法,使得适配器的灵活性更强。

对象适配器模式还具有如下优点:

  • 一个对象适配器可以把多个不同的适配者适配到同一个目标,也就是说,同一个适配器可以把适配者类和它的子类都适配到目标接口。

缺点.png 缺点

类适配器模式的缺点如下:

  • 对于Java、C#等不支持多重继承的语言,一次最多只能适配一个适配者类,而且目标抽象类只能为抽象类,不能为具体类,其使用有一定的局限性,不能将一个适配者类和它的子类都适配到目标接口。

对象适配器模式的缺点如下:

  • 与类适配器模式相比,要想置换适配者类的方法就不容易。如果一定要置换掉适配者类的一个或多个方法,就只好先做一个适配者类的子类,将适配者类的方法置换掉,然后再把适配者类的子类当做真正的适配者进行适配,实现过程较为复杂。

场景打开.png 应用场景

  • 以前开发的系统存在满足新系统功能需求的类,但其接口同新系统的接口不一致。
  • 使用第三方提供的组件,但组件接口定义和自己要求的接口定义不同。

3. 代理模式(Proxy Pattern)

代理模式是给某一个对象提供一个代理,并由代理对象控制对原对象的引用。

在某些情况下,一个客户不想或者不能直接引用一个对象,此时可以通过一个称之为“代理”的第三者来实现间接引用。代理对象可以在客户端和目标对象之间起到中介的作用,并且可以通过代理对象去掉客户不能看到的内容和服务或者添加客户需要的额外服务。

image.png

代理模式包含如下角色:

  • 抽象主题(Subject)类: 通过接口或抽象类声明真实主题和代理对象实现的业务方法。
  • 真实主题(RealSubject)类: 实现了抽象主题中的具体业务,是代理对象所代表的真实对象,是最终要引用的对象。
  • 代理(Proxy)类 : 提供了与真实主题相同的接口,其内部含有对真实主题的引用,它可以访问、控制或扩展真实主题的功能。

代码.png 代码实现

JavaScript
// 真实主题:小美
const xiaomei = {
    name: "小美",
    age: 18,
    receiveFlower(sender) {
        console.log(`${sender.name} 送你一朵花`);
    }
};

// 代理:小红
const xiaohong = {
    name: "小红",
    age: 18,
    realSubject: xiaomei, // 持有真实主题的引用
    receiveFlower(sender) {
        // 在调用真实主题的方法前添加额外的逻辑
        console.log("小红收到了花,准备转交给小美");
        setTimeout(() => {
            this.realSubject.receiveFlower(sender); // 调用真实主题的方法
        }, 2000); // 模拟延迟转交的过程
    }
};

// 客户端代码:戴先生送花给小美(通过小红代理)
const dai = { name: "戴先生" };
xiaohong.receiveFlower(dai); // 输出:小红收到了花,准备转交给小美(2秒后)戴先生 送你一朵花

在这个示例中,我们创建了一个真实主题对象xiaomei和一个代理对象xiaohong。代理对象持有一个真实主题的引用,并在调用receiveFlower方法时添加了额外的逻辑(模拟延迟转交的过程)。这样,当客户端代码调用xiaohong.receiveFlower(dai)时,实际上是通过代理对象来间接访问真实主题的方法。

优_点赞.png 优点

  • 控制访问:通过代理对象,我们可以控制对真实对象的访问,实现访问控制、权限验证等功能。
  • 增加额外功能:代理对象可以在调用真实对象的方法前后添加额外的逻辑,如日志记录、性能统计等。
  • 保护真实对象:代理对象可以作为真实对象的替代品,防止真实对象被直接访问和修改,从而提高系统的安全性。
  • 提高性能:在某些情况下,代理对象可以缓存真实对象的结果或进行预处理,从而提高系统的性能。

缺点.png 缺点

  • 由于在客户端和真实主题之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢。
  • 实现代理模式需要额外的工作,有些代理模式的实现非常复杂。

场景打开.png 应用场景

(1) 虚拟代理

使用虚拟代理可以延迟加载资源或延迟执行昂贵的操作,从而提高页面的响应速度和用户体验。例如,在图片加载时可以使用虚拟代理,只有当图片需要显示时才实际加载图片数据。

案例: 实现前端图片预加载

🏝 图片预加载

预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。

常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 — 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。

此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。

(2) 缓存代理

使用缓存代理可以缓存计算结果,避免重复的计算或请求。例如,可以使用缓存代理来缓存网络请求的结果,如果下次请求相同的资源,则直接返回缓存的结果而不用再次发送请求。