前端常用的设计模式

248 阅读23分钟

设计模式在前端项目中的应用

前端的设计模式是什么

设计模式一个比较宏观的概念,通俗来讲,它是软件开发人员在软件开发过程中面临的一些具有代表性问题的解决方案。

当然,在实际开发中不用设计模式同样也是可以实现需求的,只是在业务逻辑比较复杂的情况下,代码可读性及可维护性变差。

所以随着业务逻辑的扩展,了解常用设计模式解决问题是非常有必要的。

前端的设计模式的基本准则
  • 单一职责原则:每个类只需要负责自己的那部分,类的复杂度降低。
  • 开闭原则:一个实体,如类、模块和函数应该对扩展开放,对修改关闭,让程序更稳定更灵活。
  • 里式替换原则:所有引用基类的地方必须能透明地使用其子类的对象,也就是说子类对象可以替换其父类对象,而程序执行效果不变。便于构建扩展性更好的系统。
  • 依赖倒置原则:上层模块不应该依赖底层模块,它们都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。这可以让项目拥有变化的能力。
  • 接口隔离原则:多个特定的客户端接口要好于一个通用性的总接口,系统有更高的灵活性。
  • 迪米特原则(最少知识原则):一个类对于其他类知道的越少越好,也就是说一个对象应当对其他对象有尽可能少的了解。
设计模式的种类
1、 创建型模式

一般用于创建对象。 包括:单例模式,工厂方法模式,抽象工厂模式,建造者模式,原型模式。

2、结构型模式

重点为“继承”关系,有着一层继承关系,且一般都有“代理”。 包括:适配器模式,桥接模式,组合模式,装饰器模式,外观模式,享元模式,代理模式,过滤器模式。

3、行为型模式

职责的划分,各自为政,减少外部的干扰。 包括:命令模式,解释器模式,迭代器模式,中介者模式,备忘录模式,观察者模式,状态模式,策略模式,模板方法模式,访问者模式,责任链模式。

前端常用的计模式应用实例
1、单例模式

单例模式又称为单体模式,保证一个类只有一个实例,并提供一个访问它的全局访问点。一个极有可能重复出现的“实例”, 如果重复创建,将会产生性能消耗。如果借助第一次的实例,后续只是对该实例的重复使用,这样就达到了我们节省性能的目的。

image-20250210095722-cv138mv.png ElementUI 中的全屏 Loading 蒙层调用有两种形式:

  • 指令形式:Vue.use(Loading.directive)
  • 服务形式:Vue.prototype.$loading = service

指令形式注册的使用方式 :

<div :v-loading.fullscreen="true">...</div>;

服务形式注册的使用方式 :

this.$loading({ fullscreen: true });

用服务方式使用全屏 Loading 是单例的,即在前一个全屏 Loading 关闭前再次调用全屏 Loading,并不会创建一个新的 Loading 实例,而是返回现有全屏 Loading 的实例。

下面是 ElementUI 实现全屏 Loading 的源码:

import Vue from 'vue'
import loadingVue from './loading.vue'
const LoadingConstructor = Vue.extend(loadingVue)
let fullscreenLoading
const Loading = (options = {}) => {
    if (options.fullscreen && fullscreenLoading) {
        return fullscreenLoading
    }
    let instance = new LoadingConstructor({
        el: document.createElement('div'),
        data: options
    })
    if (options.fullscreen) {
        fullscreenLoading = instance
    }
    return instance
}
export default Loading

这里的单例是 fullscreenLoading,是存放在闭包中的,如果用户传的 options 的 fullscreen 为 true 且已经创建了单例,则直接返回之前创建的单例,如果之前没有创建过,则创建单例并赋值给闭包中的 fullscreenLoading 后返回新创建的单例实例。

这是一个典型的单例模式的应用,通过复用之前创建的全屏蒙层单例,不仅减少了实例化过程,而且避免了蒙层叠加蒙层出现的底色变深的情况。

优缺点

优点

  • 节约开支,提高性能: 单例模式在创建后在内存中只存在一个实例,节约了内存开支和实例化时的性能开支,特别是需要重复使用一个创建开销比较大的类时,比起实例不断地销毁和重新实例化,单例能节约更多资源,比如数据库连接;
  • 解决资源多重占用: 单例模式可以解决对资源的多重占用,比如写文件操作时,因为只有一个实例,可以避免对一个文件进行同时操作;
  • 提高系统流畅度: 只使用一个实例,也可以减小垃圾回收机制 GC(Garbage Collecation) 的压力,表现在浏览器中就是系统卡顿减少,操作更流畅,CPU 资源占用更少;

缺点:

  • 对扩展不友好​:一般不容易扩展,因为单例模式一般自行实例化,没有接口;
  • 与单一职责原则冲突​:一个类应该只关心内部逻辑,而不关心外面怎么样来实例化;

适用场景

常见的单例(而非单例模式的应用):

  • 浏览器中的 window 和 document 全局变量​,这两个对象都是单例,任何时候访问他们都是一样的对象,window 表示包含 DOM 文档的窗口,document 是窗口中载入的 DOM 文档,分别提供了各自相关的方法。
  • 在 ES6 新增语法的 Module 模块特性,通过 import/export 导出模块中的变量是单例的​,也就是说,如果在某个地方改变了模块内部变量的值,别的地方再引用的这个值是改变之后的。
  • 项目中的全局状态管理模式 ​Vuex 维护的全局状态, ​vue-router`维护的路由实,在单页应用的单页面中都属于单例的应用。
  • 模态框/弹窗管理器​:确保在同一时间只有一个模态框或弹窗处于打开状态。
2、工厂模式

工厂模式提供了一种创建对象的方法,对使用方隐藏了对象的具体实现细节,并使用一个公共的接口来创建对象。

image-20250210105024-ydhye43.png 前端本地存储目前最常见的方案就是使用 localStorage,为了避免在业务代码中各种 getItem 和 setItem,我们可以做一下最简单的封装。

let themeModel = {
    name: "local_theme",
    get() {
        let val = localStorage.getItem(this.name);
        return val && jsON.parse(val);
    },
    set(val) {
        localStorage.setItem(this.name, jsON.stringify(val));
    },
    remove() {
        localStorage.removeItem(this.name);
    },
};
themeModel.get();
themeModel.set({ darkMode: true });

这样,通过 themeModel 暴露的 get、set 接口,我们无需再维护 local_theme。但上面的封装也存在一些可见的问题,如果需要新增多个 name,那么上面的模板代码需要重新写多遍吗?为了解决这个问题,我们可以创建 Model 对象的逻辑进行封装。

const storageMap = new Map()
function createStorageModel(key, storage = localStorage) {
    // 相同key返回单例
    if (storageMap.has(key)) {
        return storageMap.get(key);
    }

    const model = {
        key,
        set(val) {
            storage.setItem(this.key, JSON.stringify(val););
        },
        get() {
            let val = storage.getItem(this.key);
            return val && JSON.parse(val);
        },
        remove() {
            storage.removeItem(this.key);
        },
    };
    storageMap.set(key, model);
    return model;
}

const themeModel =  createStorageModel('local_theme', localStorage)
const utmSourceModel = createStorageModel('utm_source', sessionStorage)

这样,我们就可以通过 createStorageModel 这个公共的接口来创建各种不同本地存储的对象,而无需关注创建对象的具体细节。

优缺点

优点

  • 封装性好​:工厂模式可以将对象的创建和使用分离,客户端只需要知道使用工厂方法来创建对象即可,无需了解具体的创建细节。
  • 扩展性强​:通过工厂模式,可以方便地添加新的产品类并创建对应的工厂类,而不影响已有的代码结构和客户端使用。
  • 符合开放封闭原则​:新增产品类不需修改原有代码,只需新增对应的产品类和工厂类,符合软件设计原则。

缺点

  • 增加复杂度​:引入工厂模式会增加代码的复杂度和结构,可能需要新增很多工厂类,增加了代码量。
  • 不适用于简单对象的创建​:工厂模式适合创建复杂对象的场景,如果只需要创建简单对象,使用工厂模式可能过于繁琐。
  • 需要预先知道具体产品类​:工厂模式需要事先了解可能会创建的产品类,而且随着产品种类的增加,工厂类也会增多

适用场景

当对象的创建比较复杂,而访问者无需知道创建的具体流程,我们可以考虑使用工厂模式。典型应用,如 VueReact 中创建虚拟 DOMcreateElement 函数。Vue-Router 的设计,根据不同的 mode 创建不同的路由实例。

3、策略模式

策略模式,可以针对不同的状态,给出不同的算法或者结果。将层级相同的逻辑封装成可以组合和替换的策略方法,减少 if...else 代码,方便扩展后续功能。

image-20250210105127-gk016wg.png 策略模式中主要有下面概念:

  1. Context :封装上下文,根据需要调用需要的策略,屏蔽外界对策略的直接调用,只对外提供一个接口,根据需要调用对应的策略;
  2. Strategy :策略,含有具体的算法,其方法的外观相同,因此可以互相代替;
  3. StrategyMap :所有策略的合集,供封装上下文调用;

表单校验是我们最常见的场景了,我们一般都会想到用 if...else 来判断。

function onFormSubmit(params) {
    if (!params.name) {
        return showError("请填写昵称");
    }
    if (params.name.length > 6) {
        return showError("昵称最多6位字符");
    }
    if (!/^1\d{10}$/.test(params.phone))
        return showError("请填写正确的手机号");
    }
    // ...
    sendSubmit(params)
}

将所有字段的校验规则都堆叠在一起,代码量大,排查问题也是一个大麻烦。在遇见错误时,直接通过 return 跳过了后面的判断;如果我们希望直接展示每个字段的错误呢,那么改动的工作量又不少。

不过,在 antd、ELementUI 等框架盛行的年代,我们已经不再需要写这些复杂的表单校验,但是对于他们的实现原理,我们可以简单模拟一下。

// 定义一个校验的类,主要暴露了构造参数和validate两个接口
class Schema {
    constructor(descriptor) {
        this.descriptor = descriptor; // 传入定义的校验规则
    }
   // 拆分出一些更通用的规则,比如required(必填)、len(长度)、min/max(最值)等,可以尽可能地复用
    handleRule(val, rule) {
        const { key, params, message } = rule;
        let ruleMap = {
            required() {
                return !val;
            },
            max() {
                return val > params;
            },
            validator() {
                return params(val);
            },
        };

        let handler = ruleMap[key];
        if (handler && handler()) {
            throw message;
        }
    }

    validate(data) {
        return new Promise((resolve, reject) => {
            let keys = Object.keys(data);
            let errors = [];
            for (let key of keys) {
                const ruleList = this.descriptor[key];
                if (!Array.isArray(ruleList) || !ruleList.length) continue;

                const val = data[key];
                for (let rule of ruleList) {
                    try {
                        this.handleRule(val, rule);
                    } catch (e) {
                        errors.push(e.toString());
                    }
                }
            }
            if (errors.length) {
                reject(errors);
            } else {
                resolve();
            }
        });
    }
}

// 声明每个字段的校验逻辑
const descriptor = {
    nickname: [
        { key: "required", message: "请填写昵称" },
        { key: "max", params: 6, message: "昵称最多6位字符" },
    ],
    phone: [
        { key: "required", message: "请填写电话号码" },
        {
            key: "validator",
            params(val) {
                return !/^1\d{10}$/.test(val);
            },
            message: "请填写正确的电话号码",
        },
    ],
};

// 开始对数据进行校验
const validator = new Schema(descriptor);
const params = { nickname: "", phone: "123000" };
validator.validate(params).then(() => {
  console.log("success");
}).catch((e) => {
  console.log(e);
});

Schema 主要暴露了构造参数和 validate 两个接口,是一个通用的工具类,而 params 是表单提交的数据源,因此主要的校验逻辑实际上是在 descriptor 中声明的。将常见的校验规则都放在 ruleMap 中,比之前各种不可复用的 if..else 判断更容易维护和迭代。

优缺点

策略模式将算法的实现和使用拆分,这个特点带来了很多优点

  1. 策略之间相互独立,但策略可以自由切换,这个策略模式的特点给策略模式带来很多灵活性,也提高了策略的复用率;
  2. 如果不采用策略模式,那么在选策略时一般会采用多重的条件判断,采用策略模式可以避免多重条件判断,增加可维护性;
  3. 可扩展性好,策略可以很方便的进行扩展;

策略模式的缺点

  1. 策略相互独立,因此一些复杂的算法逻辑无法共享,造成一些资源浪费;
  2. 如果用户想采用什么策略,必须了解策略的实现,因此所有策略都需向外暴露,这是违背迪米特法则/最少知识原则的,也增加了用户对策略对象的使用成本。

适用场景

  1. 多个算法只在行为上稍有不同的场景,这时可以使用策略模式来动态选择算法;
  2. 算法需要自由切换的场景;
  3. 有时需要多重条件判断,那么可以使用策略模式来规避多重条件判断的情况;
4、状态模式

状态模式允许一个对象在其内部状态改变的时候改变它的行为。状态模式的思路是:首先创建一个状态对象保存状态变量,然后封装好每种动作对应的状态,然后状态对象返回一个接口对象,它可以对内部的状态修改或者调用。

常见的使用场景,比如滚动加载,包含了初始化加载、加载成功、加载失败、滚动加载等状态,任意时间它只会处于一种状态。

// 定义一个状态机
class rollingLoad {
  constructor() {
    this._currentState = 'init'
    this.states = {
        init: { failed: 'error' },
        init: { complete: 'normal' },
        normal: { rolling: 'loading' },
        loading: { complete: 'normal' },
        loading: { failed: 'error' },
    }
    this.actions = {
        init() {
          console.log('初始化加载,大loading')
        },
        normal() {
          console.log('加载成功,正常展示')
        },
        error() {
          console.log('加载失败')
        },
        loading() {
          console.log('滚动加载')
        }
        // .....
    }
  }

  change(state) {
    // 更改当前状态
    let to = this.states[this._currentState][state]
    if(to){
        this._currentState = to
        this.go()
        return true
    }
    return false
  }
  
  go() {
    this.actions[this._currentState]()
    return this
  }
}

// 状态更改的操作
const rollingLoad = new rollingLoad()
rollingLoad.go()
rollingLoad.change('complete')
rollingLoad.change('loading')

这样,我们就可以通过状态变更,运行相应的函数,且状态之间存在联系。那么,看起来是不是和策略模式很像呢?其实不然,策略类的各个属性之间是平等平行的,它们之间没有任何联系。而状态机中的各个状态之间存在相互切换,且是被规定好了的

在等红绿灯的时候,红绿灯的状态和行人汽车的通行逻辑是有关联的:

  1. 红灯亮:行人通行,车辆等待;
  2. 绿灯亮:行人等待,车辆通行;
  3. 黄灯亮:行人等待,车辆等待;

还有下载文件时,有好几个状态,比如下载验证、下载中、暂停下载、下载完毕、下载失败,文件在不同状态下表现的行为也不一样,比如下载中时显示可以暂停下载和下载进度,下载失败时弹框提示并询问是否重新下载等等。类似的场景还有很多,比如女生作为你的朋友、好朋友、女朋友、老婆等不同状态的时候,行为也不同 。

在这些场景中,有以下特点:

  1. 对象有有限多个状态,且状态间可以相互切换;
  2. 各个状态和对象的行为逻辑有比较强的对应关系,即在不同状态时,对应的处理逻辑不一样;

状态模式的实现

下面用最常用的方式来实现上面的红绿灯的例子:

let trafficLight = (function() {
    let state = '绿灯'        // 闭包缓存状态
  
    return {
        // 设置交通灯状态 
        setState: function(target) {
            if (target === '红灯') {
                state = '红灯'
                console.log('交通灯颜色变为 红色,行人通行 & 车辆等待')
            } else if (target === '黄灯') {
                state = '黄灯'
                console.log('交通灯颜色变为 黄色,行人等待 & 车辆等待')
            } else if (target === '绿灯') {
                state = '绿灯'
                console.log('交通灯颜色变为 绿色,行人等待 & 车辆通行')
            } else {
                console.error('交通灯还有这颜色?')
            }
        },
    
        // 获取交通灯状态 
        getState: function() {
            return state
        }
    }
})()

trafficLight.setState('红灯') // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
trafficLight.setState('黄灯') // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
trafficLight.setState('绿灯') // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

trafficLight.setState('紫灯') // 输出: 交通灯还有这颜色?

这里可以使用 if-else 来实现,也可以使用 swich-case 来实现,但是这样实现是存在问题的,这里处理的逻辑比较简单,如果比较复杂,在增加新的状态时,比如增加了 蓝灯​、紫灯​ 等颜色及其处理逻辑的时候,需要到 setState​ 方法里找到对应地方修改。

在实际项目中,if-else​ 伴随的业务逻辑处理通常比较复杂,找到要修改的状态就不容易,特别是如果是别人的代码,或者接手遗留项目时,需要看完这个 if-else​ 的分支处理逻辑,新增或修改分支逻辑的过程中也很容易引入 Bug。

正式因为这样非常的不方便维护状态及其对应的行为,所以引入了状态模式的理念,状态模式把每种状态和对应的处理逻辑封装在一起,比如下面用一个类实例将红绿灯的逻辑封装起来:

// 抽象状态类 
var AbstractState = function() {}

// 抽象方法 
AbstractState.prototype.employ = function() {
    throw new Error('抽象方法不能调用!')
}

// 交通灯状态类 
var State = function(name, desc) {
    this.color = { name, desc }
}

State.prototype = new AbstractState()
State.prototype.employ = function(trafficLight) {
    console.log('交通灯颜色变为 ' + this.color.name + ',' + this.color.desc)
    trafficLight.setState(this)
}

// 交通灯类 
var TrafficLight = function() {
    this.state = null
}

// 获取交通灯状态 
TrafficLight.prototype.getState = function() {
    return this.state
}

// 设置交通灯状态 
TrafficLight.prototype.setState = function(state) {
    this.state = state
}

// 实例化一个红绿灯
var trafficLight = new TrafficLight()

// 实例化红绿灯可能有的三种状态
var redState = new State('红色', '行人等待 & 车辆等待')
var greenState = new State('绿色', '行人等待 & 车辆通行')
var yellowState = new State('黄色', '行人等待 & 车辆等待')

redState.employ(trafficLight)    // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
yellowState.employ(trafficLight) // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
greenState.employ(trafficLight)  // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

这里的不同状态是同一个类的类实例,比如 redState​ 这个类实例,就把所有红灯状态处理的逻辑封装起来,如果要把状态切换为红灯状态,那么只需要 redState.employ()​ 把交通灯的状态切换为红色,并且把交通灯对应的行为逻辑也切换为红灯状态。

状态模式与策略模式很相似:

  • 策略模式把可以相互替换的策略算法提取出来
  • 状态模式把事物的状态及其行为提取出来。

下面使用 ES6 的 Class 语法对上面的代码进行改造:

// 抽象状态类 
class AbstractState {
    constructor() {
        if (new.target === AbstractState) {
            throw new Error('抽象类不能直接实例化!')
        }
    }
  
    // 抽象方法 
    employ() {
        throw new Error('抽象方法不能调用!')
    }
}

// 交通灯类 
class State extends AbstractState {
    constructor(name, desc) {
        super()
        this.color = { name, desc }
    }
  
    // 覆盖抽象方法 
    employ(trafficLight) {
        console.log('交通灯颜色变为 ' + this.color.name + ',' + this.color.desc)
        trafficLight.setState(this)
    }
}

// 交通灯类 
class TrafficLight {
    constructor() {
        this.state = null
    }
  
    // 获取交通灯状态 
    getState() {
        return this.state
    }
  
    // 设置交通灯状态 
    setState(state) {
        this.state = state
    }
}

const trafficLight = new TrafficLight()

const redState = new State('红色', '行人等待 & 车辆等待')
const greenState = new State('绿色', '行人等待 & 车辆通行')
const yellowState = new State('黄色', '行人等待 & 车辆等待')

redState.employ(trafficLight)    // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
yellowState.employ(trafficLight) // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
greenState.employ(trafficLight)  // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

如果要新建状态,不用修改原有代码,只要加上下面的代码:

const blueState = new State('蓝色', '行人倒立 & 车辆飞起')
blueState.employ(trafficLight)    // 输出: 交通灯颜色变为 蓝色,行人倒立 & 车辆飞起

传统的状态区分一般是基于状态类扩展的不同状态类,如何实现实现看需求具体了,比如逻辑比较复杂,通过新建状态实例的方法已经不能满足需求,那么可以使用状态类的方式。

最后,提供一个状态类的实现,同时引入状态的切换逻辑:

// 抽象状态类 
class AbstractState {
    constructor() {
        if (new.target === AbstractState) {
            throw new Error('抽象类不能直接实例化!')
        }
    }
  
    // 抽象方法 
    employ() {
        throw new Error('抽象方法不能调用!')
    }
  
    changeState() {
        throw new Error('抽象方法不能调用!')
    }
}
// 交通灯类-红灯 
class RedState extends AbstractState {
    constructor() {
        super()
        this.colorState = '红色'
    }
  
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人通行 & 车辆等待')
        // const redDom = document.getElementById('color-red')    // 业务相关操作
        // redDom.click()
    }
  
    changeState(trafficLight) {
        trafficLight.setState(trafficLight.yellowState)
    }
}
// 交通灯类-绿灯 
class GreenState extends AbstractState {
    constructor() {
        super()
        this.colorState = '绿色'
    }
  
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人等待 & 车辆通行')
        // const greenDom = document.getElementById('color-green')
        // greenDom.click()
    }
  
    changeState(trafficLight) {
        trafficLight.setState(trafficLight.redState)
    }
}
// 交通灯类-黄灯
class YellowState extends AbstractState {
    constructor() {
        super()
        this.colorState = '黄色'
    }
  
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人等待 & 车辆等待')
        // const yellowDom = document.getElementById('color-yellow')
        // yellowDom.click()
    }
  
    changeState(trafficLight) {
        trafficLight.setState(trafficLight.greenState)
    }
}
// 交通灯类 
class TrafficLight {
    constructor() {
        this.redState = new RedState()
        this.greenState = new GreenState()
        this.yellowState = new YellowState()
    
        this.state = this.greenState
    }
  
    // 设置交通灯状态 
    setState(state) {
        state.employ(this)
        this.state = state
    }
  
    changeState() {
        this.state.changeState(this)
    }
}
const trafficLight = new TrafficLight()
trafficLight.changeState()    // 输出: 交通灯颜色变为 红色,行人通行 & 车辆等待
trafficLight.changeState()    // 输出: 交通灯颜色变为 黄色,行人等待 & 车辆等待
trafficLight.changeState()    // 输出: 交通灯颜色变为 绿色,行人等待 & 车辆通行

如果要增加新的交通灯颜色,也是很方便的:

// 交通灯类-蓝灯 
class BlueState extends AbstractState {
    constructor() {
        super()
        this.colorState = '蓝色'
    }
  
    // 覆盖抽象方法 
    employ() {
        console.log('交通灯颜色变为 ' + this.colorState + ',行人倒立 & 车辆飞起')
        const redDom = document.getElementById('color-blue')
        redDom.click()
    }
}
const blueState = new BlueState()
trafficLight.employ(blueState)    // 输出: 交通灯颜色变为 蓝色,行人倒立 & 车辆飞起

状态模式的优缺点

优点:

  • 结构相比之下清晰,避免了过多的 switch-case​ 或 if-else​ 语句的使用,避免了程序的复杂性提高系统的可维护性;
  • 符合开闭原则,每个状态都是一个子类,增加状态只需增加新的状态类即可,修改状态也只需修改对应状态类就可以了;
  • 封装性良好,状态的切换在类的内部实现,外部的调用无需知道类内部如何实现状态和行为的变换。

缺点:

引入了多余的类,每个状态都有对应的类,导致系统中类的个数增加。

应用场景

在以下场景中可以使用状态模式:

  • 操作中含有庞大的多分支的条件语句,且这些分支依赖于该对象的状态,那么可以使用状态模式来将分支的处理分散到单独的状态类中;
  • 对象的行为随着状态的改变而改变,那么可以考虑状态模式,来把状态和行为分离,虽然分离了,但是状态和行为是对应的,再通过改变状态调用状态对应的行为;
5、观察者模式

任务订阅的大致功能是这样的: 宗门推出五星任务订阅功能,弟子通过购买获得订阅权限,当宗门发布五星任务后,会通知拥有订阅权限的弟子。

那么任务订阅功能中有两类主体:

  • 宗门任务大殿

    • 维护拥有订阅权限的弟子列表
    • 提供弟子购买订阅权限的功能
    • 发布对应任务后通知有订阅权限的弟子
  • 接受任务通知的弟子们

上面宗门任务大殿与弟子间的关系其实就构成了一个观察者模式。

那什么是观察者模式那? 当对象之间存在一对多的依赖关系时,其中一个对象的状态发生改变,所有依赖它的对象都会收到通知,这就是观察者模式。

在观察者模式中,只有两种主体:目标对象 (Object) 和 观察者 (Observer)。宗门任务大殿就是目标对象,弟子们就是观察者。

  • 目标对象 Subject:

    • 维护观察者列表 observerList ———— 维护拥有订阅权限的弟子列表
    • 定义添加观察者的方法 ———— 提供弟子购买订阅权限的功能
    • 当自身发生变化后,通过调用自己的 notify 方法依次通知每个观察者执行 update 方法 ———— 发布对应任务后通知有订阅权限的弟子
  • 观察者 Observer 需要实现 update 方法,供目标对象调用。update 方法中可以执行自定义的业务逻辑 ———— 弟子们需要定义接收任务通知后的方法,例如去抢任务或任务不适合,继续等待下一个任务

我们把上面的文字形象化一下:

image_9HJUBGfLFU-20241203100653-fdwrjlo.png

class Observer {
    constructor(name) {
        this.name = name;
    }
    update({taskType, taskInfo}) {
        // 假设任务分为日常route和战斗war
        if (taskType === "route") {
            console.log(`${this.name}不需要日常任务`);
            return;
        }
        this.goToTaskHome(taskInfo);
        
    }
    goToTaskHome(info) {
        console.log(`${this.name}去任务大殿抢${info}任务`);
    }
}

class Subject {
    constructor() {
        this.observerList = []
    }
    addObserver(observer) {
        this.observerList.push(observer);
    }
    notify(task) {
        console.log("发布五星任务");
        this.observerList.forEach(observer => observer.update(task))
    }
}

const subject = new Subject();
const stu1 = new Observer("弟子1");
const stu2 = new Observer("弟子2");

// stu1 stu2 购买五星任务通知权限
subject.addObserver(stu1);
subject.addObserver(stu2);

// 任务殿发布五星战斗任务
const warTask = {
    taskType: 'war',
    taskInfo: "猎杀时刻"
}

// 任务大殿通知购买权限弟子
subject.notify(warTask);

// 任务殿发布五星日常任务
const routeTask = {
    taskType: 'route',
    taskInfo: "种树浇水"
}

subject.notify(routeTask);

输出结果:

// 战斗任务
发布五星任务
弟子1去任务大殿抢猎杀时刻任务
弟子2去任务大殿抢猎杀时刻任务

// 日常任务
发布五星任务
弟子1不需要日常任务
弟子2不需要日常任务

通过上面代码我们可以看到,当宗门发布任务后,订阅的弟子(观察者们)都会收到任务最新通知。

举个栗子:

比如你要应聘阿里巴巴的前端工程师,结果阿里巴巴 HR 告诉你没坑位了,留下你的电话,等有坑位联系你。于是,你美滋滋的留下了联系方式。殊不知,HR 已经留下了好多联系方式。好在 2022 年 2 月 30 号那天,阿里巴巴有了前端工程师的坑位,HR 挨着给留下的联系方式联系了一通。 案例中阿里巴巴就是目标对象 Subject ,联系方式列表就是用来维护观察者的 observerList ,根据前端职位的有无来调用 notify 方法。

6、迭代器模式

迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。

迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。

JS 也内置了多种遍历数组的方法如 forEach、reduce 等。对于数组的循环大家都轻车熟路了,在实际开发中,也可以通过循环来优化代码。

一个常见的开发场景是:通过 ua 判断当前页面的运行平台,方便执行不同的业务逻辑,最基本的写法当然是 if...else。

const PAGE_TYPE = {
    app: "app", // app
    wx: "wx", // 微信
    tiktok: "tiktok", // 抖音
    bili: "bili", // B站
    kwai: "kwai", // 快手
};
function getPageType() {
    const ua = navigator.userAgent;
    let pageType;
    // 移动端、桌面端微信浏览器
    if (/xxx_app/i.test(ua)) {
        pageType = app;
    } else if (/MicroMessenger/i.test(ua)) {
        pageType = wx;
    } else if (/aweme/i.test(ua)) {
        pageType = tiktok;
    } else if (/BiliApp/i.test(ua)) {
        pageType = bili;
    } else if (/Kwai/i.test(ua)) {
        pageType = kwai;
    } else {
        // ...
    }
    return pageType;
}

参考策略模式的思路,我们可以减少分支判断的出现,将每个平台的判断拆分成单独的策略:

function isApp(ua) {
    return /xxx_app/i.test(ua);
}

function isWx(ua) {
    return /MicroMessenger/i.test(ua);
}

function isTiktok(ua) {
    return /aweme/i.test(ua);
}

function isBili(ua) {
    return /BiliApp/i.test(ua);
}

function isKwai(ua) {
    return /Kwai/i.test(ua);
}

let platformList = [
    { name: "app", validator: isApp },
    { name: "wx", validator: isWx },
    { name: "tiktok", validator: isTiktok },
    { name: "bili", validator: isBili },
    { name: "kwai", validator: isKwai },
];
function getPageType() {
    // 每个平台的名称与检测方法
    const ua = navigator.userAgent;
    // 遍历
    for (let { name, validator } in platformList) {
        if (validator(ua)) {
            return name;
        }
    }
}

这样,整个 getPageType 方法就变得非常简洁:按顺序遍历 platformList,返回第一个匹配上的平台名称作为 pageType。

这样即使后面需要增加或移除平台判断,需要修改的仅仅也只是 platformList 这个地方而已。

迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前的绝大部分语言都内置了迭代器。

7、发布订阅模式

那什么是发布订阅模式呐? 基于一个事件(主题)通道,希望接收通知的对象 Subscriber 通过自定义事件订阅主题,被激活事件的对象 Publisher 通过发布主题事件的方式通知各个订阅该主题的 Subscriber 对象。

因此发布订阅模式与观察者模式相比,发布订阅模式中有三个角色,发布者 Publisher ,事件调度中心 Event Channel ,订阅者 Subscriber

上面的文字有些难以理解,我们继续以弟子领取任务为栗子,宗门感觉把任务订阅放在任务大殿中有些繁琐,于是决定在任务大殿和弟子中间添加中介。弟子在中介中订阅其需要的任务类型,当任务大殿发布任务后,中介会将发布任务给对应的订阅者。

  • 宗门任务大殿: 任务发布者 —— Publisher

  • 中介功能 —— Event Channel

    • 维护任务类型,以及每种任务下的订阅情况
    • 给订阅者提供订阅功能 —— subscribe 功能
    • 当宗门发布任务后,中介会给所有的订阅者发布任务 —— publish 功能
  • 弟子: 任务接受者 —— Subscriber

image_hYMYYzPDDP-20241203100653-4ebl14n.png

举个例子: 以目前的热播剧开端为例,临近过年,摸鱼的心思越来越重,每天就迫不及待的等开端更新,想在开端更新的第一刻就开始看剧,那你会怎么做那?总不能时时刻刻刷新页面吧。平台提供了消息订阅功能,如果你选择订阅,平台更新开端后,会第一时间发消息通知你,订阅后,你就可以愉快的追剧了

上面案例中,开端就是发布者 Publisher,追剧人就是订阅者 Subscribe,平台则承担了事件通道 Event Channel 功能。

class PubSub {
    constructor() {
        // 事件中心
        // 存储格式: warTask: [], routeTask: []
        // 每种事件(任务)下存放其订阅者的回调函数
        this.events = {}
    }
    // 订阅方法
    subscribe(type, cb) {
        if (!this.events[type]) {
            this.events[type] = [];
        }
        this.events[type].push(cb);
    }
    // 发布方法
    publish(type, ...args) {
        if (this.events[type]) {
            this.events[type].forEach(cb => cb(...args))
        }
    }
    // 取消订阅方法
    unsubscribe(type, cb) {
        if (this.events[type]) {
            const cbIndex = this.events[type].findIndex(e=> e === cb)
            if (cbIndex != -1) {
                this.events[type].splice(cbIndex, 1);
            }
        }
        if (this.events[type].length === 0) {
            delete this.events[type];
        }
    }
    unsubscribeAll(type) {
        if (this.events[type]) {
            delete this.events[type];
        }
    }
}

// 创建一个中介公司
let pubsub = new PubSub();

// 弟子一订阅战斗任务
pubsub.subscribe('warTask', function (taskInfo){
    console.log("宗门殿发布战斗任务,任务信息:" + taskInfo);
})
// 弟子一订阅战斗任务
pubsub.subscribe('routeTask', function (taskInfo) {
    console.log("宗门殿发布日常任务,任务信息:" + taskInfo);
});
// 弟子三订阅全类型任务
pubsub.subscribe('allTask', function (taskInfo) {
    console.log("宗门殿发布五星任务,任务信息:" + taskInfo);
});

// 发布战斗任务
pubsub.publish('warTask', "猎杀时刻");
pubsub.publish('allTask', "猎杀时刻");

// 发布日常任务
pubsub.publish('routeTask', "种树浇水");
pubsub.publish('allTask', "种树浇水");

输出结果:

 // 战斗任务
宗门殿发布战斗任务,任务信息:猎杀时刻
宗门殿发布五星任务,任务信息:猎杀时刻
// 日常任务
宗门殿发布日常任务,任务信息:种树浇水
宗门殿发布五星任务,任务信息:种树浇水

上文中提到了观察者模式和发布——订阅模式,我们来总结一下两者差异:

image_ax0ybR30AZ-20241203100653-1nyybqi.png

设计模式观察者模式发布订阅模式
主体Object 观察者、Subject 目标对象Publisher 发布者、Event Channel 事件中心、Subscribe 订阅者
主体关系Subject 中通过 observerList 记录 ObServerPublisher 和 Subscribe 不想不知道对方,通过中介联系
优点角色明确,Subject 和 Object 要遵循约定的成员方法松散耦合,灵活度高,通常应用在异步编程中
缺点紧耦合当事件类型变多时,会增加维护成本
使用案例双向数据绑定事件总线 EventBus

优缺点

发布订阅模式最大的特点就是发布者和订阅者之间完全解耦:发布者不需要订阅者是谁,只需要更新的时候遍历所以订阅该消息的订阅者即可。订阅者也不需要时时关注发布者的动态,当有消息更新时会自动接受。因此,可以将事件处理中心封装起来,统一管理,独立运行。

发布订阅模式的缺点在于,订阅者会增加内存消耗,及时后续没有触发,也会常驻内存中。随着订阅者的增多,系统复杂度会增加,代码运行效率、资源消耗会变大。另外,发布者与订阅者完全解耦,会导致代码追踪起来比较困难。

适用场景

发布订阅模式特别适用于要实现一对多关联的场景。日常生活中我们订阅的公众号,关注的明星微博,今日头条的新闻等,他们都会在有新消息的时候第一时间推送给你。而实际开发中,vue 的数据响应式,浏览器的 DOM 事件绑定等也都是这个原理。

8、代理模式

概念: 为对象提供一个代用品或者占位符,以便控制对它的访问。

意义:

  • 单一职责,只有代理对象才能引起该对象的变化。
  • 用代理来过滤掉一些不合规的的访问,减少该对象的访问,在某种意义上可以提高性能。

image-20250210113530-uixul3v.png

应用场景:图片预加载、mobx 的变量拦截

var myImage = (function() {
  var imgNode = document.createElement('img')
  document.body.appendChild(imgNode)
  return {
    setSrc: function(src) {
      imgNode.src = src
    }
  }
})()
var proxyImage = (function() {
  var img = new Image()
  img.onload = function() {
    myImage.setSrc(this.src)
  }
  return {
    setSrc: function(src) {
      myImage.setSrc('loading.gif')
      img.src = src
    }
  }
})()

proxyImage.setSrc('http://imgcache.qq.com/music/photo/k/000GGDys0yA0Nk.jpg')

优缺点

优点:

  1. 代理对象在访问者与目标对象之间可以起到中介和保护目标对象的作用;
  2. 代理对象可以扩展目标对象的功能
  3. 代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开闭原则;

缺点:

主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式

使用场景

1)拦截器

上面使用代理模式代理对象的访问的方式,一般又被称为拦截器

拦截器的思想在实战中应用非常多,比如我们在项目中经常使用 Axios​ 的实例来进行 HTTP 的请求,使用拦截器 interceptor​ 可以提前对 request​ 请求和 response​ 返回进行一些预处理,比如:

  1. request​ 请求头的设置,和 Cookie 信息的设置;
  2. 权限信息的预处理,常见的比如验权操作或者 Token 验证;
  3. 数据格式的格式化,比如对组件绑定的 Date​ 类型的数据在请求前进行一些格式约定好的序列化操作;
  4. 空字段的格式预处理,根据后端进行一些过滤操作;
  5. response​ 的一些通用报错处理,比如使用 Message 控件抛出错误;

除了 HTTP 相关的拦截器之外,还有路由跳转的拦截器,可以进行一些路由跳转的预处理等操作。

(2)前端框架的数据响应式化

现在的很多前端框架或者状态管理框架都使用上面介绍的 Object.defineProperty​ 和 Proxy​ 来实现数据的响应式化,比如 Vue,Vue 2.x 使用前者,而 Vue 3.x 则使用后者。

Vue 2.x 中通过 Object.defineProperty​ 来劫持各个属性的 setter/getter​,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,从而实现数据的响应式化,也就是数据到视图的双向绑定。

为什么 Vue 2.x 到 3.x 要从 Object.defineProperty​ 改用 Proxy​ 呢,是因为前者的一些局限性,导致的以下缺陷:

  1. 无法监听利用索引直接设置数组的一个项,例如:vm.items[indexOfItem] = newValue​;
  2. 无法监听数组的长度的修改,例如:vm.items.length = newLength​;
  3. 无法监听 ES6 的 Set​、WeakSet​、Map​、WeakMap​ 的变化;
  4. 无法监听 Class​ 类型的数据;
  5. 无法监听对象属性的新加或者删除;

除此之外还有性能上的差异,基于这些原因,Vue 3.x 改用 Proxy​ 来实现数据监听了。当然缺点就是对 IE 用户的不友好,兼容性敏感的场景需要做一些取舍。

(3)缓存代理

在高阶函数的文章中,就介绍了备忘模式,备忘模式就是使用缓存代理的思想,将复杂计算的结果缓存起来,下次传参一致时直接返回之前缓存的计算结果。

(4)保护代理和虚拟代理

有的书籍中着重强调代理的两种形式:保护代理虚拟代理

  1. 保护代理 :当一个对象可能会收到大量请求时,可以设置保护代理,通过一些条件判断对请求进行过滤;
  2. 虚拟代理 :在程序中可以能有一些代价昂贵的操作,此时可以设置虚拟代理,虚拟代理会在适合的时候才执行操作。

保护代理其实就是对访问的过滤,之前的经纪人例子就属于这种类型。而虚拟代理是为一个开销很大的操作先占位,之后再执行,比如:

  1. 一个很大的图片加载前,一般使用菊花图、低质量图片等提前占位,优化图片加载导致白屏的情况;
  2. 现在很流行的页面加载前使用骨架屏来提前占位,很多 WebApp 和 NativeApp 都采用这种方式来优化用户白屏体验;

image-20250210113826-21suve8.png

(5)正向代理与反向代理

还有个经常用的例子是反向代理(Reverse Proxy),反向代理对应的是正向代理(Forward Proxy),他们的区别是:

  1. 正向代理: 一般的访问流程是客户端直接向目标服务器发送请求并获取内容,使用正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器(原始服务器),然后由代理服务器和原始服务器通信,转交请求并获得的内容,再返回给客户端。正向代理隐藏了真实的客户端,为客户端收发请求,使真实客户端对服务器不可见;
  2. 反向代理: 与一般访问流程相比,使用反向代理后,直接收到请求的服务器是代理服务器,然后将请求转发给内部网络上真正进行处理的服务器,得到的结果返回给客户端。反向代理隐藏了真实的服务器,为服务器收发请求,使真实服务器对客户端不可见。

反向代理一般在处理跨域请求的时候比较常用,属于服务端开发人员的日常操作了,另外在缓存服务器、负载均衡服务器等等场景也是使用到代理模式的思想。

image-20250210113841-dayyasn.png

9、装饰者模式

概念:在不改变对象自身的基础上,在程序运行期间给对象动态地添加方法。

意义:

  • 原有方法维持不变,在原有方法上再挂载其他方法来满足现有需求;函数的解耦,将函数拆分成多个可复用的函数,再将拆分出来的函数挂载到某个函数上,实现相同的效果但增强了复用性。
  • 拓展对象功能。

image-20250210114350-56jaa0u.png

应用场景:动态表单验证...

function Sale(price) {
  this.price = price
  this.decorateList = []
}
Sale.decorators = {}
Sale.decorators.fedtax = {
  getPrice: function(price) {
    var price = this.uber.getPrice()
    return price * 0.8 //对price进行处理
  }
}
Sale.decorators.quebec = {
  getPrice: function(price) {
    var price = this.uber.getPrice()
    return price * 0.7 //对price进行处理
  }
}
Sale.decorators.money = {
  getPrice: function(price) {
    var price = this.uber.getPrice()
    return '$' + price * 0.9 //对price进行处理
  }
}
Sale.prototype.decorate = function(decorator) {
  this.decorateList.push(decorator)
}
Sale.prototype.getPrice = function() {
  var price = this.price
  this.decorateList.forEach(function(name) {
    price = Sale.decorators[name].getPrice(price)
  })
  return price
}
var sale = new Sale(100)
sale = sale.decorate('fedtax') //联邦税
sale = sale.decorate('quebec') //魁北克省税
sale = sale.decorate('money') //转为美元格式
console.log(sale.getPrice()) //$50.4

优缺点

装饰器模式的优点:

  • 可维护性高: 我们经常使用继承的方式来实现功能的扩展,但这样会给系统中带来很多的子类和复杂的继承关系,装饰器模式允许用户在不引起子类数量暴增的前提下动态地修饰对象,添加功能,装饰器和被装饰器之间松耦合,可维护性好;
  • 灵活性好: 被装饰器可以使用装饰器动态地增加和撤销功能,可以在运行时选择不同的装饰器,实现不同的功能,灵活性好;
  • 复用性高: 装饰器模式把一系列复杂的功能分散到每个装饰器当中,一般一个装饰器只实现一个功能,可以给一个对象增加多个同样的装饰器,也可以把一个装饰器用来装饰不同的对象,有利于装饰器功能的复用;
  • 多样性: 可以通过选择不同的装饰器的组合,创造不同行为和功能的结合体,原有对象的代码无须改变,就可以使得原有对象的功能变得更强大和更多样化,符合开闭原则;

装饰器模式的缺点:

  • 使用装饰器模式时会产生很多细粒度的装饰器对象,这些装饰器对象由于接口和功能的多样化导致系统复杂度增加,功能越复杂,需要的细粒度对象越多;
  • 由于更大的灵活性,也就更容易出错,特别是对于多级装饰的场景,错误定位会更加繁琐;

适用场景

  • 如果不希望系统中增加很多子类,那么可以考虑使用装饰器模式;
  • 需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,这时采用装饰器模式可以很好实现;
  • 当对象的功能要求可以动态地添加,也可以动态地撤销,可以考虑使用装饰器模式;
  • 我们在使用 vue​ ,或者 scss ​的时候,有时候会用到 mixins​,mixins ​的原理就类似于装饰器。

10、命令模式 (Command Pattern)

命令模式,就是将一系列操作的指令封装起来,根据客户端不同的请求参数执行的对应的方法,本质上是对方法调用的封装,但它可以使请求发送者和接收者消除彼此之间的耦合关系。

const Manager = (function () {
    // 命令
    const commander = {
        open: function () {
            console.log('打开电视')
        },
        close: function () {
            console.log('关闭电视')
        },
        change: function (channel) {
            console.log('更换频道 ' + channel)
        }
    }

    return {
        // 执行命令
        exec: function (cmd) {
            const args = [].splice.call(arguments, 1)
            commander[cmd] && commander[cmd](args)
        }
    }
})();

Manager.exec('open')        // 打开电视
Manager.exec('change', 10)  // 更换频道 10
Manager.exec('close')       // 关闭电视

上述代码以一种简单的方式展示了命令模式的基本用法,我们是先定义好一些命令,并暴露出一个执行命令的 exec 方法,使用者就可以通过 Manager.exec 传递不同的命令参数来达到执行不同命令的效果。

优缺点

上面的代码很明显就可以看出,命令模式中命令的请求和命令的执行两者完全解耦,因此系统的可扩展性良好,加入新的命令不会影响原有逻辑,而且复用性很强,可以被任何请求者使用,不关心请求者是谁。

命令模式的缺点在于,一是使用者要事先了解有哪些命令方能正常使用,二是随着命令的不断增加系统会变得很膨胀,复杂性会随之增加。

11、桥接模式

桥接模式是为了将抽象部分与实现部分分离,使抽象部分和实现部分都可以独立的变化而不会互相影响,实现二者的解耦,从而降低了代码的耦合性,提高了代码的扩展性。

// 桥接方法
function addEvent(ele, eventName, fn) {
    document.querySelector(ele).addEventListener(eventName, fn, false);
}

// 具体业务
addEvent('#btn', 'click', function () {
    console.log('hello world');     // hello world
})

上述通过一个简单的事件监听器的例子来展示了桥接模式的工作原理,桥接方法 addEvent 它内部不实现具体的业务逻辑,只是抽象出一个方法,它就充当了了 DOM 元素与其具体事件绑定的一个桥梁,要实现具体的业务逻辑只要给桥接函数传递参数即可。

优缺点

桥接模式分离了抽象和实现部分,将实现层(DOM 元素事件具体逻辑)和抽象层(绑定方法)解耦,使用者不需要关心细节的实现,只需要方便快捷的使用即可,提高了代码的拓展性。

桥接模式的弊端在于需要很好地抽象出桥接方法与业务逻辑,具有一定的局限性,另外桥接模式会引入额外的代码,增加系统的复杂度

适用场景

命令模式比较适合于需要发布一些命令,但不清楚接受者和请求的操作,即只用知道发布了一个指令就行,具体做什么谁来做不用关心。常见的 GUI 编程中基本都采用这种模式,前端比较典型应用如,富文本编辑器中的各种按钮,canvas 动画中各种指令操作等。

12、组合模式

组合模式是将一系列对象组合成树形结构,以表示 “部分-整体” 的层次结构,使用者只需统一地使用组合结构中的所有对象,而不需要关心它究竟是组合对象还是单个对象。它主要体现了整体与部分的关系,其典型的应用就是树形结构。

// 创建部门
function createApartment(name) {
    return {
        name,
        _children: [],
        add(target) {
            this._children.push(target);
            return this;
        },
        show(cb) {
            this._children.forEach(function (child) {
                child.show(cb)
            })
        }
    }
}

// 创建员工
function createEmp(num, name) {
    return {
        num,
        name,
        show(cb) {
            cb(this)
        }
    }
}

// 创建部门
const techApartment = createApartment('技术部');

// 创建子部门
const proApartment = createApartment('产品组'),
    devApartment = createApartment('开发组');

techApartment.add(proApartment).add(devApartment);

proApartment.add(createEmp(100, '张三'))
    .add(createEmp(101, '李四'))

techApartment.add(createEmp(201, '小刘'))
    .add(createEmp(202, '小王'))
    .add(createEmp(203, '小陈'))
    .add(createEmp(204, '小亮'))

// 遍历
techApartment.show(function (item) {
    console.log(`工号:${item.num},姓名:${item.name}`)
})

/***
    工号:100,姓名:张三
    工号:101,姓名:李四
    工号:201,姓名:小刘
    工号:202,姓名:小王
    工号:203,姓名:小陈
    工号:204,姓名:小亮
***/

上述我们同通过一个部门的组织架构图来展示了什么是组合模式,可以发现组合对象和单个子对象具有相同的接口和数据结构,一次来保证操作一致,我们在遍历整个 techApartment 对象时,如果当前对象是没有子对象,则自身会做处理,否则会传递到下一个子对象中处理,以此完成整个递归遍历。

优缺点

组合模式的组合对象和单个子对象具有同样的接口,所以无论调用的是组合对象还是叶子对象调用方式上没有差别,外部调用非常方便。拓展性良好,新增节点会很方便,也不会影响到其他的对象。

随着节点的增加,组合模式也暴露出其不足,过多的节点会导致整个树状结构非常复制,层级嵌套深,内存占用较高,导致系统整体性能下降。

适用场景

如果对象组织呈树形结构,操作树中对象的方法比较类似时可以考虑适用组合模式。常见的比如组织架构图,文件目录,以及熟悉的 vue 中的 createElement 方法等都采用的组合模式这种设计理念。

13、外观模式

外观模式的本质是封装交互,简化调用,它的做法是隐藏了系统的复杂性,将子系统的一组接口封装起来,给使用者提供了一个统一的高层接口,减少了客户端与子系统之间的耦合性。

JavaScript 中外观模式常常用于解决浏览器兼容性问题以及源码中的一些函数重载,很多主流的库,如 jQuerylodash 等都有涉及。

// 事件绑定
function addEvent(element, type, fn) {
    if (element.addEventListener) {      // 支持 DOM2 级事件处理方法的浏览器
        element.addEventListener(type, fn, false)
    } else if (element.attachEvent) {    // 不支持 DOM2 级但支持 attachEvent
        element.attachEvent('on' + type, fn)
    } else {
        element['on' + type] = fn        // 都不支持的浏览器
    }
}

// 阻止事件冒泡
function cancelBubble(event) {
    if (event.stopPropagation) {
        event.stopPropagation()
    } else {                    // IE 下
        event.cancelBubble = true
    }
}

// axios 中 getDefaultAdapter
function getDefaultAdapter() {
  var adapter;
  // Only Node.JS has a process variable that is of [[Class]] process
  if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  } else if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  }
  return adapter;
}

上述的代码片段都是演示了外观模式的特点,对于用户端来说都是统一的调用,但是接口内部却根据传参不同,或者运行环境不同等做了对应的处理,简化了客户端的使用。

优缺点

外观模式的优点在于使用者不必关系子系统的具体实现,通过统一的接口调用就能达到效果,降低了使用者和系统模块之间的耦合性,增加了可维护性和可扩展性。

由于外观模式是将一组子系统的接口进行整合,所以它的缺点就很明显,在系统内部扩展子系统时 , 容易产生风险。

适用场景

外观模式在很多开源作品中屡见不鲜,例如上述提到的 jQuerylodash 等,实际上我们开发也会经常用到。它比较适合将复杂的系统进行分层,让外观模块成为每层的入口,简化层与层之间调用。或者说当我们需要通过一个单独的函数或方法来访问一系列的函数或方法调用时,为了使代码更容易跟踪管理或者更好的维护时,可以考虑适用外观模式。

14、适配器模式

适配器模式,作为两个不兼容的接口之间的桥梁,目的就是通过适配器的转换解决类(对象)之间接口不兼容的问题,从而使得原本不兼容的接口可以兼容现有的需求。

image-20250210114504-6b3y4mi.png

与早些年传统的万能充电器的作用类似。

// 适配器模式

// 百度地图 api
const baiduMap = {
    show: function () {
        console.log('开始渲染百度地图')
    }
}


// 高德地图 api
const AMap = {
    render: function () {
        console.log('开始渲染高德地图')
    }
}


// 适配器
const baiduAdapter = {
    render: function () {
        return baiduMap.show()
    }
}


function renderMap(map) {
    if (typeof map.render === 'function') {
        map.render()
    }
}

renderMap(AMap);            // 开始渲染高德地图
renderMap(baiduAdapter);    // 开始渲染百度地图

上述代码中演示了适配器模式的原理,我们之前用的是高德地图 ,如今我们也要接入百度地图,二者的 api 的渲染方式不同,为了解决不兼容的问题,我们构造了一个 baiduAdapter 适配器,这样我们就可以适用同样的接口完成不同地图的渲染。

优缺点

适配器模式的优点

  1. 已有的功能如果只是接口不兼容,使用适配器适配已有功能,可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码;
  2. 可扩展性良好,在实现适配器功能的时候,可以调用自己开发的功能,从而方便地扩展系统的功能;
  3. 灵活性好,因为适配器并没有对原有对象的功能有所影响,如果不想使用适配器了,那么直接删掉即可,不会对使用原有对象的代码有影响;

适配器模式的缺点:会让系统变得零乱,明明调用 A,却被适配到了 B,如果系统中这样的情况很多,那么对可阅读性不太友好。如果没必要使用适配器模式的话,可以考虑重构,如果使用的话,可以考虑尽量把文档完善。

适用场景

当你想用已有对象的功能,却想修改它的接口时,一般可以考虑一下是不是可以应用适配器模式。

  • 如果你想要使用一个已经存在的对象,但是它的接口不满足需求,那么可以使用适配器模式,把已有的实现转换成你需要的接口;
  • 如果你想创建一个可以复用的对象,而且确定需要和一些不兼容的对象一起工作,这种情况可以使用适配器模式,然后需要什么就适配什么;

15、享元模式

享元模式,字面解释,享就是共享,元就是元素,公共部分。

因此,享元模式就是通过共享技术实现相同或相似对象的重用,主要用于减少创建对象的数量,以减少内存占用和提高性能。

// 享元对象
function Shape(shape) {
    this.shape = shape;
}

Shape.prototype.draw = function () {
    console.log(`画了一个 ${this.shape}`)
}

// 享员工厂
const ShapeFactory = (function () {
    const dataMap = {};
    return {
        getShapeContext(shape) {
            // 如果存在,则直接返回
            if (dataMap[shape]) return dataMap[shape];
            else {
                // 没有就创建,并保存当前shape的实例
                const instance = new Shape(shape);
                dataMap[shape] = instance
                return instance;
            }
        }
    }
})();

const rect = ShapeFactory.getShapeContext('rect');
const circle = ShapeFactory.getShapeContext('circle');

rect.draw();     // 画了一个 rect
circle.draw();   // 画了一个 circle

上述代码,我们用来一个绘画的例子,通过享元工厂去创建不同类型的 "画笔" 对象,并保存在我们的工厂函数中,下次使用的时候则不需要重新创建,直接从 map 中读取即可。这种方式,相比于传统的用的时候去 new 创建在数据量大的时候会节约很多内存。

优缺点

享元模式最大的优点就在于它可以极大的减少了系统中对象的创建,降低内存的使用,加快了运行速度,提高了运行效率。

提高效率的同时也暴露出其缺点,共享对象的创建,销毁等都需要增加额外的逻辑,会使整个系统的逻辑变得复杂,代码不容易阅读,维护的成本增加。

适用场景

享元模式比较适合项目中大量使用了相同或相似对象,可以共享资源时可以考虑。

其实在前端开发设计中还是比较常见的,例如我们所熟知和使用的 事件委托 经行事件绑定,就是利用了享元模式的原理,我们并不是给每个元素绑定事件,而是为其父元素绑定一个事件,根据事件参数 event 来判断。

另外 nodejs 中所使用的数据库连接池,一些缓存服务器的设计等是利用这个原理。

16、建造者模式

建造者模式,将一个复杂对象的构建与表示分离,使得同样的构建过程可以创建不同的表示。在工程模式中,我们不关心创建过程,直接得到一个完成的对象。而建造者模式中,我们关心对象的创建过程,将复杂对象模块化,使得每个模块都可以复用

// 建造者模式

/* 建造者 */
function ComputerBuilder(brand) {
    this.brand = brand;
}


ComputerBuilder.prototype.buildCPU = function (type) {
    switch (type) {
        case 'inter':
            this.cpu = 'inter 处理器';
            break;
        case 'AMD':
            this.cpu = 'AMD 处理器';
            break;
    }
    return this;
}

ComputerBuilder.prototype.buildMemory = function (mSize) {
    this.mSize = '内存' + mSize + 'G';
    return this;
}

ComputerBuilder.prototype.buildDisk = function (dSize) {
    this.dSize = '硬盘' + dSize + 'G';
    return this;
}


/* 厂家,负责组装 */
function computerDirector(brand, type, mSize, dSize) {
    const _computer = new ComputerBuilder(brand);
    _computer.buildCPU(type)
        .buildMemory(mSize)
        .buildDisk(dSize);
    return _computer;
}

const com = computerDirector('联想', 'inter', 16, 500);

console.log(com); // ComputerBuilder {brand: "联想", cpu: "inter 处理器", mSize: "内存16G", dSize: "硬盘500G"}

上述我们通过生产电脑的例子,描述了建造者模式的构建过程,我们的部件都是由一个个类创建出来的,最后进行组装完成整个对象的。如果后期需要拓展组件,只需要在建造者上增加对应的方法,再适当修改链式调用即可。

优缺点

建造者模式适用于构建复杂的、需要分步骤构建的对象,可以将构建过程分离,分步骤进行。优点显而易见,具有很好的拓展性,很高的复用性。

如果对象之间差异过大,复用性不高的话不建议使用这种模式,否则创建过程中会导致代码比较乱,复杂度过高,显得有些强行建造了。

适用场景

建造者模式适用于可以通过不同的部件组装得到不同完整产品的场景,可以将代码最小程度的拆分,利于后期维护。例如,你封装一个公共弹窗,里面涉及有标题,内容,按钮,文字等,但也不都是必须的,你可以在需要的时候去构建他们。

总结

在将函数作为一等对象的语言中,有许多需要利用对象多态性的设计模式,这些模式的结构与传统面向对象语言的结构大相径庭,实际上已经融入到了语言之中,我们可能经常使用它们,只是不知道它们的名字而已。

深入理解他们,并有意识地去使用设计模式来优化代码,提升效率,使我们的系统有更好的拓展性才是我们追求的。