yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

前端开发中常用的设计模式

yinguangyao opened this issue · comments

commented

1. 前言

设计模式(Design Pattern)不是软件开发圣经,更像是一种经验之谈,这是前辈们在无数实践中总结出来的一些方法,用来提高代码的可读性、可复用性、可维护性等。
1995 年,GoF(Gang of Four)出版了《设计模式:可复用面向对象软件的基础》这本书,里面一共收录了 23 种设计模式,从此设计模式深入人心。
本文会介绍几种常用的模式,还有一些实用的设计模式都分散在了后面其他的文章中,这里就不一一做介绍了。

2. 模式分类

根据设计模式的用途,可以将他们分为三类,分别是创型性、结构型和行为型。

2.1 创建型

创建型模式一般是将对象的创建和使用进行分离,外界只知道他们暴露的接口,却不知道实现细节,结构更加清晰。

  1. 单例模式
  2. 简单工厂模式
  3. 工厂方法模式
  4. 抽象工厂模式
  5. 生成器模式
  6. 原型模式

2.2 结构型

结构型模式描述了如何将类和对象组合成一个更大的结构,就像我们写 React/Vue 组件一样,很像搭积木,将简单的功能组合成一个复杂的结构。

  1. 适配器模式
  2. 桥接模式
  3. 享元模式
  4. 代理模式
  5. 外观模式
  6. 装饰器模式
  7. 组合模式

2.3 行为型

行为型模式是对不同的对象之间划分责任和算法的抽象化。行为型模式更加关注不同对象之间的相互作用。

  1. 职责链模式
  2. 解释器模式
  3. 观察者模式
  4. 策略模式
  5. 访问者模式
  6. 命令模式
  7. 迭代器模式
  8. 中介者模式
  9. 备忘录模式
  10. 状态模式
  11. 模板方法模式

3. 单例模式

单例模式可能是前端开发中比较简单但又最常用的一种设计模式,这是维基百科对单例模式的介绍:

在应用单例模式时,生成单例的类必须保证只有一个实例的存在,很多时候整个系统只需要拥有一个全局对象,才有利于协调系统整体的行为。比如在整个系统的配置文件中,配置数据有一个单例对象进行统一读取和修改,其他对象需要配置数据的时候也统一通过该单例对象来获取配置数据,这样就可以简化复杂环境下的配置管理。

单例模式一般是指我们从不同的地方读取系统中的这个对象,最后拿到的都是同一个引用,也就是只有一个实例。这样在一个地方对单例进行了修改,另一个地方也会保持同步。

3.1 对象字面量

在JS里面,一个对象是独一无二的,所以创建一个对象字面量也是创建了一个单例。

const obj = {
    name: '',
    age: 0
}

我们不管从哪里访问这个对象,最后拿到的都是同一份引用。这种方式的缺点就是所有属性都是暴露出来的,没有私有属性,缺少封装。

3.2 闭包写法

由于在es6之前,js中并没有类的概念,因此我们也可以使用闭包来实现单例模式。
通过在立即执行函数中创建一个私有属性instance,在第一次执行getInstance的时候对instance赋值,这样利用闭包实现了一个单例。

var single = (function(){
    var instance;
    function getInstance(){
        if( !instance ){
            instance = new Construct();
        }
        return instance;
    }
    function Construct(){
        // 单例构造函数
    }
    return {
        getInstance : getInstance
    }
})();
var instance1 = single.getInstance(),
    instance2 = single.getInstance();
console.log(instance1 === instance2); // true

但这种实现形式有些繁琐,容易让人一眼看上去摸不着头脑。

3.3 es6 写法

es6引用了class和extends的概念,我们完全可以用es6来更加简洁地实现单例模式。
最简单的class单例模式和对象字面量的原理基本一样。只要在导出的地方做new的操作就可以保证每次引用的都是这一个对象。

class Single {
    constructor() {
        // 单例构造函数
    }
}
export default new Single

但是这种模式不适合一些场景。比如第一次初始化实例的时候,我想传参,但这种形式不被允许。

class Single {
    static instance = null;
    static getInstance() {
        if (!Single.instance) {
            return Single.instance = new Single();
        }
        return Single.instance;
    }
    constructor() {
        // 单例构造函数
    }
}
const s1 = Single.getInstance(),
    s2 = Single.getinstance();
console.log(instance1 === instance2); // true

但是这种方式有个问题,如果别人对Single类直接做new的操作,最后依然得到了两个不同的实例,这就不算单例模式了。

这里我们可以使用工厂模式进行一下封装,最后导出这个工厂方法,这样就能得到一致的实例。

const singleFactory = (...rest) => {
    return Single.getInstance(...rest)
}

3.4 应用场景

单例模式在平时开发中应用的场景也有不少,最典型的莫过于在发布订阅中使用。

我们需要在一个地方进行订阅,在另一个地方进行发布,这样就需要保证在不同文件中访问到的是同一个实例。

class PubSub {
    constructor() {
        this.listeners = {}
    }
    publish(event, data) {
        if (!this.listeners[event]) {
            return;
        }
        this.listeners[event].forEach(listener => {
            listener(data);
        })
    }
    subscribe(event, callback) {
        if (!this.listeners[event]) {
            this.listeners[event] = []
        }
        this.listeners[event].push(callback);
    }
}
export default new PubSub

除此之外,在数据库连接以及 react native 的缓存封装中也有用到单例模式,当然这些已经不属于我们要讲的范围。

4. 工厂模式

工厂模式是一种将对象的创建与对象的实现分离的设计模式,常被用来创建同一类对象。
在前端开发中,工厂模式比较常见。例如我们常用的创建 DOM 节点的方法 createElement 就用了工厂模式,它可以根据传入的不同参数来创建不同的对象。

const div = document.createElement('div'); // 创建 div
const span = document.createElement('span'); // 创建 span

4.1 简单工厂模式

上述的 createElement 就是简单工厂模式,简单工厂模式一般是根据输入的类型,来判断生成不同的产品。比如去饭店吃饭,他们会做很多不同种类的饭菜。

const RestaurantFactory = (type) => {
    switch(type) {
        case "fish":
            console.log('酸菜鱼');
            break;
        case "chicken":
            console.log('白切鸡');
            break;
        case "tofu":
            console.log('麻婆豆腐');
            break;
        default:
            console.log("招牌菜");
    }
}

4.1 工厂方法模式

工厂方法模式也叫工厂模式,就是定义好一个生产某种产品的工厂抽象类,不同的产品通过不同的共产来生产。比如可乐厂会生产可乐,但是百事可乐和可口可乐会生产不同的可乐。

// 抽象工厂
class Factory {
    createCola() {
        console.log('生产可乐');
        return new Cola();
    }
}
// 百事可乐工厂
class PesiColaFactory extends Factory {
    createCola() {
        console.log('生产百事可乐');
        return new PesiCola();
    }
}
// 可口可乐工厂
class CocaColaFactory extends Factory {
    createCola() {
        console.log('生产可口可乐');
        return new CocaCola();
    }
}
const pesiCol = PesiColaFactory.createCola();
const cocaCola = CocaColaFactory.createCola();

优点:

  1. 用户只需要关注产品是哪个工厂生产的,不需要关心实现细节。
  2. 加入新产品的时候,不需要对原有代码进行修改,只需要增加新的工厂就行了,符合开闭原则。

缺点:

  1. 缺点就是每次增加新产品的时候,还要增加一个工厂类,增加了系统的复杂度。

4.2 抽象工厂模式

抽象工厂模式和工厂模式的不同在于生产产品的工厂也是抽象的,比如一个可乐工厂可能不仅生产可乐,还会生产可乐瓶、包装盒等等。

// 可乐主题工厂
class ColaFactory {
    createCola() {
        console.log('生产可乐');
        return new Cola();
    }
    createBottle() {
        console.log('生产可乐瓶子');
        return new Bottle();
    }
    createBox() {
        console.log('生产可乐盒子');
        return new Box();
    }
}
// 百事可乐主题工厂
class PesiColaFactory extends Factory {
    createCola() {
        console.log('生产百事可乐');
        return new PesiCola();
    }
    createBottle() {
        console.log('生产百事可乐瓶子');
        return new PesiColaBottle();
    }
    createBox() {
        console.log('生产百事可乐盒子');
        return new PesiColaBox();
    }
}
// 可口可乐主题工厂
class CocaColaFactory extends Factory {
    createCola() {
        console.log('生产可口可乐');
        return new CocaCola();
    }
    createBottle() {
        console.log('生产可口可乐瓶子');
        return new CocaColaBottle();
    }
    createBox() {
        console.log('生产可口可乐盒子');
        return new CocaColaBox();
    }
}

const pesiCola = PesiColaFactory.createCola(),
    pesiColaBottle = PesiColaFactory.createBottle(),
    pesiColaBox = PesiColaFactory.createBox();

const cocaCola = CocaColaFactory.createCola(),
    cocaColaBottle = CocaColaFactory.createBottle(),
    cocaColaBox = CocaColaFactory.createBox();

抽象工厂模式的缺点就是,如果需要增加新的产品,比如要生产可乐周边玩偶,这就需要对原有的抽象工厂接口进行改造,不符合开闭原则。

5. 观察者模式

观察者模式也是前端开发中常用的一种模式,它定义了对象之间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
观察者模式常用于事件通信,在 Vue 和 React 中得到广泛的使用。
很多人分不清观察者模式和发布订阅模式的区别,这张图展示了两者的区别:

image_1e0qb6ftc1dod17ncu4ofg1v4m9.png-29.2kB
不论是观察者模式还是发布订阅模式,它们的实现核心**都是事先将注册的函数放到一个数组中,在合适的时机一个个触发。

5.1 观察者模式

我们在开发中经常用到的 addEventListener 方法就属于观察者模式的一种,一般会注册一个观察者,当观察到目标事件的时候,就会进行一些处理。

/* cb 函数相当于注册了一个观察者,观察到 click 事件的时候就会执行 */
ele.addEventListener('click', function cb() {})

观察者模式主要包括观察者 observer 和目标 subject 两部分,观察者注册到目标中,当目标通知的时候会执行所有的观察者,两者实际上是耦合起来的。

class Subject {
    constructor() {
        this.observerList = [];
    }
    add(observer) {
        this.observerList.push(observer);
    }
    remove(observer) {
        this.observerList = this.observerList.filter(ob => ob !== observer);
    }
    notify(...args) {
        this.observerList.forEach(observer => observer(...args));
    }
}
const subject = new Subject(),
    observer1 = () => console.log('this is observer1'),
    observer2 = () => console.log('this is observer2');
subject.add(observer1); // 注册观察者
subject.add(observer2); // 注册观察者
subject.notify(); // 通知观察者

5.2 发布订阅模式

由上面的图上可以看出来,发布订阅模式和观察者模式的区别就在于发布订阅模式增加了一个调度中心,观察者的注册和事件通知都由调度中心来处理,这样就可以将观察者和目标进行解耦。

class PubSub {
    constructor() {
        this.listeners = {}
    }
    subscribe(type, fn) {
        if (!this.listeners[type]) {
          this.listeners[type] = [];
        }
        this.listeners[type].push(fn);
    }
    publish(type, ...args) {
        let listeners = this.listeners[type];
        if (!listeners || !listeners.length) return;
        listeners.forEach(listener => listener(...args));        
    }
}

let ob = new PubSub();
ob.subscribe('click', (val) => console.log(val));
ob.publish('click', "hello, world");

发布订阅模式可以用于 React/Vue 中的组件通信,React 新的 Context API 就是发布订阅的一种。

6. 模板方法模式

顾名思义,模板方法模式就是让你的代码和模板一样固定,就是父类定义一个算法的骨架,子类不用修改结构,只是去实现具体的每一步。
模板方法模式的 UML 类图如下:

image_1e1c7j9nv1n5s1f54qcvll51f329.png-25.8kB

以一个上班族的日常为例,作为一个社畜,我们每天的行为都比较固定,无非是起床、上班、午饭、上班、下班这几种行为,但不同人的行为是不一样的,比如午饭吃的东西不一样上班做的事情也不一样等等。
因此,可以设定一个具体的结构,再到不同的子类中去实现具体的每一步。
先来固定一下一个上班族日常的生活模式,定义好一个 DailyOfWorker 的类,这个类有一个 startNewDay 的具体方法,这个方法就是算法的骨架。 getUpstartWork 等几个抽象方法就是需要子类去实现的具体细节。

class DailyOfWorker {
    getUp() {}
    startWork() {}
    haveLunch() {}
    continueWork() {}
    goOffWork() {}
    startNewDay() {
        this.getUp(); // 起床
        this.startWork(); // 上班
        this.haveLunch(); // 午饭
        this.continueWork(); // 继续上班
        this.goOffWork(); // 下班
    }
}

再来实现子类,在这里就是指不同职位的人,比如程序员和医生。

class Programmer {
    getUp() {
        console.log("程序员起床了")
    }
    startWork() {
        console.log("开始敲代码")
    }
    haveLunch() {
        console.log("喝一杯82年的Java")
    }
    continueWork() {
        console.log("继续敲代码")
    }
    goOffWork() {
        console.log("下班?那是不可能的")
    }
}
class Doctor {
    getUp() {
        console.log("医生起床了")
    }
    startWork() {
        console.log("开始接待病人")
    }
    haveLunch() {
        console.log("去吃个午饭")
    }
    continueWork() {
        console.log("继续接待病人")
    }
    goOffWork() {
        console.log("下班咯")
    }
}

可以看到模板方法模式还是比较简单的,主要是在有一些通用的方法时,将这些方法给抽象出来。

模板方法模式的优点是:

  1. 封装不变部分,扩展可变部分
  2. 将公共逻辑抽离出来,便于维护
  3. 整体行为由父类来控制,子类负责实现

当然也有一些缺点:

  1. 具体方法需要子类来实现,这样就导致系统中子类个数增加。

推荐阅读

  1. JavaScript设计模式与开发实践
  2. 那些你不经意间使用的设计模式(一) - 创建型模式
  3. 切图仔最后的倔强:包教不包会设计模式 - 结构型