一直都在用,原来这就是设计模式

本文的目的是通过工作中一些熟悉的场景带领大家慢慢理解常用的设计模式思想,让大家不再觉得设计模式苦涩难懂,甚至难以下手。通过阅读本文,相信你对设计模式一定会有种豁然开朗的感觉。

为什么要学习设计模式

很多人都说“我工作3年了,没学过设计模式一点也不影响我敲代码”,又或者“我看了几遍设计模式理论,在工作中根本就用不到”。对于设计模式,从来都说仁者见仁智者见智。这里关于为什么要学习设计模式,我总结了以下几个原因:

  • 改善你的代码。设计模式遵循单一职责、可复用性、最少知识等原则,在你的代码设计之初能帮你增加代码的可读性以及可复用性,在代码重构的时候同样也起着非常重要的作用。可以帮助我们摆脱繁琐的if...else...写法,可以阅读我之前写过的文章 项目中的代码优化总结——if-else的替代写法
  • 解决既有问题。设计模式是在软件设计开发过程中,前人们针对一些特定问题或者场景,提出的解决方案。当你在开发过程中,遇到相似场景,便可以选择相应设计模式作为问题的解决方案之一。
  • 提升自身软实力。精通设计模式,在面试中能让你从众多面试者中脱颖而出,在工作中能让你以不同于众人的思维思考问题...

设计模式

GoF提出了23种设计模式,但JavaScript有着区别于其他强类型语言的特性,传统的设计模式并不完全适用,这也是很多人拒绝学习设计模式或者觉得设计模式苦涩难懂的原因,这里结合我们的工作,只介绍JavaScript中常见的几种设计模式。

虽然这些设计模式各有特点,但请记住它们的核心都是一样的——封装变化。找出代码中变化的部分,并将变化封装起来,让我们的代码更灵活,可复用性更高。

1.单例模式

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

简而言之,单例模式一个最大的特征是唯一性,考虑全局唯一时使用单例模式,比如浏览器中的window对象、全局缓存等。

实现单例模式

我们先来解读定义里的“一个类仅有一个实例”这句话,我们都知道当我们创建一个类后,可以通过new关键字生成一个新对象(也就是一个实例),从另外一个角度看,说明单例模式中的类无论通过new关键字被实例化几次,都应该返回第一次创建的那个实例对象。

有了这个思路,下面用代码来实现一个单例模式:

class Singleton{
    getInstance(){
        // 判断是否创建过实例了
        if(!Singleton.instance){
            // 如果没有创建过,则生成一个新的实例
            Singleton.instance = new Singleton()
        }
        // 如果创建过,则返回该实例
        return Singleton.instance
    }
}

const s1 = Singleton.getInstance()
const s2 = Singleton.getInstance()
console.log(s1===s2) // true
复制代码

上面的例子中,我们用Singleton.instance来保存Singleton创建的实例,如果没有创建过实例则生成一个新的实例,我们可以看到无论执行多少次Singleton.getInstance(),返回的结果都是第一次生成的实例。

JavaScript中我们很少使用class这种写法,是不是就用不到单例模式?实现单例模式的方式不只这一种,我们还可以用闭包来实现:

Singleton.getInstance = (function() {
  // 定义自由变量instance,模拟私有变量
  let instance = null
  return function() {
      // 判断自由变量是否为null
      if(!instance) {
          // 如果为null则new出唯一实例
          instance = new Singleton()
      }
      return instance
  }
})()
复制代码

单例模式的核心是确保只有一个实例,并提供全局访问,下面我们来看两个工作中常见的单例模式的实例,来帮助我们更好的理解单例模式。

vuex中的单例模式

vuexvue的状态管理器,vuex中的store就是单例模式的一个应用。一个应用中只包含一个store实例,我们去看vuex的源码,其内部实现了一个install方法,这个方法在插件安装时被调用。在install方法里,有一段逻辑和我们上面写的getInstance方法非常相似:

if (!Vue && typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

function install (_Vue) {
  // 判断传入的Vue实例对象是否已经被install过Vuex插件
  if (Vue && _Vue === Vue) {
    console.error('[vuex] already installed. Vue.use(Vuex) should be called only once.')
    return
  }
  // 若没有,则为这个Vue实例对象install一个唯一的Vuex
  Vue = _Vue
}
复制代码

通过这种方式,可以保证一个应用只会 install 一次 Vuex 插件,所以每个 Vue 实例只会拥有一个全局的 Store

dialog/modal弹窗

工作中我们经常会遇到写modal弹窗(dialog弹窗也类似)的场景,比如我们的登录弹窗。登录弹窗的特点是:

  • 在页面中是唯一的,不可能同时出现两个登录弹窗。
  • 用户第一次点击按钮的时候创建弹窗,再次触发的时候不会创建新的弹窗,而是显示之前创建的弹窗。

据此,我们可以实现一个登录弹窗demo:

<html>
<body>
  <button id="loginBtn">登录</button>
</body>
<script>
  const createLoginLayer = (function () {
    var div;
    return function () {
      if (!div) {
        div = document.createElement('div');
        div.innerHTML = '我是登录弹窗';
        div.style.display = 'none';
        document.body.appendChild(div);
      }
      return div;
    }
  })();
  document.getElementById('loginBtn').onclick = function () {
    const loginLayer = createLoginLayer();
    loginLayer.style.display = 'block';
  };
</script>
</html>
复制代码

2.迭代器模式

定义:指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。

定义可能不太好理解,但我们可以从名称入手。迭代器在很多语言里面都内置了,比如JS中数组迭代器——Array.prototype.forEach,我们一般这样使用:

[1, 2, 3].forEach((item, index) => {
    console.log(`当前元素${item}的下标为${index}`)
})
复制代码

我们在使用Array.prototype.forEach的时候,不用关心forEach内部的实现,只关注业务层的实现,这恰好也是迭代器模式的特点。迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。

实现一个迭代器

我们可以自己实现一个迭代器each

const each = function (ary, callback) {
  for (let i = 0, l = ary.length; i < l; i++) {
    callback.call(ary[i], i, ary[i]); // 把下标和元素当作参数传给 callback 函数
  }
};
each([1, 2, 3], function (i, n) {
  console.log([i, n]);
});

复制代码

当然迭代器的用法不止于此,感兴趣的推荐阅读文末参考的书籍和文章。

3.装饰器模式

定义:为对象动态添加新行为。

圣诞节快到了,很多小伙伴都会装饰圣诞树,我们会往树上挂上很多有节日气氛的装饰品,但我们并不会破坏这棵树原有的结构,这便是我们生活中的装饰器模式。

装饰函数

JavaScript中装饰器模式的一个很好地表现便是装饰函数。比如我们在维护一个项目的时候,突然来了新的需求,需要我们往原来的函数中添加新的功能。原来的函数是以前的同事写的又经过好几个人的手,里面的实现非常杂乱,最好的方法是尽量不去改动原函数,通过保存原引用的方式去改写这个函数。

比如我们想给 window 绑定 onload 事件,但是如果这个事件之前有人绑定过,我们直接写就会覆盖掉之前事件中的行为。为了避免覆盖掉之前的 window.onload 函数中的行为,我们一般都会先保存好原先的 window.onload,把它放入新的 window.onload 里执行:

window.onload = function () {
  console('原先执行的行为');
};

const _onload = window.onload || function () {};

window.onload = function () {
  _onload();
  console.log('添加新行为');
};
复制代码

新的 window.onload 函数就是我们的装饰函数。

给函数动态添加新行为

上面这种方式存在以下两个问题:

  • 必须维护_onload这个中间变量,如果函数装饰链较长,就需要更多的中间变量。
  • this指向的问题,函数作为对象的方法被调用时,this指向该对象(上面的例子不存在该问题,但我们应该考虑到原来的函数实现有用到this的情况)。

为了解决这两个问题,我们用高阶函数来装饰函数。

我们在原来的函数上添加新行为,添加的新行为要么先于函数执行,要么后于函数执行。我们实现两个方法——Function.prototype.before给函数添加先于函数执行的新行为,Function.prototype.after给函数添加后于函数执行的新行为:

Function.prototype.before = function (beforefn) {
  var __self = this; // 保存原函数的引用
  return function () {
    // 返回包含了原函数和新函数的"代理"函数
    beforefn.apply(this, arguments); // 执行新函数,且保证 this 不被劫持,新函数接受的参数
    // 也会被原封不动地传入原函数,新函数在原函数之前执行
    return __self.apply(this, arguments); // 执行原函数并返回原函数的执行结果,
    // 并且保证 this 不被劫持
  };
};
Function.prototype.after = function (afterfn) {
  var __self = this;
  return function () {
    var ret = __self.apply(this, arguments);
    afterfn.apply(this, arguments);
    return ret;
  };
};
复制代码

再回到上面window.onload的例子,我们可以这样写:

window.onload = function () {
  console('原先执行的行为');
};

window.onload = (window.onload || function () {}).after(function () {
  console.log('添加新行为');
}).after(function () {
  console.log('继续添加其他新行为');
});
复制代码

事件埋点

假如我们给搜索按钮添加埋点事件,需要做两件事,一是实现搜索功能,另一个是上报数据。

// 普通实现
const btnCLick = () => {
  console.log("搜索功能");
  console.log("上报数据");
};

// 装饰器模式实现
const search = () => {
  console.log("搜索功能");
};

const sendData = () => {
  console.log("上报数据");
};

const btnCLick = search.after(sendData);
复制代码

在第二种实现方式(装饰器模式实现)中,我们对按钮职责进行了更细的划分,保证了函数单一职责原则。

axios请求加上token参数

下面是一个我们封装的axios请求

var request = function(url, type, data){
  console.log(data);
  // 具体代码略
};
复制代码

现在需要给每个请求都加上token参数

// 普通实现
var request = function(url, type, data = {}){
  data.token = getToken();
  console.log(data);
};

// 装饰器模式实现
var request = function(url, type, data = {}){
  console.log(data);
};

request = request.before((url, type, data = {})=>{
  data.token = getToken();
})

复制代码

在装饰器模式实现方式中我们没有改变原函数,原函数是一个比较纯净的单一职责函数,提高了request函数的可复用性。

4.代理模式

定义:当直接访问本体不方便或者不符合需要时,为这个本体提供一个代替者。

代理模式其实非常常见,比如图片预加载、事件冒泡、缓存计算结果等,是不是很惊讶这些我们一直都在用的技术,居然就用到了代理模式的设计思想,所以设计模式并没有离我们很遥远。

下面详细介绍两个例子。

事件代理

事件代理是我们经常会遇到的一个情景,比如我们希望点击每个li标签的时候打印点击的li标签的内容,如果我们不用事件代理就会这样写:

<html>
<body>
  <ul id="parent">
    <li>列表项1</li>
    <li>列表项2</li>
    <li>列表项3</li>
    <li>列表项4</li>
    <li>列表项5</li>
  </ul>

  <script>
    const aNodes = document.getElementsByTagName('li')
    const aLength = aNodes.length
    for (let i = 0; i < aLength; i++) {
      aNodes[i].addEventListener('click', function (e) {
        e.preventDefault()
        alert(`我是${aNodes[i].innerText}`)
      })
    }
  </script>
</body>
</html>
复制代码

这样我们给5个li元素都添加了监听事件,而实际项目中列表元素是非常多的,给每个列表项添加监听事件的性能开销是非常大的。

进一步考虑到事件本身有冒泡的特性,可以我们可以用代理模式去实现对子元素的事件监听,我们只需要给父元素添加监听事件即可:

const element = document.getElementById('parent');
element.addEventListener('click', (e) => {
  const target = e.target;
  console.log(target.innerHTML);
})
复制代码

缓存代理

缓存接口数据:把调过的接口的返回结果存储起来,不用每次都去调接口拿数据,减少服务器的压力,适用于数据基本不怎么更新的场景。

const getData = (function() {
    const cache = {};
    return function(url) {
        if (cache[url]) {
            return Promise.resolve(cache[url]);
        }
        return $.ajax.get(url).then((res) => {
            cache[url] = res;
            return res;
        }).catch(err => console.error(err))
    }
})();

getData('/getData'); // 发起http请求
getData('/getData'); // 返回缓存数据
复制代码

5.适配器模式

定义:主要用于解决接口间不兼容的问题。

我们的生活中有很多适配器的例子,比如我的iPhone 13的耳机孔是方头的,想要用上圆头耳机,必须要加一个转接器才能用,这个转接器一端是圆孔用来插圆头耳机的,另一端是方头插在我手机上的,这样我就愉快的用上了耳机。这里的转接器就起到了适配器的作用。

同理,我们在设计程序的时候也会用到适配器模式的思想。

兼容接口数据格式

适配器模式在我们日常工作中用的特别多,比如我们用Echarts画图的时候,我们都知道,Echarts中的data数据都有一定数据格式的,比如我们画饼图,需要的数据格式是这样的:

data = [
  {
    name: "2020",
    value: 14800,
  },
  {
    name: "2021",
    value: 23400,
  },
];
复制代码

而我们从后端接口拿到的数据格式可能是这样的:

data = {
  2020: 14800,
  2021: 23400,
};
复制代码

让我们去改Echarts源码去适配我们的数据格式很明显是十分不现实的,这个时候我们需要一个适配器把我们从后端拿到的数据格式转换成饼图能接收的数据格式:

function adpter(data) {
  const result = [];
  for (let key in data) {
    result.push({
      name: key,
      value: data[key],
    });
  }
  return result;
}
复制代码

跨浏览器兼容

我们都知道事件监听在浏览器有兼容问题,为此 jQuery 封装了$('selector').on 的事件处理适配器,用来解决跨浏览器兼容性问题。

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
    }
}
复制代码

和装饰者模式、代理模式的区别

这三种模式都属于包装模式,不会改变已有的接口,主要的区别是它们的意图。

模式 意图 包装次数
装饰者模式 给对象增加功能 可以包装多次,形成一条长长的装饰链
代理模式是 为了控制对对象的访问 通常只包装一次
适配器模式 主要解决两个已有接口间不匹配的问题 通常只包装一次

6.策略模式

定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

比如我们要对一组数据进行从小到大排序,有很多种算法供我们选择,你可以用冒泡排序,也可以选择快速排序或者堆排序。你可以根据自己喜欢选择排序算法,也可以观察数据特征选择最适合的排序算法,这就是我们要说的策略模式。

这里的算法是广义的“算法”,可以是封装的一系列“业务规则”。

页面复用

我们项目中一定做过新增和编辑页面,大多数情况它们的页面都长得一样。现在我们项目中有查看详情、单个新增、批量新增、和编辑详情四个页面,它们的内容都一样,主要区别如下:

  • 查看详情:所有表单项只能查看,不能编辑,点击确定按钮关闭页面
  • 单个新增:只能上传一个文件,点击确定按钮调用单个新增的接口,新增成功后弹出“新增成功”的信息,关闭页面,并刷新列表
  • 批量新增:可以上传多个文件,点击确定按钮调用批量新增的接口,新增成功后弹出“批量新增成功”的信息,关闭页面,并刷新列表
  • 编辑详情:回填详情信息,修改信息后点击确定按钮调用修改接口,修改成功后弹出“修改成功”的信息,关闭页面,并刷新列表

既然这四个页面内容都一样,我想作为一个“效率开发者”,你一定不是傻到去写四个一样的页面,那么就意味着我们要在一个公共页面实现确定按钮的点击事件,这个时候要根据情况来判断该做什么操作。

我们约定一个变量来区分当前这个公共页面是“扮演”的是哪个页面:

const source = '' 
// view 查看详情;add 单个新增;batchAdd 批量新增;edit 修改详情
复制代码

没学过策略模式,你可能最先想到的是用if...else...来实现:

document.getElementById("confirmBtn").addEventListener("click", () => {
  if (source === "view") {
    console.log("关闭页面");
  } else if (source === "add") {
    console.log("调用单个新增接口");
    console.log("其他操作");
  } else if (source === "batchAdd") {
    console.log("调用批量新增接口");
    console.log("其他操作");
  } else if (source === "edit") {
    console.log("调用修改接口");
    console.log("其他操作");
  }
});
复制代码

一堆if...else...阅读起来十分不友好,可复用性低。

JavaScript中,可以利用对象的特性实现策略模式,上面的例子用策略模式我们可以这么写:

document.getElementById("confirmBtn").addEventListener("click", () => {
  const strategies = {
    view: () => {
      console.log("关闭页面");
    },
    add: () => {
      console.log("调用单个新增接口");
      console.log("其他操作");
    },
    addBatch: () => {
      console.log("调用批量新增接口");
      console.log("其他操作");
    },
    edit: () => {
      console.log("调用修改接口");
      console.log("其他操作");
    },
  };

  strategies[source]();
});
复制代码

7.观察者模式

定义:定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。

Vue数据双向绑定原理

熟悉Vue的人都知道Vue v2.x的数据双向绑定原理中就用到了观察者模式,当我们修改数据层时,视图层也会更新,具体可以看Vue官网深入响应式原理的介绍。双向绑定原理的详细介绍以及实现,网上已经有很多文章了,推荐大家阅读vue的双向绑定原理及实现 

在Vue双向绑定原理里面,有三个比较关键的对象,它们各自的任务如下:

  • 监听器Observer:劫持并监听所有属性,如果有变动的,就通知订阅者。
  • 订阅者Watcher:收到属性的变化通知并执行相应的函数,从而更新视图。
  • 编译器Compiler:扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。

三者之间的配合过程如下;

image.png

Event Bus全局事件总线

Vue中,我们经常用Event Bus来实现组件通信,严格来说这不是观察者模式,而是发布——订阅者模式(后面会讲到它们间的区别),这两种模式思想上差不多,很多书上都不做区分的,所以这个例子在这里讲,不再单独讲发布——订阅者模式。

Event Bus的用法如下:

// 创建一个 Event Bus(本质上也是 Vue 实例)并导出:
const EventBus = new Vue()
export default EventBus

// 在主文件里引入EventBus,并挂载到全局:
import bus from 'EventBus的文件路径'
Vue.prototype.bus = bus

// 订阅事件:
this.bus.$on('someEvent', func) // 这里func指someEvent这个事件的监听函数

// 发布(触发)事件:
this.bus.$emit('someEvent', params) // 这里params指someEvent这个事件被触发时回调函数接收的入参
复制代码

可以看到整个过程并没有出现明显的发布者和订阅者,只有一个bus在统筹协调。这就是全局事件总线的特点——所有事件的发布/订阅操作,必须经由事件中心,禁止一切“私下交易”!

下面我们来实现一个Event Bus

class EventEmitter {
  constructor() {
    // handlers是一个map,用于存储事件与回调之间的对应关系
    this.handlers = {}
  }

  // on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
  on(eventName, cb) {
    // 先检查一下目标事件名有没有对应的监听函数队列
    if (!this.handlers[eventName]) {
      // 如果没有,那么首先初始化一个监听函数队列
      this.handlers[eventName] = []
    }

    // 把回调函数推入目标事件的监听函数队列里去
    this.handlers[eventName].push(cb)
  }

  // emit方法用于触发目标事件,它接受事件名和监听函数入参作为参数
  emit(eventName, ...args) {
    // 检查目标事件是否有监听函数队列
    if (this.handlers[eventName]) {
      // 这里做了一次浅拷贝,是为了避免通过 once 安装的监听器在移除的过程中出现顺序问题
      const handlers = this.handlers[eventName].slice()
      // 如果有,则逐个调用队列里的回调函数
      handlers.forEach((callback) => {
        callback(...args)
      })
    }
  }

  // 移除某个事件回调队列里的指定回调函数
  off(eventName, cb) {
    const callbacks = this.handlers[eventName]
    const index = callbacks.indexOf(cb)
    if (index !== -1) {
      callbacks.splice(index, 1)
    }
  }

  // 为事件注册单次监听器
  once(eventName, cb) {
    // 对回调函数进行包装,使其执行完毕自动被移除
    const wrapper = (...args) => {
      cb(...args)
      this.off(eventName, wrapper)
    }
    this.on(eventName, wrapper)
  }
}
复制代码

观察者模式与发布——订阅者模式的区别

这个知识点我曾踩过坑。因为大多数书上给的说法是发布—订阅模式就是观察者模式,包括《JavaScript设计模式与开发实践》这本书上也是这样说的。后面我再去了解了一下,其实两者还有有区别的。

  • 观察者模式:能够直接接触到被观察的对象。减少了模块间的耦合问题,两个分离的、毫不相关的模块也能进行通信,但并未完全解耦,被观察者必须去维护一套观察者的集合,观察者必须实现统一的方法供被观察者调用,两者相互联系。

  • 发布订阅者模式:有一个第三方平台用来发布/订阅。实现了完全解耦,注册和触发都独立于双方的第三平台。

发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信的操作,叫做发布-订阅模式。 观察者模式和发布-订阅模式之间的区别,在于是否存在第三方、发布者能否直接感知订阅者(如图所示)。

image.png

结语

本文对设计模式的探讨没有很深入,主要目的是带大家发现一些用到设计模式的场景,从而对设计模式不再陌生。然后再根据这些例子去理解设计模式的定义,相信会有不一样的感受。看完这些,答应我下次别人问你设计模式,别再说不知道了好吗?

参考

  • 《JavaScript设计模式与开发实践》,曾探:通俗易懂,反复读过好几遍,每次看都会有新的收获
  • 掘金课程 JavaScript 设计模式核⼼原理与应⽤实践:修大文笔幽默,讲解的很形象,文中一些例子便从这里来的

猜你喜欢

转载自juejin.im/post/7039626306115338247