JS中常见的设计模式

338 阅读8分钟

设计模式是某种思想,这种思想可以更规范更合理去管理代码,方便维护、升级、扩展、开发。

单例模式

Singleton单例模式 && Command命令模式

举例:

// 公用版块 utils
let utils = (function () {
    function debounce(func, wait) {}
    //...

    return {
        debounce: debounce
    };
})();

// A版块
let AModule = (function () {
    utils.debounce();

    function fn() {}

    function query() {}

    return {
        query: query
    };
})();

// B版块
let BModule = (function () {

    function getData() {}
    
    return {
        getData() {}
    };
})();

为了保证每个板块私有化,将每个板块单独写到一个闭包里面。

此时,其中的一个板块utils就叫做命名空间,它代表的就是最后return回来的那个对象,即那块空间的名字。它包含了暴露到外面的供别的模块调用的接口。这个板块就算是一个单例,这种组织代码的思想就叫做单例模式

其实直接声明一个对象,并且将一些东西放到那个对象中,那个对象也叫做命名空间,这也属于单例模式,只不过实现这种思想的方式仅仅是通过了一个对象,比较简单

而结合闭包的模块化,就可以算是高级的单例模式

作用:

  • 最早期的模块化编程思想「同样的还有AMD/CMD/CommonJS/ES6Module」
  • 避免全局变量的污染
  • 实现模块之间的相互调用「提供了模块导出的方案」

总结:

  1. 什么是单例模式?
  • 其实声明一个对象,并将一些方法和属性放到对象中就属于单例模式,那个对象也叫做命名空间。只不过实现这种思想的方式仅仅是通过了一个对象,比较简单
  • js中还有一种比较优雅的单例模式方法,就是立即执行函数返回一个对象,这个对象向外暴露接口供别的模块调用,这种使用闭包的方式时早起的模块化思想,也可以避免全局变量的污染。

命令模式

基于单例模式。

在实际的业务开发中,我们还可以基于命令模式管控方法的执行顺序,从而有效的实现出对应的功能。


// B版块{实现当前模块下需要完成的所有的功能}
let BModule = (function () {
    utils.debounce();
    AModule.query();

    // 获取数据
    function getData() {}

    // 绑定数据
    function binding() {}

    // 处理事件绑定
    function handle() {}

    // 处理其它事情的
    function fn() {}

    return {
        init() {
        // 模块的入口「相当于模块的大脑,控制模块中方法的执行顺序」
            getData();
            binding();
            handle();
            fn();
        }
    };
})();
BModule.init(); 

BModule.init()实现了控制模块中方法的执行顺序的功能,命令各种方法(功能模块)按照一定的顺序执行,这种模式就是命令模式。

早期基于原生js或者jquery开发的时候,都是基于单例模式,命令模式进行代码组织和开发的。

发布订阅模式

Publish & Subscribe 发布订阅模式

过程基本如下:

  1. 创建事件池 / 发布计划
  2. 向事件池中加入方法 / 向计划表中订阅任务
  3. fire(触发事件) / 通知计划表中的任务执行

image.png 应用场景:凡是某个阶段到达的时候,需要执行很多方法「更多时候,到底执行多少个方法不确定,需要编写业务边处理的」,我们都可以基于发布订阅设计模式来管理代码;

基本的发布订阅模式代码:

(function () {
    // 自己创造的事件池
    let pond = [];

    // 向事件池中注入方法
    function subscribe(func) {
        // 去重处理
        if (!pond.includes(func)) {
            pond.push(func);
        }

        // 每一次执行,返回的方法是用来移除当前新增的这一项的
        return function unsubscribe() {
            pond = pond.filter(item => item !== func);
        };
    }

    // 通知事件池中的每个方法执行
    subscribe.fire = function fire(...params) {
        pond.forEach(item => {
            if (typeof item === "function") {
                item(...params);
            }
        });
    };

    window.subscribe = subscribe;
})(); 

使用:

例如一个需求:希望从服务获取数据,并且获取数据后要对数据做很多处理


// 需求:从服务获取数据,获取数据后要干很多事情
// A
const fn1 = data => {
    console.log('fn1',data)
};
subscribe(fn1);

// B
const fn2 = data => {
    console.log('fn2',data)
};
subscribe(fn2);

// C
const fn3 = data => {
    console.log('fn3',data)
};
const unsubscribe = subscribe(fn3);
unsubscribe()



function query(){
    return new Promise(resolve=>{
        setTimeout(()=>{
           resolve('query data')
        },0)
    })
}
query().then(data => {
    subscribe.fire(data)//触发事件
});

image.png

如果使用下面这种方式

query().then(data => {
    fn1(data);
    fn2(data);
    fn3(data);
    fn4(data);
});

如果以后有新增一个方法fn5,那么还需要把方法加入到代码当中去,如果用了设计模式后,代码会变得更好一点

多个事件池、不同的事件类型与面向对象

一个项目中,我们可能会出现多个事情都需要基于发布订阅来管理,一个事件池不够,并且每次使发布订阅模式都需要重新

我们想实现

  1. 管理多个事件池
  2. 每个事件池支持不同的自定义事件类型
  3. 通过面向对象中类&实例的方式来进行抽象化,好处:
    • 让每个实例都有一个自己的私有事件池
    • subscribe/unsubscribe/fire方法是公用的,可以抽象在公有属性中 实现如下:

面向对象

首先改为面向对象的模式:

class Sub {
    // 实例私有的属性:私有的事件池
    pond = [];
    // 原型上设置方法:向事件池中订阅任务
    subscribe(func) {
        let self = this,
            pond = self.pond;
        if (!pond.includes(func)) pond.push(func);
        return function unsubscribe() {//移除,也可以用filter
            let i = 0,
                len = pond.length,
                item = null;
            for (; i < len; i++) {
                item = pond[i];
                if (item === func) {
                    pond.splice(i, 1);
                    break;
                }
            }
        };
    }
    // 通知当前实例所属事件池中的任务执行
    fire(...params) {
        let self = this,
            pond = self.pond;
        pond.forEach(item => {
            if (typeof item === "function") {
                item(...params);
            }
        });
    }
}

使用:

let sub1 = new Sub;
sub1.subscribe(function () {
    console.log(1, arguments);
});
sub1.subscribe(function () {
    console.log(2, arguments);
});
setTimeout(() => {
    sub1.fire(100, 200);
}, 1000);

let sub2 = new Sub;
sub2.subscribe(function () {
    console.log(3, arguments);
});
sub2.subscribe(function () {
    console.log(4, arguments);
});
setTimeout(() => {
    sub2.fire(300, 400);
}, 2000); 

多个事件池

我们用以下数据结构来设置事件池

pond = {
    A:[fn1,fn2],
    B:[fn1,fn2]
}

约定成俗地,几个操作的名字分别是:监听事件on, 移除事件 off, 触发事件 emit。也可以用add remove fire。这里我们用on,off,emit

整体结构如下:

let sub = (function () {
    let pond = {};

    // 向事件池中追加指定自定义事件类型的方法
    const on = function on(type, func) {};

    // 从事件池中移除指定自定义事件类型的方法
    const off = function off(type, func) {};

    // 通知事件池中指定自定义事件类型的方法执行
    const emit = function emit(type, ...params) {};

    return {
        on,
        off,
        emit
    };
})();

细节完善:

let sub = (function () {
    let pond = {};

    // 向事件池中追加指定自定义事件类型的方法
    const on = function on(type, func) {
        // 每一次增加的时候,验证当前类型在事件池中是否已经存在
        !Array.isArray(pond[type]) ? pond[type] = [] : null;//不存在就是undefined
        let arr = pond[type];
        if (arr.includes(func)) return;
        arr.push(func);
    };

    // 从事件池中移除指定自定义事件类型的方法
    const off = function off(type, func) {
        let arr = pond[type],
            i = 0,
            item = null;
        if (!Array.isArray(arr)) throw new TypeError(`${type} 自定义事件在事件池中并不存在!`);
        for (; i < arr.length; i++) {
            item = arr[i];
            if (item === func) {
                // 移除掉
                // arr.splice(i, 1); //这样导致数据塌陷
                arr[i] = null; //这样只是让集合中当前项值变为null,但是集合的机构是不发生改变的「索引不变」;下一次执行emit的时候,遇到当前项是null,我们再去把其移除掉即可;
                break;
            }
        }
    };

    // 通知事件池中指定自定义事件类型的方法执行
    const emit = function emit(type, ...params) {
        let arr = pond[type],
            i = 0,
            item = null;
        if (!Array.isArray(arr)) throw new TypeError(`${type} 自定义事件在事件池中并不存在!`);
        for (; i < arr.length; i++) {//取出事件池中的所有事件执行
            item = arr[i];
            if (typeof item === "function") {
                item(...params);
                continue;
            }
            //不是函数的值都移除掉即可
            arr.splice(i, 1);
            i--;//防止数组塌陷
        }
    };

    return {
        on,
        off,
        emit
    };
})();

测试:

const fn1 = () => console.log(1);
const fn2 = () => console.log(2);
const fn3 = () => {
    console.log(3);
    sub.off('A', fn1);
    sub.off('A', fn2);
};
const fn4 = () => console.log(4);
const fn5 = () => console.log(5);
const fn6 = () => console.log(6);

sub.on('A', fn1);
sub.on('A', fn2);
sub.on('A', fn3);
sub.on('A', fn4);
sub.on('A', fn5);
sub.on('A', fn6);
setTimeout(() => {
    sub.emit('A');
}, 1000);

setTimeout(() => {
    sub.emit('A');
}, 2000);

 sub.on('B', fn4);
sub.on('B', fn5);
sub.on('B', fn6);
setTimeout(() => {
    sub.emit('B');
}, 3000);

off函数中使用arr.splice(i, 1)造成数据塌陷的原因:在使用splice的时候,会改变数组长度和索引。所以在for循环中用splice的时候,for循环中的i不会变,但是数组长度和索引变了,所以会导致跳过一些数组中某些item的遍历

可以参考另一种简易的发布订阅器

浏览器中的事件绑定与发布订阅模式

DOM2事件绑定(addEventListener等)就是基于发布订阅模式来运行的。

DOM事件绑定的原理是给元素对象对应的事件行为的私有属性赋值。而DOM2事件绑定机制如下:

  • 给当前元素的某一个事件行为,绑定多个不同的方法「事件池机制
  • 事件行为触发,会依次通知事件池中的方法执行
  • 但是他只支持内置的标准事件,例如:click、dblclick、mouseenter.,我们没办法自定义事件

观察者模式

Observer 观察者模式也和发布订阅模式的思想差不多

  • 定义观察者observer:形式可以不一样,只需要观察者具备update方法即可,用来接收到消息的时候做出反应
  • 定义目标Subject:目标用来管理观察者,将具有update方法的观察者加入到观察者列表,observerList
  • 当目标发送信息(notify)的时候,可以把消息传给观察者,观察者触发update方法即可

和发布订阅模式差不多。

image.png 代码如下:

 // 定义观察者:形式可以不一样,只需要具备update方法即可
class OBSERVER {
    update(msg) {
        console.log(`我是观察者1,我接收到的消息是:${msg}`);
    }
}
let DEMO = {
    update(msg) {
        console.log(`我是观察者2,我接收到的消息是:${msg}`);
    }
};

// 目标
class Subject {
    observerList = [];
    add(observer) {
        this.observerList.push(observer);
    }
    remove(observer) {
        // 没有考虑塌陷问题
        this.observerList = this.observerList.filter(item => item !== observer);
    }
    notify(...params) {
        this.observerList.forEach(item => {
            if (item && typeof item.update === "function") {
                item.update(...params);
            }
        });
    }
}
let sub = new Subject;
sub.add(new OBSERVER);
sub.add(DEMO);
setTimeout(() => {
    sub.notify('hello world~~');
}, 1000);

构造器模式

Constructor构造器模式:站在面向对象的思想上去构建项目

  • 自定义类和实例
  • 私有&公有属性和方法

使用的地方:编写公共的类库 & 插件组件

function Fn() {
    this.xxx = xxx;
}
Fn.prototype = {
    constructor: Fn,
    query() {},
    // ...
};
Fn.xxx = function () {}; 
class Fn {
    constructor() {
        this.xxx = xxx;
    }
    query() {}
    static xxx() {}
}
let f1 = new Fn;
let f2 = new Fn; 

我们在使用某些插件时,每一次调用插件我们都是创造这个类的一个实例,既保证每个实例之间(每次调用之间)有自己的私有属性,互不影响,也可以保证一些属性方法还是公用的,有效避免代码的冗余

所以在我们自己写公共类库,或者插件组件的时候,基本都要用到构造器模式,即面向对象思想

工厂模式

简单的工厂模式:一个方法根据传递参数的不同,做了不同的处理。

function factory(options) {
    if (options == null) options = {};
    if (!/^(object|function)$/i.test(typeof options)) options = {};
    let {
        type,//类型
        payload//值
    } = options;
    if (type === 'MYSQL') {
        // ...
        return;
    }
    if (type === 'SQLSERVER') {
        // ...
        return;
    }
    // ...
}

factory({
    type: 'SQLSERVER',
    payload: {
        root: '',
        pass: '',
        select: ''
    }
}); 

这样我们就可以根据传入的配置,进行不同的处理,就像工厂加工东西一样