by - 星期一
一、前言
往往提到设计模式这个概念,有些人都会望而却步,觉得它是一个极其抽象而且高深莫测的东西,想着我又不造火箭(框架),搞搞业务或者造些小轮子用到设计模式也太小题大做了吧!(ps: 这是对设计模式很大的误解)
正巧,小伙伴前面已经分享了好几个框架的源码,也是时候趁热打铁把设计模式捡起来了。
通过阅读本文,你会发现其实很多设计模式我们在实际开发中都用到了或者说用到了它们的核心思想,只是我们并不知道这样写代码叫某种设计模式以及它的优缺点。
二、介绍
1. 什么是设计模式?
通俗的讲,设计模式就是一种书写代码的方式,是为了解决某些或者某类特定的问题给出的简洁优雅的解决方案。
2. 概览
三、设计模式
1. 单例模式
定义与实现
顾名思义:这种模式就是单一的实例。
特点:一个构造函数只能有一个实例,无论new多少次,都是同一个实例。
显然,在这种模式中,我们需要存储一个变量来维护这个不变的实例,调用的时候始终返回这个实例就可以。
自然,就可以简单的如此实现:
// 定义一个Person类
const Person = function () {
this.name = '张三';
};
Person.prototype.getName = function () {
return this.name;
};
let instance = null; // 存储实例的变量
function singleton() {
if(!instance) {
// 如果不存在该实例,则创建
instance = new Person();
}
// 如果存在,直接返回
return instance;
}
let p1 = new singleton()
let p2 = new singleton()
console.log(p1 === p1) // true
由此可以看出,单例模式的核心就是两个元素:保存实例的变量和判断是否存在并返回实例的方法
既然需要将变量一直保存,就有了更优化的方案:闭包。来实现变量私有化。
const singleton = (function () {
// 会被保存在一个不会被销毁的函数执行空间里面
let instance = null;
return function () {
if (!instance) {
instance = new Person();
}
return instance;
};
})();
除此之外,单例模式还有更多优化的实现方案,例如惰性模式来实现使用时创建实例等等,我们可以根据使用中的实际场景来对它进行优化。
应用场景
-
全局模态框
-
Vuex
-
ES Module, 用export导出的实例对象
先来看看这个问题,下面这段代码中,import会执行几次?
import { A } from './a.js' import { A } from './a.js'答案当然是1次,等同于
import { A } from './a.js'第二个例子,import会执行几次?
import { A } from './a.js' import { B } from './a.js'还是一次,等同于
import { A, B } from './a.js'那如果在不同文件中执行同一个import呢?导出的还是同一个对象。 所以,如果多次重复执行同一句
import语句,那么只会执行一次,而不会执行多次。也就是说,import语句是单例模式。 在这里再抛出一个问题,那如果通过 Webpack 将 ES6 转成ES5 以后呢,这种方式还会是单例对象吗?
2. 观察者模式和发布订阅模式
这两种设计模式应该是我们最耳熟能详的模式了,把它们放在一起是因为很多人认为它们其实是一种,也有人认为是两种不同的设计模式。先让我们分别了解下这两种设计模式。
观察者模式:
特点: 当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知,并自动更新
根据它的特点,我们需要两个构造函数来实现,一个被观察者,一个观察者。
被观察者构造函数中需要有的内容:
- 一个状态
- 一个数组,记录观察者
- 一个能设置自己的状态的方法
- 一个通知观察者更新的方法
- 一个添加观察者的方法
- 一个删除观察者的方法
// 创建被观察者
class Subject {
constructor(state) {
this.state = state; // 状态
this.observers = []; // 观察者
}
// 状态更新
setState(state) {
this.state = state;
this.notifyAllObservers();
}
// 通知观察者更新
notifyAllObservers() {
this.observers.forEach((observer) => {
observer.update(this.state);
});
}
// 注册观察者
addObserver(observer) {
this.observers.push(observer);
}
// 删除观察者
removeObserver(observer) {
this.observers = this.observers.filter((obs) => obs !== observer);
}
}
观察者构造函数需要有的内容:
- 一个名字或者说身份
- 一个要更新的方法
// 创建观察者
class observer {
constructor(name) {
this.name = name;
}
update(state) {
console.log(`${this.name} update: ${state}`);
}
}
使用:
// 创建一个被观察者 - 学生,观察状态是学习
const student = new Subject('上学');
// 创建两个观察者
const headMaster = new observer('班主任');
const father = new observer('家长');
// 给被观察者添加观察者
student.addObserver(headMaster);
student.addObserver(father);
// 更新状态
student.setState('网吧');
// console
// 班主任 update: 网吧
// 家长 update: 网吧
发布订阅模式
特点: 基于一个事件通道,订阅者通过自定义事件订阅事件,发布者通过发布事件的方式通知订阅者
有点绕,其实他的核心思想就是通过一个构造函数管理一个消息队列,这个消息队列是个状态和更新行为的集合。
分析下这个构造函数的主要组成:
- 一个消息队列格式be like:
[{stateA: [ fn1 , fn2 ]}, {stateB: [fn1, fn2]}] - 一个向消息队列添加内容的方法
- 一个删除消息队列内容的方法
- 一个触发消息队列内容的方法
class observer {
constructor() {
this.message = [];
}
// 添加消息
add(state, fn) {
if (!this.message[state]) {
// 消息队列还未注册此状态相关事件
this.message[state] = [];
}
this.message[state].push(fn);
}
// 删除消息
remove(state, fn) {
if (!this.message[state]) {
return;
}
if (!fn) {
// 删除所有相关事件
delete this.message[state];
return;
}
// 删除指定事件
this.message[state] = this.message[state].filter((item) => item !== fn);
}
// 更新消息
update(state) {
if (!this.message[state]) {
return;
}
this.message[state].forEach((item) => item());
}
}
// 创建一个实例 - 第三方平台
const platform = new observer();
// 拜托平台观察一些事情
platform.add('书到了', () => {
console.log('发短信:您订阅的书到了');
});
platform.add('书到了', () => {
console.log('邮寄到xxx地址');
});
// 更新状态
platform.update('书到了');
// 输出
// 发短信:您订阅的书到了
// 邮寄到xxx地址
区别:
观察者模式把观察者对象维护在目标对象中的,需要发布消息时直接发消息给观察者。在观察者模式中,目标对象本身是知道观察者存在的。
发布/订阅模式中,发布者并不维护订阅者,也不知道订阅者的存在,所以也不会直接通知订阅者,而是通知调度中心,由调度中心通知订阅者。
应用场景:
-
事件监听 addEventListener()
-
vue响应式原理
-
mobx
根据前面mob源码的分享,我们都知道mobx的核心是采用了观察者模式的,这里我们来回顾一下。
在mobx中,我们需要一个值或者一个对象更新时,触发相应的响应。
mobx源码中,在get方法中,通过一系列的处理,最终将值包装成ObservableValue,这个ObservableValue就是被观察者角色,观察者就是在这个过程中读取过值的reaction们,在set值时,最终会触发notifyListeners通知观察者更新。
3. 策略模式
定义与实现
要实现某一个功能,有多种方案可以选择。通过定义策略,把它们一个个封装起来,并且使它们可以相互转换。
结合一个实际例子就很好理解了,例如双11的购物车结算,经历过双十一都知道每件商品都有自己的折扣和满减方式,199-20、200-30、前一小时两件5折等等。那么我们首先想到的最顺手的解决方式就是if/else嘛,虽然可以解决,但是逼死代码洁癖患者哈哈,而且下次双十二的结算呢?复制粘贴?显然一个能够复用的折扣计算方法会比较实用。
我们来看看策略模式如何来解决此类问题,先来看看策略模式包含有什么:
- 一个定义了多种方案的Strategy对象
- 一个执行指定方案的方法
- 一个添加策略的方法
- 一个删除策略的方法
class ShopCartCalc {
constructor() {
this.strategy = {
'200_30': function (price) {
return price - parseInt(price / 200) * 30;
},
'199_20': function (price) {
return price - parseInt(price / 199) * 20;
},
half_off_two: function (price) {
return price - parseInt(price / 2);
}
};
}
// 添加策略
add(discount, fn) {
this.strategy[discount] = fn;
}
// 删除策略
remove(discount) {
delete this.strategy[discount];
}
// 计算价格
getPrice(price, discount) {
if (!this.strategy[discount]) {
return price; // 没有这个折扣,返回原价
}
return this.strategy[discount](price);
}
}
const calcPrice = new ShopCartCalc();
calcPrice.add('80%', (price) => price * 0.8);
console.log(calcPrice.getPrice(100, '200_30'));
console.log(calcPrice.getPrice(100, '80%'));
应用场景
- 表单验证
- 购物车结算
- 奖金计算
4. 适配器模式
定义与实现
别名:包装器。目的是为了不改变源代码的情况下,让两个不兼容的对象在同样的调用下正常运作。
可以理解为适配器模式就是基于源对象的再封装来处理兼容问题。
适配器模式中有三种角色:
- 目标(target)
- 适配器(adapter)
- 适配者(adaptee)
// 目标类
class Target {
constructor() {
this.name = 'Target';
}
request() {
console.log('Target request');
}
}
// 适配者类
class Adaptee {
constructor() {
this.name = 'Adaptee';
}
// 自身的方法,不兼容目标类的方法
specificRequest() {
console.log('Adaptee specificRequest');
}
}
// 适配器类
class Adapter extends Adaptee {
constructor() {
super();
this.name = 'Adapter';
}
// 适配目标类的方法
request() {
// 调用适配者的方法
this.specificRequest();
}
}
const target = new Target();
target.request();
const adapter = new Adapter();
adapter.request();
应用场景
- vue的计算属性实现
- axios的适配器
// default.js
function getDefaultAdapter() {
var adapter;
if (typeof XMLHttpRequest !== 'undefined') {
// For browsers use XHR adapter
adapter = require('./adapters/xhr');
} else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// For node use HTTP adapter
adapter = require('./adapters/http');
}
return adapter;
}
// http适配器
module.exports = function httpAdapter(config) {
return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
// ...
}
// xhr适配器
module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// ...
}
五、设计原则
设计模式的目的就是为了让代码有更好的复用性,可读性,增加可维护性和更易于扩展。所以也就有了一些原则来约束我们如何设计代码。
SOLID原则:
S - 【单一职责原则】:一个对象或方法应该只负责一件事
O - 【开放封闭原则】:对扩展开放,对修改关闭
L - 【里氏置换原则】:子类能够覆盖、替换父类,且不破坏程序的正常运行(主要适用于继承)
I - 【接口独立原则】:保持接口的单一独立(js使用较少)
D - 【依赖倒置原则】:面向接口编程,依赖于抽象而不依赖于具体。(js使用较少)
这五大原则中,S、O这两个原则更为重要。
六、总结
设计模式的核心是观察整个逻辑中的变与不变,将变与不变分离,达到使变化的部分灵活,不变的地方稳定的目的。
希望通过这一次对设计模式的初探,起到一个抛砖引玉的作用,打破部分同学心中对设计模式的陌生感和畏惧感。也希望我们在实际开发中能够合理运用设计模式,多考虑设计原则,更轻松愉快的开发。
招贤纳士
青藤前端团队是一个年轻多元化的团队,坐落在有九省通衢之称的武汉。我们团队现在由 20+ 名前端小伙伴构成,平均年龄26岁,日常的核心业务是网络安全产品,此外还在基础架构、效率工程、可视化、体验创新等多个方面开展了许多技术探索与建设。在这里你有机会挑战类阿里云的管理后台、多产品矩阵的大型前端应用、安全场景下的可视化(网络、溯源、大屏)、基于Node.js的全栈开发等等。
如果你追求更好的用户体验,渴望在业务/技术上折腾出点的成果,欢迎来撩~ yan.zheng@qingteng.cn