以 JavaScript 浅谈常见的几种设计模式

107 阅读9分钟

前言

  • 如果你没有设计图纸就开始建房子,很可能会建得乱七八糟,不仅费时费力,还可能达不到预期的效果。而设计模式就是软件开发中的“图纸”和“模板”,简称“套路”。它们提供了一套经过验证的、可复用的解决方案。能够帮助我们更好地组织代码、提高代码的可读性和可维护性。

1. 单例模式

  • 单例模式的概念就是:保证一个类的实例只有一个,并且这个实例对象能够被全局访问

  • 想要使用js来实现单例模式的话,核心思想有以下几步:

    • 在创建这个类的时候,在其class的构造函数中将这个类的this保存成一个类属性

    • 并且需要在构造函数中判断一下这个类属性,看它是否有值

      • 如果没值,那就证明这个类是第一次被new,那将this返回出去即可
      • 但如果这个类属性已经有值了,那就证明这个类已经被new过了
      • 所以直接将类属性中已经保存的那个this返回出去就好了
      • 这就保证了这个类只能被实例化一次这个条件,无论new这个类多少次,拿到的都是同一个对象
    class Singleton {
         constructor() {
            if (typeof Singleton.instance === 'object') {
                return Singleton.instance;
            }
            this.name = 'Singleton';
            Singleton.instance = this;
            return this;
          }
    }
    ​
    const instance1 = new Singleton();
    const instance2 = new Singleton();
    ​
    console.log(instance1 === instance2); // true
    
  • 但是上面这种说法无疑是非常抽象的,就只是为了学设计模式而学,下面我们看一下日常开发中的具体应用:

    • 如果我们有一个页面,用户点击登录按钮时,展示登录弹框(loginBoxEl)

    • 点击弹窗的 close 按钮时,关闭它

      • 为了优化,展示和关闭弹窗时,不要销毁元素,用 diplay: none/block 来控制其显隐
    • 为了遵循一个函数只做一件事的原则,所以我们需要对函数进行拆分,不能在一个函数里面做所有事

    • 所以此时就可以用单例模式来对这段代码进行优化:

        <script>
      ​
          const loginBtnEl = document.querySelector('.login-btn')
          const closeBtnEl = document.querySelector('.close-btn')
          const loginEl = document.querySelector('.login')
      ​
          // 创造单例模式的函数/类(Singleton)
          const getSingle = (fn) => {
            let result = null
            return function() {
              if (!result) {
                result = fn()
              }
              return result
            }
          }
      ​
          // 创建弹窗的业务代码
          function createDialog() {
            // 只会打印一次
            console.log('创建弹窗')
      ​
            const loginBoxEl = document.createElement('div')
            loginBoxEl.className = 'login-box'
            loginBoxEl.innerHTML = '<h1>我是登录弹窗</h1>'
            loginBoxEl.style = 'display: none;'
            loginEl.appendChild(loginBoxEl)
      ​
            return loginBoxEl
          }
      ​
          // 使用单例模式对获取弹窗实例的函数做一次操作
          const getLoginBox = getSingle(createDialog)
      ​
          loginBtnEl.onclick = () => {
            const loginBox = getLoginBox()
            loginBox.style.display = 'block'
          }
      ​
          closeBtnEl.onclick = () => {
            const loginBox = getLoginBox()
            loginBox.style.display = 'none'
          }
      ​
        </script>
      

2. 观察者模式:

  • 观察者模式的概念就是:当一个对象的状态被改变时,它的所有依赖者都会接到通知并自动更新。一般在对象间存在一对多的关系时,就会使用观察者模式
  • 其实Vue的响应式系统就是采用的观察者模式,所以这个就不多做赘述了,可以看一下我以前的文章:# 手动实现Vue的响应式原理,并详解Vue2和Vue3响应式实现的区别

3. 发布订阅模式:

  • 发布订阅模式和观察者模式本质上是一样的,所以很多地方也就直接把两者看成一种设计模式了

  • 观察者模式是一个对象有多个依赖

  • 而发布订阅模式其实是引入了一个中间层,来进行注册和通知

4. 代理模式:

  • 代理模式的概念简单点说就是,对原有的对象再包装一层,得到一个新的代理对象,并且我们可以通过这个代理对象来控制或者限制对原对象的访问

    • 和 Charles 其实是一样的,Charles 也是将自己设置成系统的网络代理服务器

    • 举个例子:

      • 我们现在有一个对象A,对象A中有一个方法 loadImage 图片加载器,调用这个方法就会从服务器上加载图片并显示在网页上。但是,由于图片文件较大,加载时间可能会很长。为了提升用户体验,我们希望在图片加载完成之前,显示一个加载动画或占位符图片

        const bodyEl = document.body
        const axios = {
          get: '...',
          post: '...'
        }
        ​
        const A = {
          loadImage: async (config) => {
            const img = new Image()
            const src = await axios.get(config)
        ​
            img.src = src
            bodyEl.appendChild(img)
          }
        }
        
      • 那么此时就可以使用代理模式来实现。我们可以创建一个代理对象 B,它也有一个方法 newLoadImage 。当调用代理对象的方法,想要获取图片时。在这个方法中,可以先显示加载动画或占位符图片,并同时请求真实的图片加载器加载图片。当图片加载完成后,代理对象再将图片显示在网页上。

        const bodyEl = document.body
        const axios = {
          get: '...',
          post: '...'
        }
        ​
        const A = {
          loadImage: async (config) => {
            const img = new Image()
            const src = await axios.get(config)
        ​
            img.src = src
            bodyEl.appendChild(img)
          }
        }
        ​
        const B = {
          newLoadImage: async (config) => {
            console.log('loading start')
        ​
            await A.loadImage(config)
        ​
            console.log('loading end')
          }
        }
        

5. 装饰器模式

  • 装饰器模式主要指的是给一个对象动态的添加一些额外的功能,并且可以让对象/函数和这个功能是相互独立的,降低它们之间的耦合度

  • 在前端开发中能应用到它的一个场景就是:

    • 我们知道微信小程序定义一个页面是通过Page方法进行定义的,然后我们可以给其传入一个配置对象,并且在这个配置对象中做一些操作

    • 但是如果我们有个需求就是,在每个页面被加载的时候,都要调用一个接口,给这个接口传入一些自定义数据。

      • 此时最直接的方法就是在每个页面的onLoad中都调用一次这个接口
      • 但是它们都逻辑其实都是一样的,一个页面一个页面的去加,就太麻烦了
      • 所以这里就可以用到装饰器模式了
    • 我们可以先定义一个装饰器函数,这个函数接收一个option为参数,这个函数会将传入的option对象装饰之后再返回出去

    • 那么我们只需要在调用Page函数的时候,再调用一下decorator即可,将本来要传入Page函数的option对象,直接传给decorator这个函数

    • 在decorator中,我们拿到option之后,就可以通过对象的解构,将onLoad解构出来

    • 然后我们只需要在decorator返回的那个对象中,先将 rest 展开,再将onLoad这个函数进行重写即可

    • 那么在这个新的onLoad函数中,我们就想做什么操作就可以做什么了,只需要最后再调用一下原来的onLoad函数,并将this什么的显示绑定过去就好了

    • 同理,我们也可以利用装饰器模式对其他的生命周期函数进行装饰,所以我们的options对象,也其实是可以被装饰很多次的。最终将我们装饰了很多次的options再返回给Page函数就好了

    // 装饰函数
    const decorator = (option) => {
      const { onLoad, ...rest } = option;
      return {
          ...rest,
          // 重写 onLoad 方法
          onLoad(...args) { 
              // 增加路由字段
              this.reportData(); // 上报数据
    ​
              // onLoad
              if (typeof onLoad === 'function') {
                  onLoad.call(this, ...args);
              }
          }
    }
    ​
    // Page页面
    Page(decorator({
        data: {},
        onLoad: function () {
            // 页面渲染后 执行
        }
    })
    ​
    // 装饰多层
    Page(decoratorA(decoratorB(decoratorC({
        data: {},
        onLoad: function () {
            // 页面渲染后 执行
        }
    }))))
    
  • 所以也能感觉到,这个模式也是履行了单一职责原则的,可以把各个函数的功能都独立出来,降低它们之间的耦合度

  • 我们可以感觉到装饰器模式和代理模式,是十分相似的。他们的作用都是对原对象进行包装,然后增强原对象

  • 但它们其实还是有很多不同的地方的:

    • 举个例子说

      • 代理模式的话,对象之间的依赖关系已经写死了,原始对象 A,新增代理对象 A1, A1 的基础上再新增代理对象 A2。如果我们不想要 A1 新增的功能了,我们并不能直接使用 A2 ,因为 A2 已经包含了 A1 的功能,我们只能在 A 的基础上再新写一个代理对象 A3。
      • 而装饰器模式,我们只提供装饰函数 A1,装饰函数 A2,然后对原始对象进行装饰 A2(A1(A))。如果不想要 A1 新增的功能,只需要把 A1 这个装饰器去掉,调用 A2(A) 即可。

6. 策略模式

  • 策略模式指的就是对象有某个行为,但是在不同的场景中,该行为有不同的实现方式

  • 这个模式在开发中是非常非常常见的

    • 总结一下就是:当出现很多 if else 或者 switch 的时候,我们就可以考虑是否能使用策略模式了
  • 举个例子:

    • 进入某个页面之后,这个页面就会根据后端接口返回的type的值,展示不同的弹窗

      • 那么我们立马想到的实现方式应该就是,在请求到后端的type之后,直接通过大量的if else进行判断
      • 从而实现不同的type展示不同弹窗的需求
    • 但是这种代码有个缺点,如果以后再新增一种弹窗的话,那么我们就需要再增加一个else if,这就很麻烦

    • 所以这时,我们就可以使用策略模式了

      • 页面默认会展示弹窗,但是根据不同的type,会展示不同的弹窗

        • 我们可以将所有的弹窗逻辑都写在不同的方法中,然后将这些方法都写在一个对象popTypes中,以{ typeValue: fn}的方式进行存储
        • 并且暴露出去一个方法openPop,这个方法接收一个参数type。在这个方法中,直接通过popTypes[type]的方法,拿到封装着对应某个弹窗的方法,调用它,并将其的返回值返回出去
        • 那么我们在业务代码中,在通过后端接口拿到type的值之后,就可以调用这个openPop,并将type传进去就好了
        • 后续如果要增加新的弹窗类型的话,只需要在保存着所有方法的popTypes中,再加一个方法即可
      const popTypes = {
        reward: function() {
          ...
        },
        waitreward: function() {
          ...
        },
        Poster: function() {
          ...
        },
        Activity: function() {
          ...
        }
      }
      ​
      export function openPop(type){
        return popTypes[type]();
      }
      ​
      // 业务调用
      import { openPop } from './popTypes';
      async getMainData() {
        try {
          const res = await activityQuery(); // 请求后端数据
          openPop(res.styleType)
        } catch (error) {
          log.error(MODULENAME, '主接口异常', JSON.stringify(error));
        }
      }