前言
- 如果你没有设计图纸就开始建房子,很可能会建得乱七八糟,不仅费时费力,还可能达不到预期的效果。而设计模式就是软件开发中的“图纸”和“模板”,简称“套路”。它们提供了一套经过验证的、可复用的解决方案。能够帮助我们更好地组织代码、提高代码的可读性和可维护性。
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. 发布订阅模式:
-
发布订阅模式和观察者模式本质上是一样的,所以很多地方也就直接把两者看成一种设计模式了
-
观察者模式是一个对象有多个依赖
-
而发布订阅模式其实是引入了一个中间层,来进行注册和通知
- 其实 eventBus(事件总线)就是实现了发布订阅模式
- eventBus就是这个中间层,可以调用它的on方法进行注册(也就是订阅),调用它的emit方法进行通知(也就是发布)
- 事件总线的具体实现可以看一下以前的帖子:# 23种设计模式,前端常用的有几种?什么是事件总线?它属于哪种?
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)); } } -
-