史上最全的JS设计模式(一)

4,775 阅读32分钟
在大部分同学的眼里,设计模式是非常神秘的东西,感觉自己curd的时候也接触不到。这往往是一种误解,我们在实际开发过程中也会写出一些符合某些设计模式的代码。本文主要想介绍一下设计模式的概念,同时用通俗易懂的demo给大家一个比较清晰、全面的认识

设计模式是什么

设计模式(design pattern)这个术语是上个世纪90年代由Erich Gamma、Richard Helm、Raplh Johnson和Jonhn Vlissides四个人总结提炼出来的,并且写了一本Design Patterns的书。这四人也被称为四人帮(GoF)。

维基百科中的描述是:设计模式是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。设计模式并不直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。

换一种更简短说法就是:设计模式是解决特定场景的一系列通用方案

设计模式的好处

  1. 设计模式是被反复验证的解决方案(最佳实践),可以减少代码强耦合、硬编码,有效的提高代码的可扩展性和可维护性
  2. 设计模式是一种通用方案和思维,它不限制于特定编程语言
  3. 提供一种通用术语,方便交流,减少沟通中的理解成本。


设计模式的原则

做什么事都需要遵循一些准则,设计模式也不例外。我们在设计一些设计模式时,一般遵循如下6项基本原则,它们分别是:

Single-responsibility principle(单一职责原则)
每个软件模块都有且只有一个需要被改变的理由。
单一职责能够降低模块的复杂度,减少功能变更引起的关联风险,提高代码可读性和可维护性。

Open–closed principle(开放封闭原则)
系统应该对扩展开放,对修改关闭。
如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而不是只能靠修改原来的代码。

Liskov substitution principle(里斯科夫替代原则)
一个子类应该可以替换掉父类并且可以正常工作

Interface segregation principle(接口隔离原则)
一个类对另一个类的依赖应该建立在最小的接口上。
主要告诫软件设计师应该在设计中避免不必要的依赖。

Dependency inversion principle(依赖反转原则)
高层策略性的代码不应该依赖实现底层细节的代码,相反,底层细节代码应该依赖高层策略性的代码。

Law of Demeter(迪米特法则)
减少模块间的依赖。只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。



设计模式的类别

《设计模式》中一共提出了23个经典模式,他们可以分为创建型、结构型和行为型三类:
类型
目的
模式
核心
创建型
处理对象创建机制

使得程序可以更加灵活的判断针对某个给定实例需要创建哪些对象。

单例模式
确保一个类只有一个实例,并提供对该实例的全局访问。


简单工厂模式
一个工厂类根据传入的参量决定创建出那一种产品类的实例。


工厂模式
定义一个接口用于创建对象,但是让子类决定初始化哪个类。工厂方法把一个类的初始化下放到子类。


抽象工厂模式
为一个产品族提供了统一的创建接口。当需要这个产品族的某一系列的时候,可以从抽象工厂中选出相应的系列创建一个具体的工厂类


建造者模式
封装一个复杂对象的构建过程,并可以按步骤构造


原型模式
用原型实例指定创建对象的种类,并且通过拷贝这些原型,创建新的对象
结构型
处理对象的组合

将对象结合在一起形成更大的结构
代理模式
通过替身对象实现对访问动作的控制和处理


适配器模式
将一个类的方法接口转换成客户希望的另外一个接口


装饰器模式

动态的给对象添加新的功能


组合模式
将对象组合成树形结构以表示“”部分-整体“”的层次结构


享元模式
通过共享技术来有效的支持大量细粒度的对象


外观模式
对外提供一个统一的方法,来访问子系统中的一群接口


桥接模式
将抽象部分和它的实现部分分离,使它们都可以独立的变化
行为型
改善或者简化系统中不同对象之间的通信
观察者模式
定义了对象一对多的依赖关系。当目标对象状态发生变化后,会通知到所有的依赖对象


模版模式
抽象父类定义抽象方法和具体运行策略,来制定子类的运行顺序和机制;
具体子类来重写父类的抽象方法


策略模式
定义多个策略类实现具体算法
定义一个环境类通过请求参数来决定使用哪些策略


状态模式
允许一个对象在其对象内部状态改变时改变它的行为


中介者模式
用一个中介对象来封装一系列的对象交互


迭代器模式
提供一种方法顺序访问一个聚合对象中的各个元素,不需要关心对象的内部构造


备忘录模式
在不破坏封装的前提下,保持对象的内部状态


访问者模式
在不改变数据结构的前提下,增加作用于一组对象元素的新功能


解释器模式
给定一个语言,定义它的文法的一种表示,并定义一个解释器


命令模式
将命令请求封装为一个对象,使得可以用不同的请求来进行参数化。


职责链模式
将请求的发送者和接收者解耦,使的多个对象都有处理这个请求的机会


基于某些原因(​'熬了几个晚上写不动了' || '我目前也不太熟悉该模式'​),本次先给大家介绍JavaScript 开发中常见的 13 种设计模式 + 一些额外扩展的模式




【创建型】

模块模式

模块模式是在传统软件工程中为类提供私有和公有封装的方法。通过这种方式,让一个对象拥有私有和公有的方法/变量,有效控制对外暴露的api接口,屏蔽底层处理逻辑

但是由于js曾经没有访问修饰符,从技术的角度来说,我们不能称js变量为私有和公有。所以我们需要使用 IIFE(即时调用函数表达式)、闭包和函数作用域的方式来实现js的模块模式。

var obj=(function(){    
    var count=0;    
    return {        
        addCount:function(){ count++ },        
        getCount:function(){ return count }    
    }
})()
IIFE使得obj会获得function中返回的对象,同时只有对象中的函数可以闭包访问到内部的count变量,达到私有的目的。

最终外部采用调用模块的公有属性/方法来访问和操作私有变量

obj.addCount() // 1
obj.getCount() // 1
obj.count // undefined

  • 应用场景
    • 需要管理大量私有变量/方法,希望屏蔽内部处理逻辑,只对外暴露接口的独立模块
  • 优点
    • 采用封装的思想,只有本模块才能享有私有变量,不会暴露于外部模块
    • 减少对全局作用域的影响,避免命名空间污染;
    • 模块化有助于保持代码的整洁、隔离和条理性。
  • 缺点
    • 无法访问方法调用后产生的私有变量



揭示模块模式

某位同学(Christian Heilmann) 不满模块模式中必须创建新的公共函数来调用私有函数和变量,所以略微改进了模块模式为揭示模块模式: 在本模块的私有作用域范围内定义所有的方法和变量,将把返回对象的属性映射到我们想要公开的私有函数。
var obj=(function(){    
    var count=0;    
    function addCount(){ count++ }    
    function getCount(){ return count }    
    return {        
        addCount: addCount,       
        getCount: getCount    
    }
})()
于模块模式相比
  • 优点:
    • 方法从private改为public非常简单,只需要改属性映射
    • 返回的对象不包含任何函数定义,公开的函数和变量一目了然,有助于提高代码的可读性
  • 缺点:
    • 导致私有函数升级困难。如果一个私有函数引用一个公有函数,在需要打补丁时,公有函数是不能被覆盖的。eg:

function rmpUrlBuilder(){  
    var _urlBase ="http://my.default.domain/";  
    var _build = function(relUrl){    
        return _urlBase + relUrl;  
    };  
    return {    
        urlBase: _urlBase,    
        build: _build  }
}

var builder = new rmpUrlBuilder();
builder.urlBase ="http://stackoverflow.com";
console.log(builder.build("/questions"); 
// 打印结果是 "http://my.default.domain/questions" 
// 而不是 "http://stackoverflow.com/questions"




单例模式

单例模式限制某个类只能被创建一次,并且需要提供一个全局的访问点。如果一个类的实例不存在,单例模式就会创建一个新的类实例。如果实例存在,它只返回对该对象的引用。对构造函数的任何重复调用都会获取相同的对象。

js中单例模式可以使用构造函数实现,例如:

let instance = null;
function User() {  
    if(instance) {    
        return instance;  
    }  
    instance = this;  
    this.name = 'Peter';  
    this.age = 25;  
    return instance;
}

const user1 = new User();
const user2 = new User();
console.log(user1 === user2); // 打印 true

当调用这个构造函数时,它会检查实例对象是否存在。如果对象不存在,它就将这个变量赋给实例变量。如果对象存在,它只返回那个对象。


单例也可以使用模块模式实现,例如:

const singleton = (function() {  
    let instance;  
    function init() {    
        return {      
            name: 'Peter',      
            age: 24,    };  
        }  
    return {    
        getInstance: function() {
            if(!instance) {
                instance = init();      
            }      
            return instance;    
        }  
    }
})();

const instanceA = singleton.getInstance();
const instanceB = singleton.getInstance();
console.log(instanceA === instanceB); // 打印 true

在上面的代码中,我们通过调用 singleton.getInstance 方法来创建一个新实例。如果实例已经存在,则该方法只是返回这个实例,如果实例不存在,则调用 init() 函数创建一个新的实例。

小结
  • 应用场景:
    • 应用中有对象需要是全局的且唯一。比如页面全局蒙层。
  • 优点:
    • 适用于单一对象,只生成一个对象实例,避免频繁创建和销毁实例,减少内存占用。
  • 缺点:
    • 不适用动态扩展对象,或需创建多个相似对象的场景



简单工厂模式

简单工厂模式是一种创建型模式,它不需要指定所创建对象的确切类或构造函数。由工厂对象提供对外提供通用的工厂方法,然后根据我们的特定需求/条件,生成不同的对象,在创建对象的过程中不用公开实例化的逻辑。

举个例子:在系统通知中我们会发送多种类型的消息,如邮件、微信、短信、电话等。我们在创建不同消息的时候,可以采用工厂模式。通过传入的参数不同,输出不同的消息对象。

class Email {
    constructor(options) {
        this.message = options.message || '我是emial信息';
        this.type = options.type || 'email',
        this.sender = options.user;
        this.receiver = options.receiver;
        this.sendTime = options.sendTime || new Date()
    }
}
class Weixin {
    constructor(options) {
        this.msg = options.message || '我是微信信息';
        this.type = options.type || 'weixin',
        this.sender = options.user;
        this.receiver = options.receiver;
    }
}
class MessageFactory {
    create(options) {
        switch (option.type) {
        case 'email':
            return new Email(options);
        case 'weixin':
            return new Weixin(options);
        default:
            return null;
        }
    }
}

在这里我创建了一个 Email 类和一个 Weixin 类(带有一些默认值),用于创建新的 Email 和 Weixin 对象。我还定义了一个 MessageFactory 类,基于 options 对象中接收到的 type 属性创建和返回一个新的对象。

const factory = new MessageFactory();
const emailMsg = factory.create({
    type: 'email',
    message: '你好,有兴趣了解下安利吗',
    user: 'xiaojia',
    receiver: 'xx1',
});
const weixinMsg = factory.create({
    type: 'weixin',
    message: 'i m 卖保险',
    user: 'xiaojia',
    receiver: 'xx2',
});

我已经创建了一个新的 MessageFactory 类的对象工厂。之后,我们可以调用 factory.create 方法,传入一个 type 属性值为 email 或 weixin 的 options ,来创建不同的消息对象。

  • 应用场景:
    • 需要处理共享多个相同属性的小型对象/组件
    • 可以根据不同的环境/参数生成对象的不同实例
    • 对象的类型已知、有限
  • 优点:
    • 能解决创建多个相似对象的问题。
  • 缺点:
    • 违背了开放封闭原则。每增加一个产品,都需要修改工厂类的代码
    • 对象的类型不知道



工厂模式

在简单工厂模式中,一个工厂类负责所有产品对象的创建,这个工厂类的职责大大增加,可能客户端对于某些产品的创建方式会有不同的要求,这样的话,就要不断的修改工厂类,增加相应的判断逻辑,不利于后期的代码维护。

所以我们将简单工厂模式进一步抽象化,实现工厂模式:让工厂子类去实现抽象工厂类的接口,由每个具体的工厂类负责创建单独的产品,如果有新的产品加进来,只需要增加一个具体的创建产品工厂类和具体的产品类就可以了,不会影响到已有的其他代码,代码量也不会变大,后期维护更加容易,增加了系统的可扩展性。

我们基于上述MessageFactory的代码进行修改

class MessageFactory {
    create(options) {
        throw new Error('需要create方法')
    }
}
class EmailMsgFactory extends MessageFactory {
    create(options) {
        return new Email(options)
    }
}


  • 应用场景:
    • 需要处理多个复杂产品对象
    • 不同的工厂和产品可以提供客户端不同的服务或功能
    • 产品对象的类型会动态增加
  • 优点:
    • 克服了简单工厂违背开放-封闭原则的缺点,后期维护更加容易。
  • 缺点:
    • 每增加一个产品,相应的也要增加一个子工厂,加大了额外的开发量



原型模式

原型模式主要是用于创建重复的对象。通俗点讲就是创建一个共享的原型,并通过拷贝这些原型创建新的对象。

js中可以通过Object.create来实现

var myCar = {
    name: "Ford Escort",
    drive: function() {
        console.log("Weeee. I'm driving!");
    },
    panic: function() {
        console.log("Wait. How do you stop this thing?");
    }
}; 

// Use Object.create to instantiate a new car
var yourCar = Object.create( myCar );

// Now we can see that one is a prototype of the other
console.log( yourCar.name );

  • 应用场景:
    • 需要大量复制已存在的对象,对象间又相互独立
  • 优点:
    • / 实现功能吧,没想出什么优点
  • 缺点:
    • /




【结构型】

代理模式

代理模式的核心是为对象提供一个代理对象,来控制对目标对象的访问,客户其实访问的是这个代理对象。这样代理对象就可以对请求做出一些处理之后,再将请求转交给本体对象。

代理模式中常见的有保护代理、虚拟代理、缓存代理。

保护代理主要是限制了访问主体的行为。下面以过滤消息中的敏感信息作为简单的例子

// 主体,发送消息
function sendMsg(msg) {
    console.log(msg);
}

// 代理,对消息进行过滤
function proxySendMsg(msg) {
    // 无消息则直接返回
    if (typeof msg === 'undefined') {
        console.log('deny');
        return;
    }

    // 有消息则进行过滤
    msg = ('' + msg).replace(/敏感信息/g, '****');

    sendMsg(msg);
}

sendMsg('敏感信息'); // 敏感信息
proxySendMsg('敏感信息'); // ****
proxySendMsg(); // deny
虚拟代理主要是在访问行为中加入一些额外操作,最常见的例子有函数防抖。我们的目的是去触发fn,但是debounce函数会对清除老的timer,并且将fn放到新的timer中。

// 函数防抖,频繁操作中不处理,直到操作完成之后(再过 delay 的时间)才处理
function debounce(fn, delay) {
    delay = delay || 200;

    var timer = null;

    return function() {
        var arg = arguments;

        // 每次操作时,清除上次的定时器
        clearTimeout(timer);
        timer = null;

        // 定义新的定时器,一段时间后进行操作
        timer = setTimeout(function() {
            fn.apply(this, arg);
        },
        delay);
    }
};


另外,ES6所提供​Proxy​构造函数也能够让我们轻松的使用代理模式。

  • 应用场景
    • 访问对象比较复杂,并且需要对访问行为进行控制
  • 优点:
    • 依托代理,可额外添加扩展功能,而不修改本体对象,符合 “开发-封闭原则”
    • 对象职能粒度细分,函数功能复杂度降低
  • 缺点:
    • 额外代理对象的创建,增加部分内存开销
    • 处理请求速度可能有差别,非直接访问存在开销



适配器模式

在生活中我们常常会用到电源适配器、Type-C转接头,这些器件都是为了不同的设备能够兼容协作。

适配器模式的目的也是这样:将一个对象的接口(方法或属性)转化成客户希望的另外一个接口(方法或属性),使得原本由于接口不兼容而不能一起工作的那些对象可以正常协作。

工作中最常见的就是各种数据格式的转换,以传递给不同的插件方法。

function dataConvenrt(data){    
    // do something    
    return dealData
}

适配器模式也非常适用于跨浏览器兼容,例如强大的 jQuery 封装了事件处理的适配器,解决跨浏览器兼容性问题,极大简化我们日常编程操作。

function on(target, event, callback) {
    if (target.addEventListener) {
        // 标准事件监听
        target.addEventListener(event, callback);
    } else if (target.attachEvent) {
        // IE低版本事件监听
        target.attachEvent(event, callback)
    } else {
        // 低版本浏览器事件监听
        target[`on$ {
            event
        }`] = callback
    }
}
  • 与代理模式的差异:
    • 两者都会在访问原始对象前对请求数据进行处理。但是适配器的目的是为了兼容不同对象的接口/属性,而代理模式是为了控制访问行为。
  • 应用场景:
    • 需要兼容不同对象的接口。比如跨浏览器兼容、整合第三方SDK、新老接口兼容
  • 优点:
    • 兼容性好,保证外部可统一接口调用
  • 缺点:
    • 额外对象的创建,非直接调用,存在一定的开销(且不像代理模式在某些功能点上可实现性能优化)。



装饰器模式

我们在打游戏时,游戏角色会带上各种buff、debuff。这种属性并不是继承而来,而是游戏过程中动态增加的。这种场景就非常适合装饰器模式。
装饰器模式可以动态地给对象添加一些新功能。它是一种“即用即付”的方式,能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。

JS中最简单的装饰器就是重写对象的属性

var A = {    
    score: 10
};

A.score = '分数:' + A.score;

也可以通过构造函数和原型的方式来实现装饰器,并且经过多重包装可以形成一条装饰链

function Person() {}

Person.prototype.skill = function() {
    console.log('数学');
};

// 装饰器,还会音乐
function MusicDecorator(person) {
    this.person = person;
}

MusicDecorator.prototype.skill = function() {
    this.person.skill();
    console.log('音乐');
};

// 装饰器,还会跑步
function RunDecorator(person) {
    this.person = person;
}

RunDecorator.prototype.skill = function() {
    this.person.skill();
    console.log('跑步');
};

var person = new Person();

// 装饰一下
var person1 = new MusicDecorator(person);
// 再装饰一下
person1 = new RunDecorator(person1);

person.skill(); // 数学
person1.skill(); // 数学 音乐 跑步


最新的ECMA 中有装饰器(Decorator)的提案,它是一种与类(class)相关的语法,用来注释或修改类和类方法。有兴趣的同学可以看下阮一峰的介绍

  • 应用场景:动态地给对象添加一些新功能,并且不改变对象本身。
  • 优点:同上
  • 缺点:/





组合模式

组合模式是为了解决大型/复杂对象的结构问题,用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的。

这种组合有具有一定的要求和条件:1. 对象可以用树形结构来表示;2. 根对象和叶对象属于同一类,需要具有相同的接口。

最常见的例子有递归扫描文件夹中的文件

// 文件夹 组合对象
function Folder(name) {
    this.name = name;
    this.parent = null;
    this.files = [];
}

Folder.prototype = {
    constructor: Folder,

    add: function(file) {
        file.parent = this;
        this.files.push(file);

        return this;
    },

    scan: function() {
        // 委托给叶对象处理
        for (var i = 0; i < this.files.length; ++i) {
            this.files[i].scan();
        }
    },

    remove: function(file) {
        if (typeof file === 'undefined') {
            this.files = [];
            return;
        }

        for (var i = 0; i < this.files.length; ++i) {
            if (this.files[i] === file) {
                this.files.splice(i, 1);
            }
        }
    }
};

// 文件 叶对象
function File(name) {
    this.name = name;
    this.parent = null;
}

File.prototype = {
    constructor: File,

    add: function() {
        console.log('文件里面不能添加文件');
    },

    scan: function() {
        var name = [this.name];
        var parent = this.parent;

        while (parent) {
            name.unshift(parent.name);
            parent = parent.parent;
        }

        console.log(name.join(' / '));
    }
};
我们在构造组合对象和叶对象后进行实例化,插入数据

var web = new Folder('Web');
var fe = new Folder('前端');
var css = new Folder('CSS');
var js = new Folder('js');
var rd = new Folder('后端');

web.add(fe).add(rd);

var file1 = new File('HTML权威指南.pdf');
var file2 = new File('CSS权威指南.pdf');
var file3 = new File('JavaScript权威指南.pdf');
var file4 = new File('MySQL基础.pdf');
var file5 = new File('Web安全.pdf');
var file6 = new File('Linux菜鸟.pdf');

css.add(file2);
fe.add(file1).add(file3).add(css).add(js);
rd.add(file4).add(file5);
web.add(file6);

rd.remove(file4);

// 扫描
web.scan();
// 结果
// Web / 前端 / HTML权威指南.pdf
// Web / 前端 / JavaScript权威指南.pdf
// Web / 前端 / CSS / CSS权威指南.pdf
// Web / 后端 / Web安全.pdf
// Web / Linux菜鸟.pdf


  • 应用场景:
    • 可以组合成树形结构的复杂对象,并且需要对外提供一致性的操作接口。
    • 优化处理递归或分级数据结构
  • 优点:
    • 忽略组合对象和单个对象的差别,对外一致接口使用;
    • 解耦调用者与复杂元素之间的联系,处理方式变得简单。
  • 缺点
    • 树叶对象接口一致,无法区分,只有在运行时方可辨别;





外观模式

外观模式是一种非常简单而又无处不在的模式。外观模式对外提供一个统一高层的方法,来访问子系统中的一群接口,能够隐藏其底层的复杂性。

通俗点来解释这个模式:今天瑞瑞准备叫外卖。单点是一种方式,但面对各种单品也是无从下手,这时瑞瑞就会选择看套餐,因为这个是已经搭配好的,并且没有选择纠结。这个套餐就是我们的外观模式雏型,把一些细碎的东西收起来,统一对外开放。

举个🌰,上传附件时需要经过多个过程,但是我们对外只需要暴露一个upload方法

fcuntion tosUpload(){    
    // 先获取上传token    
    // 执行上传    
    // 上传后将附件数据发送给业务后台服务
}
  • 优点:
    • 简化接口,易于使用
    • 使用者与底层代码解耦
  • 缺点:
    • 隐藏底层逻辑,不易调试
    • 子系统需要能够提供稳定服务
  • 和组合模式的差别:外观模式内部需要知道具体哪几个对象,组合模式是取全量叶节点
  • 和中介模式的差别:外观模式处理的是类之间的复杂依赖关系,中介模式处理的是对象之间复杂的通信机制





【行为型】

观察者模式

观察者模式是一种行为型模式,关注的是对象之间的通讯,观察者模式就是观察者和被观察者之间的通讯,主要用于一个对象(目标)去维持多个依赖于它的对象(观察者),将相关变更的事件自动通知给他们的场景。

function Subject(){    this.observerList=[] // 初始化观察者队列}function Observer(){    this.update = function(ctx){        // do something wiht ctx    }}// 增加观察者Subject.prototype.addObserver = function(observer){    this.observerList.push(observer)}// 通知观察者Subject.prototype.notify = function(ctx){    this.observerList.forEach(observer=>{        observer.update(ctx)    })}


  • 应用场景:
    • 对一个对象状态的更新,需要其他对象同步更新,而且其他对象的数量动态可变。
    • 对象仅需要将自己的更新通知给其他对象而不需要知道其他对象的细节。
    • 比如采购中,寻源结果审批通过后,会要通知相关采购负责人、自动创建合同信息等后续操作。
  • 优点:
    • 观察者模式在被观察者和观察者之间建立一个抽象的耦合。被观察者角色所知道的只是一个具体观察者列表,每一个具体观察者都符合一个抽象观察者的接口。
    • 观察者模式支持广播通讯。被观察者会向所有的登记过的观察者发出通知,
  • 缺点:
    • 如果一个被观察者对象有很多的直接和间接的观察者的话,同步通知花费时间会很长。
    • 如果在被观察者之间有循环依赖的话,被观察者会触发它们之间进行循环调用,导致系统崩溃。



发布订阅模式

其实24种基本的设计模式中并没有发布订阅模式,他只是观察者模式的一个别称。但是经过时间的沉淀,似乎他已经强大了起来,已经独立于观察者模式,成为另外一种不同的设计模式。

在现在的发布订阅模式中,称为发布者的消息发送者不会将消息直接发送给订阅者,这意味着发布者和订阅者不知道彼此的存在。在发布者和订阅者之间存在第三个组件,称为调度中心或事件通道,它维持着发布者和订阅者之间的联系,过滤所有发布者传入的消息并相应地分发它们给订阅者。

举一个例子,你在微博上关注了A,同时其他很多人也关注了A,那么当A发布动态的时候,微博就会为你们推送这条动态。A就是发布者,你是订阅者,微博就是调度中心,你和A是没有直接的消息往来的,全是通过微博来协调的(你的关注,A的发布动态)。

我们每个人在编程的时候都用过发布订阅模式,比如DOM事件绑定addEventListener、vue数据双向绑定。

考虑到面试经常问到,我就不写demo代码了。。
  • 优点:
    • 相较于观察者模式,发布/订阅发布者和订阅者的耦合性更低
    • 事件通知分发是异步的
  • 缺点:
    • 发布者不知道所有订阅者是否成功收到通知
  • 和观察者模式的区别:
    • 观察者模式中直接通信。然而在发布订阅模式中只有通过消息代理进行通信
    • 发布/订阅模式相比于观察者模式多了一个中间媒介,因为这个中间媒介,发布者和订阅者的关联更为松耦合
    • 观察者模式大多数时候是同步的,比如当事件触发,Subject就会去调用观察者的方法。而发布-订阅模式大多数时候是异步的



策略模式

策略模式的意义是定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。具体实现是由多个策略类实现具体算法,然后由一个环境类来通过请求参数决定使用哪些策略。

策略模式利用组合、委托等方法,可以有效避免多个if条件语句。适合用于组合一系列算法,或者组合一系列相同目的的业务规则。

举个业务场景,我们如果要根据不同销售等级来计算工资,可能会写出这么僵硬的代码:

var calculateBouns = function(salary, level) {
    if (level === 'A') {
        return salary * 4;
    }
    if (level === 'B') {
        return salary * 3;
    }
    if (level === 'C') {
        return salary * 2;
    }
}; 

// 调用如下:
console.log(calculateBouns(4000,'A')); // 16000
console.log(calculateBouns(2500,'B')); // 7500

这段代码包含多个if-else ,并且也缺乏弹性。如果还来个D等级,或者A等级的计算规则需要改变,那么就需要在 calculateBouns 方法中去修改,违背了开-闭原则。

我们基于策略模式重构一下。现在我们将具体策略封装起来,可以看到代码职责更新分明,代码变得更加清晰。并且代码的可拓展性更强,增加/修改策略,只需要在策略集合obj中去维护.

var obj = {
    "A": function(salary) {
        return salary * 4;
    },
    "B": function(salary) {
        return salary * 3;
    },
    "C": function(salary) {
        return salary * 2;
    }
};
var calculateBouns = function(level, salary) {
    return obj[level](salary);
};
console.log(calculateBouns('A', 10000)); // 40000

策略模式也常用于表单验证,定义不同的规则校验方法,调用验证的时候只需要传入规则名即可。

  • 应用场景:
    • 调用方依赖1个或多个策略
    • 业务场景有多种条件处理方案
  • 优点:
    • 减少if-else,代码更整洁直观
    • 提供开放-封闭原则,代码更容易理解和扩展
  • 缺点:
    • 策略集合通常会比较多,需要事先了解定义好所有的情况
    • 使用方必须理解不同策略的区别



模板模式

模板模式为了解决不同对象的相同行为的场景,它由两部分组成:抽象父类 + 具体的实现子类。抽象父类定义抽象方法和具体运行策略,来制定子类方法的运行顺序和机制;具体子类来重写父类的抽象方法,实现不同的处理逻辑。

举个业务场景例子:在头条面试的流程是:笔试-技术面-leader面-hr面。百度的面试流程也是这样。那我们就可以用模板模式,创建一个抽象的面试类,在面试类中定义面试的流程,然后由头条面试/百度面试对象继承和重写具体的面试步骤内容,比如说头条笔试是考算法、百度笔试是考css。并且可以通过钩子函数来解决是否需要某些运行步骤。

定义面试抽象类,init为具体子类方法运行策略,其他方法是抽象方法。

// 面试
function Interview(companyName) {
    this.companyName = companyName
}

Interview.prototype = {
    constructor: Interview,
    // 模板,按顺序执行
    init: function() {
        this.writtenTest();
        this.techTest();
        this.leaderTest();
        if (this.needHrTest()) { // 钩子函数
            this.hrTest();
        }
    },

    // 笔试
    writtenTest: function() {
        throw new Error('必须传递 writtenTest 方法');
    },

    // 面试
    techTest: function() {
        throw new Error('必须传递 techTest 方法');
    },

    // leader面
    leaderTest: function() {
        throw new Error('必须传递 leaderTest 方法');
    },

    // 是否需要hr面
    needHrTest: function() {
        return true
    },

    // hr面
    hrTest: function() {
        throw new Error('必须传递 hrTest 方法');
    }
};


子类实现具体抽象方法,并调用init方法执行面试行为。

var TouTiaoInterview = function() {};
TouTiaoInterview.prototype = new Interview();

// 子类重写方法 实现自己的业务逻辑
TouTiaoInterview.prototype.writtenTest = function() {
    console.log("先来个红黑树");
}
TouTiaoInterview.prototype.techTest = function() {
    console.log("你对什么比较了解");
}
TouTiaoInterview.prototype.leaderTest = function() {
    console.log("leader谈笑风生");
}
TouTiaoInterview.prototype.hrTest = function() {
    console.log("人力资源太不给力了,我等的花儿都谢了!!");
}
var TouTiaoInterview = new TouTiaoInterview();
TouTiaoInterview.init();
  • 应用场景:
    • 在多个子类拥有相同的方法,并且这些方法逻辑相同时
    • 在程序的主框架相同,细节不同的场合下
  • 优点:
    • 利用模板模式可以将相同处理逻辑的代码放到抽象父类中,提高了代码的复用性。
    • 将不同的逻辑放到不同的子类中,通过子类的扩展增加新的行为,提高了代码的扩展性。
  • 缺点:
    • 类数量增加间接增加了系统的复杂性
    • 因为继承关系的自身缺点,如果父类添加一个新的抽象方法,所有子类都要实现一遍。



状态模式

对象的状态会影响对象的行为,并且行为中伴随着状态的改变时,就非常适合状态模式。把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部

我们每天也都处于状态模式下:工作=>睡觉=>工作=>睡觉。那我们可以定一个person对象和work、sleep两种状态

function Person(name) {
    this.name = name this.currentState = null;

    // 状态
    this.workState = new WorkState(this) this.sleepState = new SleepState(this)

    this.init()
}

Person.prototype.init = function() {
    this.currentState = this.workState; // 设置初始状态
    this.currentState.behaviour(); // 开始初始状态的行为
};
Person.prototype.setState = function(state) {
    this.currentState = state;
    this.currentState.behaviour();
}

// 工作状态
function WorkState(person) {
    this.person = person
}
WorkState.prototype.behaviour = function() {
    console.log(this.person.name + ' 上班摸鱼了8小时')
    // 触发[睡觉]状态
    setTimeout(() = >{
        this.person.setState(this.person.sleepState);
    },
    2 * 1000);
}
// 睡觉状态
function SleepState(person) {
    this.person = person
}
SleepState.prototype.behaviour = function() {
    console.log(this.person.name + ' 睡了了14小时')
    // 触发[睡觉]状态
    setTimeout(() = >{
        this.person.setState(this.person.workState);
    },
    2 * 1000);
}

var person = new Person('老王')


  • 应用场景:
    • 对象和外部互动时,会改变内部状态,并且不同的状态会发生不同的行为
    • 在运行过程中这个对象的状态会经常切换
  • 优点
    • 一个状态状态对应行为,封装在一个类里,更直观清晰,增改方便
    • 状态与状态间,行为与行为间彼此独立互不干扰
    • 避免事物对象本身不断膨胀,条件判断语句过多
  • 缺点:
    • 需要将事物的不同状态以及对应的行为拆分出来,有时候会无法避免动作拆不明白了,过度设计
    • 与策略模式的不同:状态模式经常会在处理请求的过程中更改上下文的状态,而策略模式只是按照不同的算法处理算法逻辑




中介者模式

程序中存在对象,所有这些对象都按照某种关系和规则来通信。当程序的规模增大,对象会越来越多,它们之间的关系也越来越复杂,难免会形成网状的交叉引用。当改变或删除其中一个对象的时候,很可能需要通知所有引用到它的对象。这样的硬编码方式会导致代码和对象的逻辑关系难以维护。
中介者对象可以让各个对象之间不需要显示的相互引用,从而使其耦合松散,而且可以独立的改变它们之间的交互。所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可
采购部就是一个典型的中介者模式。业务部门A找采购部提采购需求,由采购部去发布公告召集B、C、D公司来投标。业务部门不用参与后续整个招标过程,同时因为中介的存在,业务部门不一定要从B买东西,可以从C、D,使得采购非常的方便。

聊天软件也可以看成是一个中介模式。群聊/单聊都是将信息发送到聊天室这个中介者,然后由聊天室广播给所有用户/单独发送给特定用户。完成不同对象之间的通信。

//参与者
var Participant = function(name) {
    this.name = name;
    this.chatroom = null;
};
//定义参与者方法
Participant.prototype = {
    send: function(message, to) {
        this.chatroom.send(message, this, to);
    },
    receive: function(message, from) {
        log.add(from.name + " to " + this.name + ": " + message);
    }
};

//聊天室(中介者)
var Chatroom = function() {
    var participants = {};
    return {
        register: function(participant) {
            participants[participant.name] = participant;
            participant.chatroom = this;
        },
        send: function(message, from, to) {
            if (to) {
                to.receive(message, from);
            } else {
                for (key in participants) {
                    if (participants[key] !== from) {
                        participants[key].receive(message, from);
                    }
                }
            }
        }
    }
}
//查看聊天记录
var log = (function() {
    var log = ""
    return {
        add: function(msg) {
            log += msg + "\n"
        },
        show: function() {
            alert(log);
            log = ""
        }
    }
})()
//使用
function run() {
    var yoko = new Participant("Yoko") 
    var john = new Participant("John") 
    var paul = new Participant("Paul") 
    var ringo = new Participant("Ringo") 
    var chatroom = new Chatroom() 
    chatroom.register(yoko) 
    chatroom.register(john) 
    chatroom.register(paul) 
    chatroom.register(ringo) 
    yoko.send("All you need is love.") 
    yoko.send("I love you John.") 
    john.send("Hey, no need to broadcast", yoko) 
    paul.send("Ha, I heard that!") 
    ringo.send("Paul, what do you think?", paul) 
    log.show()
}


  • 应用场景:
    • 对象之间存在网状的多对多的影响关系
  • 优点:
    • 节约了对象或者组件之间的通信信道,这些对象或者组件存在于从多对多到多对一的系统之中。
    • 由于解耦合水平的因素,添加新的发布或者订阅者是相对容易
  • 缺点:
    • 可能会引入单点故障
  • 与观察者模式的差异:
    • 主要是因为发布订阅模式也增加了第三方对象,和中介者模式处理问题的手段相似,使得两者的界限变得模糊。但是还是存在不同
    • 中介者模式角度: 类似于使用消息总线抽象不同成员交互。强调通过一个第三方对象来封装一系列对象的交互,而不关心其他对象如何封装。

    • 观察者模式角度: 类似于监听特定成员发出来的消息。强调的是观察者类监听某一个对象的事件,通过回调(或者其他方式)自动触发监听处理,关心有哪些对象参与



什么时候使用设计模式

学了这么多设计模式,不知道你学废了吗? 或者是和我一样有个疑问:到底什么时候需要使用设计模式?

我先抛砖引玉谈一谈个人的观点,我认为有两种场景比较适合:
  1. 在代码重构时,对代码的逻辑和结构有一个清晰的认识,针对代码耦合等问题造成的痛点进行设计;
  1. 在写新框架前,能够提前梳理出大概会碰到的业务场景,针对这些场景来设计代码交互和扩展方式。

网上有一种观点是:设计模式是从已经写好的代码中提炼出来的,不是在还没写代码的时候设计出来的。 在没有需求没有代码的时候讨论设计模式的好处是完全没有意义的。

我觉得后半句讲的挺好的,我们需要针对具体场景去考虑是否需要使用设计模式,不要为了设计而设计。因为针对特定问题的使用了不良解决方案,往往会导致糟糕的情况发生(完美设计+错误上下文 = 反模式)


最后谢谢大家看到这里,有什么问题欢迎留言

参考资料