前端框架中的设计模式 | 青训营

101 阅读4分钟

前端框架中的设计模式

在软件开发中,如果要保证编写代码的可读性和维护性,掌握设计模式是非常重要的。另外,如果我们不能对设计模式足够熟悉的话,当别人的代码用了大量设计模式的时候,也很难去读别人的框架或者源码。设计模式就是对软件开发过程中反复出现的某类问题的解决方案。众所周知,前端设计模式包括五大基本原则(SOLID)和 23 种设计模式,本文将介绍七种最常见的设计模式。

设计原则与思想

  • SOLID 原则
  1. 单一职责原则:一个类或者模块只负责完成一个职责(或者功能)。
  2. 开闭原则:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。
  3. 里式替换原则:子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
  4. 接口隔离原则:客户端不应该被强迫依赖它不需要的接口。
  5. 依赖反转原则:高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。
  • KISS原则:尽量保持简单。
  • YAGNI原则:不要做过度设计。
  • DRY原则:不要写重复的代码。
  • 迪米特法则:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。

单例模式

一个类只允许创建一个对象(或者实例),并供全局访问,这种设计模式就叫作单例设计模式,简称单例模式。有两种实现方法,首先都需要定义私有的静态属性,来保存对象实例,然后提供一个静态的方法来获取对象实例,代码如下:

// 方法一
class Singleton { 
    private static singleton: Singleton; 
    private constructor() {}   
    public static getInstance(): Singleton {
        if (!Singleton.singleton) { 
            Singleton.singleton = new Singleton(); 
        } 
        return Singleton.singleton;
    } 
}

// 方法二
class Singleton { 
    private static singleton: Singleton=new Singleton(); 
    private constructor() {} 
    public static getInstance(): Singleton { 
        return Singleton.singleton; 
    } 
}

// 使用
var instance1 = Singleton.getInstance();
var instance2 = Singleton.getInstance();

console.log(instance1 === instance2); // true

优点:

  • 单例模式能保证全局的唯一性,可以减少命名变量。
  • 单例模式在一定情况下可以节约内存,减少过多的类生成需要的内存和运行时间。
  • 把代码都放在一个类里面维护,实现了高内聚。

缺点:

  • 对 OOP 特性的支持不友好。
  • 会隐藏类之间的依赖关系。
  • 对代码的可测试性不友好。
  • 不支持有参数的构造函数。

工厂模式

工厂模式是一种通过工厂方法创建对象的设计模式。工厂模式可以分为:简单工厂模式、工厂方法模式和抽象工厂模式。下面介绍一下简单工厂模式:

function Phone(name) {
    this.name = name;
}

function createPhone(name) {
    if (name === "Android") {
      return new Phone("Android");
    } else if (name === "iPhone") {
      return new Phone("iPhone");
  }
}

// 使用
var tele1 = createPhone("Android");
var tele2 = createPhone("iPhone");

console.log(tele1.name); // "Android"
console.log(tele2.name); // "iPhone"

优点:只需要一个传入的参数,就可以获取到所需要的对象,而无需知道其创建的具体细节。

缺点:当内部逻辑比较复杂时,这个函数将会变得很庞大并且难以维护。

观察者模式

在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知,并自动更新,包括观察者和被观察者。

//观察者 
interface Observer { 
    notify: Function; 
} 

class ConcreteObserver implements Observer{ 
    constructor(private name: string) {} 
    notify() { 
        console.log(`${this.name} has been notified.`); 
    }
}

//被观察者
class Subject { 
    private observers: Observer[] = []; 
    public addObserver(observer: Observer): void { 
        console.log(observer, "is pushed!"); 
        this.observers.push(observer); 
    } 

    public deleteObserver(observer: Observer): void { 
        console.log("remove", observer); 
        const n: number = this.observers.indexOf(observer); 
        n != -1 && this.observers.splice(n, 1); 
    } 

    public notifyObservers(): void { 
        console.log("notify all the observers", this.observers); 
        this.observers.forEach(observer => observer.notify()); 
    }
}

//使用
const subject: Subject = new Subject(); 
const zhangsan = new ConcreteObserver("张三"); 
const lisi = new ConcreteObserver("李四"); 
subject.addObserver(zhangsan); 
subject.addObserver(lisi); 
subject.notifyObservers(); 
subject.deleteObserver(zhangsan); 
subject.notifyObservers();

优点

  • 建立抽象耦合,被观察者和观察者之间可以相互通信。
  • 支持广播通讯,被观察者可以向所有登记过的观察者发出通知。

缺点

  • 观察者之间细节依赖过多,会增加时间消耗和程序的复杂程度。
  • 观察者与被观察者之间如果循环依赖,会触发二者之间的循环调用,导致系统崩溃。

策略模式

定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。

var strategies = {
  "A": function () {
    console.log("Strategy A");
  },
  "B": function () {
    console.log("Strategy B");
  },
};

function Context(strategy) {
  this.strategy = strategy;
}

Context.prototype = {
  execute: function () {
    this.strategy();
  },
};

// 使用
var contextA = new Context(strategies["A"]);
var contextB = new Context(strategies["B"]);

contextA.execute(); // 输出 "Strategy A"
contextB.execute(); // 输出 "Strategy B"

优点

  • 提供管理算法族的办法。
  • 可以替换继承关系。
  • 避免使用多重条件转移语句,如冗长的 if-else 或 switch 分支判断。

缺点

  • 如果备选的策略很多的话,那么对象的数目就会很多。
  • 客户端必须知道所有的策略类,理解区别才能自行选择。

适配器模式

可以将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作,而不用更改这些类。

class GooleMap {
    show() {
        console.log('渲染地图')
    }
}

class BaiduMap {
    display() {
        console.log('渲染地图')
    }
}

class AdapterMap {
    show() {
        return new BaiduMap().display()
    }
}

优点

  • 让两个没有任何关系的类在一起运行。
  • 提高了复用度。
  • 灵活,可以适配不同格式的数据。

缺点

  • 使用过多适配器会导致系统杂乱。
  • 适配器模式需要增加一个额外的适配器类,增加了代码的量。

装饰者模式

装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。经常用到 Function.pototype.after 和 Function.pototype.before 两个函数进行装饰。

// 原始对象
function Component() {}

Component.prototype = {
  operation: function () {
    console.log("Component operation");
  },
};

// 装饰者对象
function Decorator(component) {
  this.component = component;
}

Decorator.prototype = {
  operation: function () {
    this.component.operation();
    console.log("Decorator operation");
  },
};

// 使用
var component = new Component();
component.operation(); // 输出 "Component operation"

var decorator = new Decorator(component);
decorator.operation(); // 输出 "Component operation" 和 "Decorator operation"

优点

  • 采用装饰模式扩展对象的功能比采用继承方式更加灵活。
  • 可以设计出多个不同的具体装饰类,创造出多个不同行为的组合。

缺点:装饰模式增加了许多子类,如果过度使用会使程序变得很复杂。

代理模式

前端设计模式中的代理模式是一种结构型模式,它允许在不改变原始对象的情况下,通过引入一个代理对象来控制对原始对象的访问。代理模式在前端中比较常用的虚拟代理和缓存代理。在前端开发中经常被用来处理一些复杂或者耗时的操作,例如图片的懒加载、缓存等。

const target = {
  method() {
    console.log("Target");
  }
};

const proxy = new Proxy(target, {
  get(target, prop) {
    console.log(`Called ${prop} method.`);
    return target[prop];
  }
});

// 使用示例
proxy.method(); // "Called method. Target"

优点

  • 分离目标对象: 代理模式能将代理对象与真实被调用的目标对象分离。
  • 降低耦合: 在一定程度上, 降低了系统耦合性, 扩展性好。
  • 增强目标对象: 代理类可以在目标对象基础上, 添加新的功能。

缺点

  • 代理模式会造成系统中类的个数增加, 系统的复杂度增加。
  • 在客户端和目标对象之间增加了一个代理对象, 会使请求处理速度变慢。

总结

学习设计模式更多的是理解各种模式的内在思想和解决的问题,最终的目的还是写出高质量的代码,保证代码的可维护性、可读性、可拓展性、可复用性。在不同的应用中,灵活处理,选用合适的设计模式,并且不滥用。