前端常用设计模式介绍 | 微盟技术

259 阅读13分钟

1. 什么是设计模式?

既然要讲设计模式那么总得先对设计模式有所了解。

1.1 定义

设计模式是一套通用的可复用的解决方案,用来解决在软件设计过程中产生的通用问题。它不是一个可以直接转换成源代码的设计,只是一套在软件系统设计过程中程序员应该遵循的最佳实践准则。

1.2 设计模式怎么来的?

在 1994 年,由四位大佬(人称'四人帮')合著出版了一本名为 Design Patterns - Elements of Reusable Object-Oriented Software(中文译名:设计模式 - 可复用的面向对象软件元素) 的书,该书首次提到了软件开发中设计模式的概念。他们所提出的设计模式主要是以下基于面向对象的设计原则:

  1. 对接口编程而不是对实现编程。
  2. 优先使用对象组合而不是继承。

1.3 设计模式与前端

有些眼尖的小伙伴看到面向对象时就会问了: 既然设计模式是基于面向对象的, 那么对于不能算是面向对象的js来说是不是就没用了呢?

答案很显然不是的。设计模式本质上就是一种编程思想, 它只是最早被人以面向对象的语言来实现罢了。

也就是说, 不管是面向对象还是面向过程,都可以用到设计模式来优化我们的代码。

1.4 设计模式原则

设计模式的原则其实有好多种说法, 这里我们就以 SOLID 原则为例简单了解一下。

图片

2. 前端开发常用的几种设计模式

2.1 发布-订阅模式

定义:发布/订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,然后分别发送给不同的订阅者。

发布订阅模式可能是前端开发者听得最多的设计模式之一了, 很多状态管理工具或者组件间通信都有用到,所以就不过多赘述了。

跟发布订阅模式经常一起被提起的就是观察者模式了, 本质上发布订阅是观察者模式的扩展。二者最大的区别就是发布订阅模式在观察者与被观察者之间加了个中介, 这样就将二者解耦了,被观察者不需要再处理与观察者相关的逻辑。

举个栗子:

小红和小明都想喝牛奶。

观察者模式就是二者都打电话给牛奶站, 由牛奶站给他们配送。

而发布订阅模式则是给代理商打电话,然后牛奶站只要给代理商送牛奶, 由代理商来进行配送。这样如果又要喝果汁了,也只要跟代理商交流, 而不需要再去给果汁店打电话。

发布订阅模式↑

图片

观察者模式↑

常见订阅发布模式使用场景

事件监听, eventBus。

什么时候适合使用发布订阅模式?

当你负责的模块,基本满足以下情况时

  • 各模块相互独立
  • 存在一对多的依赖关系
  • 依赖模块不稳定、依赖关系不稳定
  • 各模块由不同的人员、团队开发

需要注意点什么?

当订阅的事件变多时,会增加维护成本。

2.2 策略模式

定义: 该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换。

从定义我们可以看出, 策略模式是由一系列相似的算法组成的, 我们可以根据具体情况自由地选择调用哪个算法, 简单地可以理解为if-else的升级版。

举个栗子:

现在有个列表页的需求, 列表有一列操作列, 操作列的操作项需要根据后端返回的数据动态展示。那么, 按照我们常规的思路可能会这么写。

    // 假如操作项返回的数据是这样的:
    // operators:['edit','add','delete']

   function renderOperators(operators) {
       return operators.map(item => {
           if (item === 'edit') {
               // return ...
            } else if (item === 'add') {
                // return ...
            } else if (item === 'delete') {
                // return ...
            } else {
                return null
            }
        })
        // 再进一步可能用switch
    }

很显然, 这样写肯定能实现需求, 但是如果后面需求变了, 又要增加一个查看按钮, 那么我们肯定就要在这里再加一个else if 上去, 每次加按钮都得加一次, 如果还要夹杂其他判断条件, 那么这个函数一定会变得非常的臃肿且难以维护。

这个时候就可以用到策略模式了:

    const strategies={
        edit:function (key){
            // do something
            // return ...
        },
        add:function (key){
            // do something
            // return ...
        },
        delete:function (key){
            // do something
            // return ...
        },
    }

    function renderOperators(operators) {
       return operators.map(item => {
          return strategies[item]()
        })
        // 再进一步可能用switch
    }

这样一来, 后面即使需求变了,我们也不需要改动renderOperators 这个函数, 只要在 strategies 上加个配置就可以了, 符合开闭原则。

常见策略模式使用场景

登录页需要多种方式登录, 表单校验, 表单渲染, 列表渲染

什么时候用策略模式?

当你负责的模块,基本满足以下情况时

  • 各判断条件下的策略相互独立且可复用
  • 策略内部逻辑相对复杂
  • 策略需要灵活组合

需要注意什么?

调用者必须理解所有策略算法的区别,以便适时选择恰当的算法类/函数。

2.3 适配器模式

定义: 将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。

举个栗子:

我们现在有一个需求, 要在商城加一个优惠券组件, 但是我们经过调研发现原来在 CRM 已经有一个了, 并且样式跟我们要的一样, 唯一的问题是后端返回的数据与现有组件的不一样。这个时候我们肯定希望能复用这个优惠券组件, 那么就可以用到适配器模式了, 只要将商城后端给的数据格式转换成优惠券组件需要的格式就行了。

有机智的的小伙伴可能就会问了, 这不就是把请求的接口数据转换一下吗, 这也算是设计模式吗?

没错, 是的, 设计模式就是这么简单~

当然, 适配器虽好, 可不要贪多哦。

过多地使用适配器,会让系统非常凌乱,不易整体进行把握。比如,明明看到调用的是 A 接口,其实内部被适配成了 B 接口的实现,一个系统如果太多出现这种情况,无异于一场灾难。因此如果不是很有必要,可以不使用适配器,而是直接对系统进行重构。

常见适配器模式使用场景

接口数据转换

2.4 代理模式

定义: 代理模式是为其它对象提供一种代理以控制这个对象的访问,具体执行的功能还是这个对象本身。

比如说,我们发邮件,通过代理模式,那么代理者可以控制,决定发还是不发,但具体发的执行功能,是外部对象所决定,而不是代理者决定。

    // 发邮件,不是qq邮箱的拦截
    const emailList = ['qq.com''163.com''weimob.com'];

    // 代理
    const ProxyEmail = function(email) {
        if (emailList.includes(email)) {
            // 屏蔽处理
        } else {
            // 转发,进行发邮件
            SendEmail.call(this, email);
        }
    };

    const SendEmail = function(email) {
    // 发送邮件
    };

    // 外部调用代理
    ProxyEmail('cctv.com');
    ProxyEmail('ojbk.com');

再比如说, 我们在开发过程中经常需要使用 console.log 打印相关数据, 但是通常不希望在线上环境直接打印这些内容, 可是在排查错误时又可能需要用到这些打印。

那么为了实现这种功能, 就可以用到代理模式, 将 console.log 做一层代理, 然后用代理的方法来执行打印的功能:


    function myLog(...args){
        if(!location.search.includes('xxx')){
            // do somethings
            return 
        }
        // do somethings
        console.log(...args)
    }

这样只有当地址栏有我们规定的参数时才进行打印内容, 没有时不打印。

常见代理模式使用场景

图片预加载、数据缓存、拦截器等

什么时候用代理模式?

  • 模块职责单一且可复用
  • 需要对某个方法进行扩展
  • 两个模块间的交互需要有一定限制关系

需要注意什么?

  • 由于在客户端和真实主体之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢。
  • 和适配器模式的区别:适配器模式主要改变所考虑对象的接口,而代理模式不能改变所代理类的接口。
  • 和装饰器模式的区别:装饰器模式为了增强功能,而代理模式是为了加以控制。

2.5 责任链模式

定义: 职责链模式是使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。

举个栗子:

如图所示,我们申请设备之后,接下来要选择收货地址,然后选择审核人,而且必须是上一个成功,才能执行下一个

图片

责任链模式流程图1.png

有的小伙伴一看, 那还不简单, 咔咔就写完了:

function applyDevice(){
    // do somethings
    let devices = {};
    let nextData = Object.assign({}, data, devices);
    selectAddress()
}

function selectAddress(){
    // do somethings
    let address = {};
    let nextData = Object.assign({}, data, address);
    selectVerifier()
}

function selectVerifier(){
    // do somethings

}

嗯, 这么写确实没问题, 然而过了几天, 又来了个新需求, 申请设备后要检查库存, emmmm, 也简单, cv大法搞起直接copy一下改改逻辑呗。

图片

image.png

这个时候就可以用到责任链模式了。

const Chain = function(fn) {
    this.fn = fn;
    this.next = null
  
    this.setNext = function(chain) {
        this.next = chain
        return this.next
    }

    this.run = function (...args) {
        const nextData = this.fn(...args)
        this.next?.run?.(nextData)
    }
}

const applyDevice = function(data) {}
const chainApplyDevice = new Chain(applyDevice);

const selectAddress = function(data) {}
const chainSelectAddress = new Chain(selectAddress);

const selectChecker = function(data) {}
const chainSelectChecker = new Chain(selectChecker);
// 使用责任链模式实现上边功能
chainApplyDevice.setNext(chainSelectAddress).setNext(chainSelectChecker);
chainApplyDevice.run();

很明显, 这样写, 将各模块之间完全解耦了, 每个模块只需要关注自身的逻辑, 而不需要考虑其他的。这样, 如果有其他需求也要用到某个模块的功能, 就可以直接拿来用, 而不需要修改任何代码

常见责任链模式使用场景

  • JS 中的事件冒泡。
  • nodejs koa中的洋葱模型
  • webpack插件

什么时候用责任链模式?

当你负责的模块,基本满足以下情况时

  • 各环节可复用
  • 各环节有一定的执行顺序
  • 各环节可重组

需要注意什么?

  • 进行代码调试时不太方便,可能会造成循环调用。
  • 可能不容易观察运行时的特征,有碍于排查错误。
  • 一个请求也可能因职责链没有被正确配置而得不到处理。

2.6 单例模式

定义: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例模式两种写法:

// class形式:

class Singleton {
  constructor (name) {
    this.name = name
  }
  // 静态方法
  static getInstance (name) {
    if (!this.instance) {
      this.instance = new Singleton(name)
    }
    return this.instance
  }
}
let a = Singleton.getInstance('a1')
let b = Singleton.getInstance('b2')
console.log(a == b)
// 函数形式:
const Singleton = function (name) {
  this.name = name
}
// 利用自执行函数产生闭包
Singleton.getInstance = (function () {
  var instance
  return function (name) {
    if (!instance) {
      return (instance = new Singleton(name))
    }
    return instance
  }
})()
let a = Singleton.getInstance('a1')
let b = Singleton.getInstance('b2')
console.log(a===b) // true

javascript与单例

前面的几种实现方式,他们更多接近的传统面向对象语言的实现,对于JavaScript这种无类语言来说有点穿棉衣洗澡,因为传统面向对象语言单例对象从"类"中创建而来,而我们天生拥有极简的对象创建方式,大可不必模仿强类型语言去实现单例,对没错!我们只需要直接创建对象就是单例模式,只要做好以下两点

  • 保证创建的对象是唯一
  • 并且提供方法给全局使用

js中最简单的单例:

const single = {}

有的小伙伴可能就吃惊了😲, 原来在js里声明一个对象就算是用到单例模式了吗, 没错, 就是这么骚。所以对于前端小伙伴来说, 单例模式可以算是我们用得最多的一个设计模式了。

惰性单例--敲黑板

惰性单例才是单例模式的重点! 它所指的是, 在需要的时候才创建实例对象; 这模式在真实开发极其有用!

举个栗子:

我们正在开发一个网站,网站类型是一个视频网站,网站有个登录按钮,点击登录会弹出一个登录框进行登录,你现在可能已经联想到,这个登录框一定是页面唯一的一个dom节点,一个页面存在两个登录框是不存在的!

图片

如果要实现这种效果第一种解决方案就是在页面加载的时候就已经创建好dom节点,并且设置样式为 display 为 none,当点击登录时修改为 block 显示;

但是我如果作为一个用户, 可能在一天的使用中都不会去打开这个登录框。那么这样很显然是浪费了一些性能的, 并且如果有很多类似的其他功能的弹窗也这么做, 那么这里的性能浪费就很明显了。

具体代码可参考上面的示例

常见单例模式使用场景

  • 公共对象封装
  • 公共弹窗
  • VueX Redux 等状态管理工具

什么时候用单例模式?

当你负责的模块,基本满足以下情况时

  • 想控制实例数目,节省系统资源的时候。

需要注意什么?

  • 需要注意命名空间污染
  • 对于不了解模块用了单例模式的开发者,可能存在调试困难的问题。

3. 总结

学习设计模式后,我们可以发现,设计模式是无处不在的。在学习设计模式之前的很多时候我们是凭借过往经验和智慧来完善系统的设计,而这些经验很多时候正好和某个设计模式的思想不谋而合。

同时设计模式也并不拘泥于某种类型的语言, 它对于任何语言的程序开发来说都是有着实用价值的。了解设计模式可以帮助我们的代码更加健壮, 更易于维护, 能更好地解决很多问题。

但是, 话又说回来, 设计模式本质上就是一种工具, 我们在开发时不需要为了使用某种设计模式而特意去那样写。学习设计模式与设计优秀的软件并不相关,盲目追求和套用书中的设计模式只能使项目变得更加糟糕。

设计模式既不能让我们学到绝世武功,也不能帮我们打通任督二脉,从此看破系统中的所有巧妙设计,认为自己学到了神功,想要在项目中大展身手套用书中的设计时很有可能会带来错误设计,成为项目中的遗留代码并被接手的工程师吐槽和重构。我们要清楚地知道设计模式的局限性以及待解决问题的上下文,才能做出好的设计。

微信公众号.png