设计模式 01
从这里开始学习设计模式。牢记一个规则:设计模式的主题总是把不变的事物和变化的事物分离开来。
设计模式一共有 23 种,在此共记录在 JavaScript 开发中更 常见的 14 种设计模式。它们分别是:
单例模式
单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
通俗的解释就是只能开一个应用,如只能打开一个微信,应用开启后再点击,会打开在运行的微信无法创建一个新的微信。
简单实现单例模式
可以想到如果只能创建一个类或者实例,则会永远访问同一个内存地址。所以可以用闭包来实现:
let Singleton = function (name) {
this.name = name
}
Singleton.prototype.getName = function () {
console.log(this.name)
}
Singleton.getInstance = (function () {
let instance = null
return function (name) {
if (!instance) {
instance = new Singleton(name)
}
return instance
}
})()
const a = Singleton.getInstance('first') // 第一个应用
const b = Singleton.getInstance('second') // 第二个应用
console.log(a === b) // true
注意: 依据单例模式的特点,在 JavaScript 中,全局变量不是单例模式,但在 JavaScript 开发中,我们经常会把全局变量当成单例来使用。这容易造成命名空间污染,应当尽量避免全局变量的使用。
封装一个通用的单例模式
const getSingle = function (fn) {
let result = null
return function () {
return result || (result = fn.apply(this, arguments))
}
}
// 应用实战
var bindEvent = getSingle(function () {
document.getElementById('div1').onclick = function () {
console.log('click 点击')
}
return true
})
var render = function () {
console.log('开始渲染列表')
bindEvent()
}
render()
render()
render()
在这里,render 函数和 bindEvent 函数都分别执行了 3 次,但 div 实际上只被绑定了一个事件。
看上去和防抖节流的实现很类似是不是?确实如此。这样做的好处是,使得要实现的方法作为高阶函数的参数被传入咱们的单例模型里,适用性较广。
策略模式
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
通俗的理解就是厨子为了做饭准备了一些列的厨具,例如菜刀系列都可以切肉,但是每把刀都有所差异。
策略模式的使用,是定义一系列的算法,把它们一个个封装起来。将不变的部分和变化的部分隔开是每个设计模式的主题,目的是将算法的使用与算法的实现分离开来。
简单实现策略模式
以发统计绩效奖金为例,定义一个 strategy 策略计算不同绩效下的奖金:
let strategies = {
S: function (salary) {
return salary * 4
},
A: function (salary) {
return salary * 3
},
B: function (salary) {
return salary * 2
},
}
实际奖金的计算则交由专门的 context 去做:
let calculateBonus = function (level, salary) {
return strategies[level](salary)
}
console.log(calculateBonus('S', 20000)) // 输出:80000
console.log(calculateBonus('A', 10000)) // 输出:30000
实际上在 JavaScript 这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身 当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当我们对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。将上述的策略模式略加修饰便更具隐匿性了:
var S = function (salary) {
return salary * 4
}
var A = function (salary) {
return salary * 3
}
var B = function (salary) {
return salary * 2
}
var calculateBonus = function (func, salary) {
return func(salary)
}
calculateBonus(S, 10000)
代理模式
代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。
来简单实现一个虚拟代理实现图片预加载的实现:
const myImage = (function () {
const imgNode = document.createElement('img')
document.body.appendChild(imgNode)
return {
setSrc: function (src) {
imgNode.src = src
},
}
})()
const proxyImage = (function () {
const img = new Image()
img.onload = function () {
myImage.setSrc(this.src)
}
return {
setSrc: function (src) {
myImage.setSrc('file://C:/Users/Desktop/loading.gif')
img.src = src
},
}
})()
proxyImage.setSrc(
'https://picx.zhimg.com/v2-3b4fc7e3a1195a081d0259246c38debc_1440w.jpg'
)
这段代码创建了两个立即执行函数(IIFE),myImage 和 proxyImage,并在页面上加载了一张图片。
首先,myImage IIFE 创建了一个 imgNode 元素并将其附加到页面的 body 元素上。然后,它返回一个包含 setSrc 方法的对象。 setSrc 方法用于设置 imgNode 元素的 src 属性,以便加载指定的图像。
接下来,proxyImage IIFE 创建了一个新的 Image 对象,并为其设置了一个 onload 事件处理程序。 onload 事件处理程序需要在图片成功加载后才会执行,这里会将图片的 src 属性设置为 myImage 的 setSrc 方法。proxyImage IIFE 返回一个包含 setSrc 方法的对象,该方法设置 img 对象的 src 属性,并将 myImage 的 setSrc 方法设置为一个本地的 loading.gif 图像的路径,以便在图像加载期间显示 loading 图像。
最后,proxyImage 的 setSrc 方法被调用,并传入了一个图像 URL。这将触发 img 对象的 onload 事件处理程序,该处理程序将 myImage 对象的 setSrc 方法设置为新加载的图像 URL,并从本地路径加载 loading.gif 图像,以便在新图像加载期间显示 loading 图像。
刚刚接触代理时,会觉得这个功能大可不必,不经过代理也可实现,但是这里运用到了单一职责思想。单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变 化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可 能会有多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多, 等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能 会遭到意外的破坏。
通俗的解释就是,如果后续不需要这个预加载占位,直接去掉代理即可,这个功能便去掉了不会对图片加载本身造成影响。
迭代模式
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。实际上 js 已经实现了迭代模式。如 Array.prototype.froEach 便可迭代的顺序访问各个属性值。而且目前,绝大部分语言都内置了迭代器。
发布-订阅模式
发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状 态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 开发中,我们一般用事件模型来替代传统的发布—订阅模式。
能够联想到的是 Vue2 中常用的事件总线 EventBus,确实也是基于此模式实现的。基于此,实现一个最小可行的发布-订阅:
// 设置订阅功能
const Event = {
clientList: [],
listen: function (key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = []
}
this.clientList[key].push(fn) // 订阅的消息添加进缓存列表
},
trigger: function () {
let key = Array.prototype.shift.call(arguments),
fns = this.clientList[key]
if (!fns || fns.length === 0) {
return false
}
for (let i = 0; i < fns.length; i++) {
const fn = fns[i++]
fn.apply(this, arguments) // arguments 是 trigger 时附带的参数
}
},
remove: function (key, fn) {
const fns = this.clientList[key]
// 开始遍历取消订阅
if (!fns) return false // 1. key 没有被人订阅, 直接返回
if (!fn) {
// 若没有传入具体的回调函数,则取消 key 对应的所有订阅
fns && (fns.length = 0)
} else {
for (let i = fns.length - 1; i >= 0; i--) {
const fnItem = fns[i]
if (fnItem === fn) fns.splice(i, 1) // 删除订阅者的回调函数
}
}
},
}
// 安装订阅
const installEvent = function (obj) {
for (const key in Event) {
obj[key] = Event[key]
}
}
// 订阅测试
const salesOffices = {}
installEvent(salesOffices)
salesOffices.listen('squareMeter88', function (price) {
// 消息订阅 1
console.log('价格= ' + price)
})
salesOffices.listen('squareMeter100', function (price) {
// 消息订阅 2
console.log('价格= ' + price)
})
salesOffices.trigger('squareMeter88', 2000000) // 输出:2000000
salesOffices.trigger('squareMeter100', 3000000) // 输出:3000000
发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布—订阅模式还可 以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是 MVC 还是 MVVM, 都少不了发布—订阅模式的参与,而且 JavaScript 本身也是一门基于事件驱动的语言。
当然,发布—订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个 bug 不是件轻松的事情。
命令模式
命令模式(Command Pattern)是一种行为型设计模式,它将请求封装成对象,从而使得请求发起者和请求接收者解耦,并且可以方便地记录、撤销和重做请求操作。
在命令模式中,客户端发起请求的对象称为请求者(Invoker),请求接收者称为接收者(Receiver),请求封装成的对象称为命令(Command)。命令对象中包含了请求的操作和相关的参数,以及执行该操作的方法。请求者通过调用命令对象的方法来发起请求,接收者通过执行命令对象的方法来响应请求。
命令模式的核心思想是将请求封装成对象,从而将请求的发起者和接收者解耦。在使用命令模式时,可以将命令对象存储在队列中,实现命令的记录、撤销和重做等功能。此外,命令模式还可以与其他模式结合使用,例如备忘录模式、组合模式等。
命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品。实际上 JavaScript 可以用高阶函数非常方便地实现命令模式。命令模式在 JavaScript 语言中是一种隐形的模式。
<body>
<button id="button1">点击按钮 1</button>
<button id="button2">点击按钮 2</button>
<button id="button3">点击按钮 3</button>
</body>
<script>
let button1 = document.getElementById( 'button1' ),
let setCommand = function( button, func ){
button.onclick = function(){
func();
}
};
let MenuBar = {
refresh: function(){
console.log( '刷新菜单界面' );
}
};
let RefreshMenuBarCommand = function( receiver ){
return function(){
receiver.refresh();
}
};
let refreshMenuBarCommand = RefreshMenuBarCommand( MenuBar );
setCommand( button1, refreshMenuBarCommand );
</script>
命令模式的优点包括:
- 降低系统的耦合度,请求者和接收者之间解耦,可以方便地扩展和修改系统。
- 可以对请求进行记录、撤销和重做等操作,提高系统的灵活性和可维护性。
- 可以将请求封装成独立的对象,并且可以方便地传递和存储,提高系统的可重用性。
命令模式的缺点包括:
- 会增加系统的复杂性,需要定义大量的命令对象和接收者对象。
- 如果命令对象过多,可能会导致系统的性能下降。
- 命令模式中的请求者和接收者之间存在间接调用关系,可能会影响系统的响应速度。
组合模式
组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的。
简单的描述就是搭积木,一个大的对象由数个小的对象组成。
值得注意的地方:
- 组合模式不是父子关系,它们可以是并列的。
- 组合模式除了要求组合对象和叶对象拥有相同的接口之外,还有一个必要条件,就是对一组 叶对象的操作必须具有一致性。下面这个比喻非常恰当: 比如公司要给全体员工发放元旦的过节费 1000 块,这个场景可以运用组合模式,但如果公 司给今天过生日的员工发送一封生日祝福的邮件,组合模式在这里就没有用武之地了,除非先把 今天过生日的员工挑选出来。只有用一致的方式对待列表中的每个叶对象的时候,才适合使用组合模式。
- 双向映射关系,这主要是防止重复嵌套。若不是如此,可能并不适用组合模式。
参考文献
- 《JavaScript 设计模式与开发实践》