基本概念
维基百科对设计模式的定义:
在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。设计模式并不直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。面向对象设计模式通常以类别或对象来描述其中的关系和相互作用,但不涉及用来完成应用程序的特定类别或对象。设计模式能使不稳定依赖于相对稳定、具体依赖于相对抽象,避免会引起麻烦的紧耦合,以增强软件设计面对并适应变化的能力。
通俗点理解,设计模式是一套被反复使用,经过分类编目,代码设计经验的总结,它是解决特定问题的一系列思路,具有普遍性和重用性。使用了设计模式可以提高代码的可读性,可复用性和可靠性。
设计模式的本质是面向对象的设计原则,它是实际应用中是很好的解决问题的方案,所以作为开发人员可以将其应用到实际项目中。目前,有多达23种设计模式被收录在1995年出版的《设计模式:可复用面向对象软件的基础》中,例如:简单工厂,抽象工厂,代理模式,适配模式,享元模式,策略模式,观察者模式,迭代器模式等等,他们往往会满足不同的需求。这里不会对设计的七大原则,诸如开闭原则,里氏替换等进行介绍。写这篇文章的初心是想让大家活学活用,不要对概念和原则进行生搬硬套,设计模式是基于业务解决问题的,切记不要为了模式而模式。
作为前端工程师来说,我们最关注的当然是在这十几二十种设计模式中,有哪些是可以用于生产解决实际问题的。遵循着设计模式大方向的分类,前端关注的设计模式也会分为下面几类:
- 创建型模式:对象实例化的模式,用于解耦对象的实例化过程,它专注于类实例化使得创建实体更加轻松。重点关注:单例模式,工厂模式,抽象工厂模式
- 结构型模式:它们处理构建不同组件(或类)之间的关系并形成新结构以提供新功能。重点关注:代理模式,装饰器模式
- 行为模式:类和对象如何交互,及划分责任和算法。重点关注:命令模式,职责链模式,观察者模式
单例模式
单例模式可能是最有名气的设计模式之一了,所以我们先从单例模式说起。单例能确保无论实例化多少次类,永远只会有一个可用的实例,并且它提供了一个访问全局的访问点。 单例模式我相信大家是最熟悉的了,所以我们直接看看在实际代码开发中,有哪些场景需要使用到单例模式。
- 登录弹框:我们需要有一个全局唯一的弹框,保证多次点击都是一个实例。
- 数据库的连接:我们希望每次用户请求时只处理一次连接,防止多个用户请求时不停建立新的连接。
- 数据缓存:项目中数据缓存处理是必要的,我们需要一个全局的缓存中心去做缓存的收集处理,统一管理缓存数据的增删查改。
- Vue.use(plugins): 要保证不重复注册插件,每个插件内部都必须保证单例唯一。
如果要实现一个单例,每次调用的时候都返回同一个实例,我们可以这样实现,createSingleton
是一个自执行的函数,最终提供了一个全局访问的点getInstance
,每次调用getInstance
时,如果instance
存在,则忽略实例化过程,返回instance
实例,当不存在时会实例化真正的构造函数。最终结果可以看到两次创建实例后,instance1
和instance2
指向同一个实例。
const createSingleton = (function() {
let instance
let Singleton = function() {
console.log('constructor')
return this
}
return {
getInstance: () => {
if(!instance) {
instance = new Singleton()
}
return instance
}
}
})()
let instance1 = createSingleton.getInstance()
let instance2 = createSingleton.getInstance()
console.log(instance1 === instance2)
// constructor
// true
当然有了es6
和typescript
后,实现单例模式显得更加的简单和直观。
class Singleton {
// Singleton的静态属性
private static instance: Singleton
private constructor() {
console.log('constructor called')
}
// Singleton的静态方法
public static getInstance(): Singleton {
if(!this.instance) {
this.instance = new Singleton()
}
return this.instance
}
}
let instance3: Singleton = Singleton.getInstance()
let instance4: Singleton = Singleton.getInstance()
console.log(instance3 === instance4)
// constructor
// true
如果尝试实例化这个单例,会有下面的报错
实际应用
上面是单例模式伪代码的实现,如果还不是很好理解这一思想,我们接着往下看实际的应用场景。我们会依次实现一个缓存处理器以及分析Vuex
对单例模式的应用。
缓存处理器
在写程序时,我们有时候需要一些运算过的数据,或者保留一些重复获取的数据已提高程序性能。这时候一个好的缓存处理器就显得格外重要。一个缓存处理器需要有存储,获取,删除,清空等功能。下面我们模拟一个缓存处理类Cache
,在实例化Cache
后,可以调用get,set,del,clear
等方式进行缓存操作。
class Cache {
protected cacheMap: Map<string, any>
constructor() {
this.cacheMap = new Map()
}
get(key: string): any {
return this.cacheMap.get(key)
}
set(key: string, value: any): void {
this.cacheMap.set(key, value)
}
has(key: string): boolean {
return this.cacheMap.has(key)
}
del(key: string): void {
this.cacheMap.delete(key)
}
clear(): void {
this.cacheMap.clear()
}
}
export default Cache
我们通过构造函数的方式实现了一个Cache
类,使用的时候通过new
去实例化。那么这种方式会遇到什么问题呢?
import Cache from './cache'
let c1 = new Cache()
let c2 = new Cache()
c1.set('test', 1)
c2.set('test2', 2)
console.log(c2.get('test'))
// undefined
很明显,每次执行new Cache()
都会创建一个全新的对象,而我们期待的结果是,不管实例化多少个缓存处理器,缓存的操作都是全局唯一的,我们希望在c1
保存的test
,在c2
同样也能拿到这个缓存记录。不然这个缓存处理器将失去意义。此时,单例模式便登场了。
解决这一问题的方式有很多种,我们先看第一种处理方式。如果Cache
这个构造函数是成熟的且不想进行内部逻辑重构的,我们可以将Cache
类隐藏起来,创建一个新SingletonCache
的类,由它去负责维护唯一的实例。
class Cache {
protected cacheMap: Map<string, any>
constructor() {
this.cacheMap = new Map()
}
get(key: string): any {
return this.cacheMap.get(key)
}
set(key: string, value: any): void {
this.cacheMap.set(key, value)
}
has(key: string): boolean {
return this.cacheMap.has(key)
}
del(key: string): void {
this.cacheMap.delete(key)
}
clear(): void {
this.cacheMap.clear()
}
}
// 增加一个singletonCache类,由它去维护唯一实例
class SingletonCache {
private static instance: Cache
constructor() {}
public static getInstance(): Cache {
if(!SingletonCache.instance) {
SingletonCache.instance = new Cache()
}
return SingletonCache.instance
}
}
export default SingletonCache
此时不管在程序中任意地方去实例化操作,SinlgetonCache.getInstance()
保证了每次都是同一个实例。
import Cache from './cache'
let c1 = Cache.getInstance()
let c2 = Cache.getInstance()
c1.set('test', 1)
c2.set('test2', 2)
console.log(c2.get('test'))
// 1
照着这种思路,如果我们一开始就知道设计Cache
类的时候就要考虑它是一个单例模式的类,那这个时候又可以有另一种做法,将维护单例的方式移到Cache
类中
class Cache {
protected cacheMap: Map<string, any>
private static instance: Cache
private constructor() {
this.cacheMap = new Map()
}
get(key: string): any {
return this.cacheMap.get(key)
}
set(key: string, value: any): void {
this.cacheMap.set(key, value)
}
has(key: string): boolean {
return this.cacheMap.has(key)
}
del(key: string): void {
this.cacheMap.delete(key)
}
clear(): void {
this.cacheMap.clear()
}
public static getInstance(): Cache {
if(!Cache.instance) {
Cache.instance = new Cache()
}
return Cache.instance
}
}
export default Cache
同样的,调用和结果都与第一种方式相同。最后我们再考虑一个问题,如果不使用单例模式去实现这一系列的逻辑,是否还有其他方式去保证单例呢?
答案很显然是有的。最简单的保证单例的方式是利用node模块化自身的缓存机制。
class Cache {
protected cacheMap: Map<string, any>
constructor() {
this.cacheMap = new Map()
}
get(key: string): any {
return this.cacheMap.get(key)
}
...
}
export const cacheContainer = new Cache()
我们在main
和main1
两个模块中分别引入实例化后的cacheContainer
,此时也能保证使用的全局唯一的实例。
// main.ts
import { cacheContainer } from './cache'
cacheContainer.set('test1', 1)
console.log(cacheContainer.get('test'))
// main1.ts
import { cacheContainer } from './cache'
cacheContainer.set('test2', 2)
console.log(cacheContainer.get('test1'))
// 1
在服务端,import
最终会编译成require
,而当我们使用require
去引用一个模块时,V8引擎会缓存require
的对象,如果你再次require
时,这个对象会从require.cache
中拿,此时cacheContainer
为同一个对象实例。所以我们也可以利用模块化的特性去完成单例。
编译后的结果:
插件的单例
最后的应用实践,我们来看一个实际大家用得比较多的库Vuex
,我们知道Vue
提供了一种简洁方便的插件方式用来添加全局的功能,例如全局的方法,全局的资源,全局的混入等等。而这一切都是基于Vue.use()
使用的。以Vuex
为例子,如何保证Vuex
插件在多次安装后,依然只保留一个全局的Store
用于存储应用的所有状态?
如果我们多次安装Vuex
,为什么不会有多个Store
?
Vue.use(Vuex)
Vue.use(Vuex)
...
接下来从源码入手看插件的实现,我们先看看如何在项目中引入Vuex
:
const Vuex = require('vuex')
Vue.use(Vuex)
new Vue({
el: '#app',
})
Vue.use
的实现在于调用了插件自身的install
方法,它自身并不去维护插件的单例。
Vue.use = function (plugin: Function | Object) {
...
// 如果插件带有install方法,则执行install进行插件的安装
if (typeof plugin.install === 'function') {
plugin.install.apply(plugin, args)
} else if (typeof plugin === 'function') {
// plugins本身是一个函数,则执行函数进行插件安装
plugin.apply(null, args)
}
installedPlugins.push(plugin)
return this
}
真正实现单例是在Vuex
的插件中。let Vue
会保持一个全局唯一值,如果已经赋值了。则证明插件已经成功安装,多次安装会被判断跳过重复安装的过程。
// 全局标志
let Vue
export function install (_Vue) {
// 如果已经赋值,不再执行后续applyMixin的混入过程。
if (Vue && _Vue === Vue) {
if (__DEV__) {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
)
}
return
}
// 如果已经安装过,Vue被赋值
Vue = _Vue
// 注入的核心逻辑
applyMixin(Vue)
}
单例模式解决了什么问题?
观察了这两个实际例子最后我们回头想想单例模式到底解决了什么问题。
- 保证一个类只有一个实例。众所周知,普通构造函数是无法实现这一行为的,构造函数设计的目的决定了每次实例化都返回一个新的对象。单例模式可以让你每次创建新对象时都拿到已创建的对象,而不是一个新对象。
- 为该实例提供一个全局的访问点。单例模式允许代码在任何地方访问特定对象,但也仅仅关心特定访问点,不考虑其内部实现逻辑。
单例模式的实现方式
- 默认构造函数不再是公有的,而是封装成私有的,防止外部通过直接new进行实例化。
- 新建一个静态的方法,该方法是唯一对外的访问接口,方法会调用私有构造函数去实例化对象,并将实例对象存储,如果已经保存了,则直接返回缓存的实例对象。
工厂模式
接下来我们介绍另一种创造型的设计模式-工厂模式。先说一下工厂模式的概念:工厂模式需要定义一个创建产品对象的工厂接口,用户面对的是一个统一的工厂接口,而实际创建的工作会放到每个具体的工厂类中。
生产应用
具体怎么更好的理解这段话,我们先从一个实际的例子入手。
假如你的公司是做车辆生产的,你的老板让你一个需求,需求是设计一款宝马牌的汽车,并可以创建投产。这时候我们第一想到的就是构造函数了,设计一个BmwCar
的类,有了这个类后每次实例化就能生产一辆汽车,我们简单通过一个desc
方法去描述这款汽车。
class BmwCar {
constructor() {}
desc(): void {
console.log('BmwCar')
}
}
let bmw = new BmwCar()
bmw.desc() // BmwCar
很好!需要多少辆宝马车就实例化多少就达到目的了。这时候又收到新任务需要设计另一款奔驰牌的汽车,可能第一反应这也很简单,我在新建一个BenzCar
的类也可以完成任务。
class BenzCar {
constructor() {}
desc(): void {
console.log('BenzCar')
}
}
let benz = new BenzCar()
benz.desc() // BenzCar
看起来一切都挺好的,可是渐渐的发现,又有其他的车型需要生产,而每个车型都是一个独立的构造函数。此时的问题在于**用户在实例化一辆车时,都需要判断先去确定车型是属于哪个构造器后再去选择对应的构造器进行实例化。**是否有更好的方式让用户不需要去关心每个车的创建过程呢。这个时候我们就可以想到工厂模式。
首先把每种车型的共性抽出来处理成接口,而后每个车型的构造器都是对这一共性接口的实现。
// Car的共性接口
interface Car {
desc(): void;
}
// 奔驰牌
class BenzCar implements Car {
constructor() {}
public desc(): void {
console.log("BenzCar");
}
}
// 宝马牌
class BmwCar implements Car {
constructor() {}
public desc(): void {
console.log("BmwCar");
}
}
// 比亚迪牌
class BydCar implements Car {
constructor() {}
public desc(): void {
console.log("BydCar");
}
}
之后我们创建一个车辆的工厂函数,由它去内部判断实例化哪个车型。而用户在生产车时,只需要实例化这个工厂就能按照需要生产不同的车。
// 车辆生产的工厂函数
class CarFactory {
public static TYPE_BENZ: string = "benz";
public static TYPE_BMW: string = "bmw";
public static TYPE_BYD: string = "byd";
constructor() {}
public static createCar(type: string): Car {
switch (type) {
case CarFactory.TYPE_BENZ:
return new BenzCar();
case CarFactory.TYPE_BMW:
return new BmwCar();
case CarFactory.TYPE_BYD:
return new BydCar();
default:
throw new Error('非法传参');
}
}
}
// 统一调用工厂方法创建不同类型的车
let ben = CarFactory.createCar("benz");
let bmw = CarFactory.createCar("bmw");
ben.desc(); // benz
bmw.desc(); // bmw
看起来这样好多了,在创建车辆时已经不再需要关心调用哪种构造器了,工厂函数将这一切都隐藏到幕后去处理了。统一的工厂方法避免了开发人员和具体的产品之间的耦合,这样的代码也更加容易维护。
适用场景
总结一下工厂模式的应用场景
- 当你无法预知类别和依赖关系的时候,可以使用工厂模式
- 如果你在开发一个库,使用工厂模式可以为开发人员提供一种方法来扩展内部的组件,而不需要访问其他源。
抽象工厂模式
一个简单的工厂函数可以就能解决所有的生产问题了吗,显然不是的。实际的生产开发中,**业务的复杂程度往往不是一个工厂可以搞定的。**我们需要对简单的工厂模式进行更大的抽象,这个抽象用来处理相关工厂之间的关系。这个就是接下来要介绍的抽象工厂模式。
从概念来理解还是稍显晦涩,我们继续从实际例子来引入这一个概念。接着上一个制造车的需求。现在生产一辆车需要考虑它的一些厂商的组合,不管是什么牌子的车型,都需要有不同颜色的配置,比如红色,黑色,白色,而每款车子也可以配备不同的发动机,这里假设有两种发动机,一个是A引擎,一个是发送机B引擎。每辆车的生产需要有颜色厂商提供的颜色,发动机厂商提供的发动机,两者的结合才是一辆完整的车子。例如需要生产一种红颜色,带A引擎的宝马车。
生产应用
如果接着上一个思路,我们可能需要修改每一个车的构造函数,有多少种组合就需要多少种构造函数。并且我们同时需要修改我们的工厂函数CarFactory
。让它继续swtich case
各种车型。很显然,这并不是一个好的做法。每次增加一种车型,都要去修改CarFactory
,除了便捷性之外,唯一的这个工厂函数还会越来越庞大。
抽象工厂模式能够很好的解决这类问题,我们会对工厂进行更大的抽象,并且对下层工厂进行解耦,在这之前先有个概念,javascript作为一门弱类型语言,我们很难在创建对象时进行解耦,毕竟它具有天然的多态性,不需要考虑类型的耦合问题,但是借助typescript
,我们可以很好的去还原抽象类。
首先先从每个底层的工厂类开始,我们需要定义执行不同功能的抽象类,IColor
是专门配置车身颜色的抽象类,IEngine
是专门负责配置车子引擎的抽象类。
// 配置颜色 - 抽象产品
abstract class IColor {
abstract setColor(): string
}
// 配置引擎
abstract class IEngine {
abstract setEngine(): string
}
**抽象类并不会对功能进行实现,具体的实现放在继承抽象类的具体类上。**接下来需要根据这些抽象类去实现每个具体类,RedColor
是最终给车身配置红色的具体类
// 具体产品
// RedColor是对IColor的继承
class RedColor extends IColor {
public setColor(): string {
return 'red color'
}
}
class BlackColor extends IColor {
public setColor(): string {
return 'black color'
}
}
类似的AEngine
是给车子配置AEngine
的具体类
// AEngine是对IEngine的继承
class AEngine extends IEngine {
public setEngine(): string {
return 'AEngine'
}
}
class BEngine extends IEngine {
public setEngine(): string {
return 'BEngine'
}
}
一旦定义了和最终创建车子的相关类之后,我们要创建一个负责定义和组装上面各种底层工厂类的结构AbstractCarFactory
,我们用它去约定车子具有什么通用特性,其中createColor
表示车子需要选择车身颜色,createEngine
表示车子需要配置引擎。
// 抽象工厂
abstract class AbstractCarFactory {
abstract createColor(): IColor
abstract createEngine(): IEngine
}
最后需要定义每个具体工厂,并在其中实例化具体的类。BmwCarFactory
负责创建一个红颜色车身,AEngine
发送机的车子,BenzCarFactory
负责创建黑颜色车身,BEngine
发动机的车子。
// 具体工厂
class BmwCarFactory extends AbstractCarFactory {
public createColor() {
return new RedColor()
}
public createEngine() {
return new AEngine()
}
}
class BenzCarFactory extends AbstractCarFactory {
public createColor() {
return new BlackColor()
}
public createEngine() {
return new BEngine()
}
}
最终对不同车型进行实例化
let benz = new BenzCarFactory()
let bmw = new BmwCarFactory()
console.log(benz.createColor().setColor())
console.log(bmw.createEngine().setEngine())
这就是基础抽象工厂模式的实现,它将创建对象的责任委托给使用多态的特定类(具体工厂),这样代码的扩展性会更强。如果又有一个新的需求,需要新增另一种车型,这时候只需要创建一个新的工厂类继承AbstractCarFactory
就可以实现了。
class BydCarFactory extends AbstractCarFactory {
...
}
实现抽象工厂类
抽象工厂和普通工厂的使用不同在于场景的复杂程度,抽象工厂是对普通工厂的更高级抽象。而如何实现抽象工厂类,需要需要理清楚四个分类
- 抽象类:每个很小颗粒度的功能特性集合,它是抽象类,是负责描述,不负责实现,目的是将产品共性抽离,例子中的
IColor, IEngine
- 具体类:对抽象类的实现,也就是对每个最小颗粒度功能的实现,它收到抽象类约束,例子中的
RedColor,BlackColor
- 抽象工厂:最终产品的共性,对每个小颗粒度功能特性的集合描述,例子中的
AbstractCarFactory
- 具体工厂:对应每个具体产品,需要继承实现抽象工厂定义的方法,最终用来创建具体的产品,例子中的
BmwCarFactory
总结它的使用场景的话就是,如果代码需要与多个不同系列的相关产品交互, 但是由于无法提前获取相关信息, 或者出于对未来扩展性的考虑, 你不希望代码基于产品的具体类进行构建, 在这种情况下, 你可以使用抽象工厂。