设计模式

201 阅读16分钟

原则

  • 单一功能
  • 开放封闭
  • 里式替换
  • 接口隔离
  • 依赖反转

开发中主要用单一功能和开放封闭原则

核心:将变化造成影响最小化,变与不变分离,保证变化部分灵活,不变部分稳定。场景是基础,代码是辅助,逻辑是主角。

不同的设计模式用自己的方式封装不同类型的变化 ——

创建型:封装创建对象过程中的变化。工厂模式:将创建对象的过程抽离;

结构型:对象间组合方式的变化,灵活表达对象间配合与依赖关系;

行为型:对象行为抽离,确保安全方便地对行为更改。

构造器模式

就是ES5构造函数,new一个实例,传不同的数据,实例化不同的对象。new的过程变的是value,不变的是key。构造器将 name、age、career 赋值给对象的过程封装,确保每个对象都具备这些key,不变的地方稳定。同时将 name、age、career 的取值开放,确保变化部分灵活。本质是抽象每个实例的变与不变。

场景:创建不同的用户,每个用户具备name、age、caree属性。

解决:实例之间。

简单工厂模式

抽象不同构造函数(类)之间的变与不变。new之前先判断变化的部分,确定了再传参。

场景:不同工种分配不同的职责说明,每个工种用户加个性化字段,分配构造器之前判断工种。不同的构造器中key相同,但是key1决定key2的变化。

解决:类之间的问题,将创建对象的过程单独封装。有构造函数的地方就有简单工厂。

抽象工厂模式

ps:开放封闭原则:拓展开放,修改封闭。软件实体(类、模块、函数)可扩展,但不可修改。

场景:简单工厂直接传参,但是不同参数new出来的对象权限及功能不同,需要对某个对象进行单独逻辑处理,例如在case为boss的地方逻辑处理,这样每增加一个群体就在在Factory里面修改不符合开放封闭原则。所以出现了抽象工厂。

class MobilePhoneFactory {
    // 提供操作系统的接口
    createOS(){
        throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
    }
    // 提供硬件的接口
    createHardWare(){
        throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
    }
}
// 具体工厂继承自抽象工厂
class FakeStarFactory extends MobilePhoneFactory {
  // 具体产品类
    createOS() {
        // 提供安卓系统实例
        return new AndroidOS()
    }
  // 具体产品类
    createHardWare() {
        // 提供高通硬件实例
        return new QualcommHardWare()
    }
}

// 定义操作系统这类产品的抽象产品类
class OS {
    controlHardWare() {
        throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
    }
}

// 定义具体操作系统的具体产品类
class AndroidOS extends OS {
    controlHardWare() {
        console.log('我会用安卓的方式去操作硬件')
    }
}

class AppleOS extends OS {
    controlHardWare() {
        console.log('我会用🍎的方式去操作硬件')
    }
}
// 定义手机硬件这类产品的抽象产品类
class HardWare {
    // 手机硬件的共性方法,这里提取了“根据命令运转”这个共性
    operateByOrder() {
        throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
    }
}

// 定义具体硬件的具体产品类
class QualcommHardWare extends HardWare {
    operateByOrder() {
        console.log('我会用高通的方式去运转')
    }
}

class MiWare extends HardWare {
    operateByOrder() {
        console.log('我会用小米的方式去运转')
    }
}
// 这是我的手机
const myPhone = new FakeStarFactory()
// 让它拥有操作系统
const myOS = myPhone.createOS()
// 让它拥有硬件
const myHardWare = myPhone.createHardWare()
// 启动操作系统(输出‘我会用安卓的方式去操作硬件’)
myOS.controlHardWare()
// 唤醒硬件(输出‘我会用高通的方式去运转’)
myHardWare.operateByOrder()

// 扩展新产品:拓展开放,修改封闭
class newStarFactory extends MobilePhoneFactory {
    createOS() {
        // 操作系统实现代码
    }
    createHardWare() {
        // 硬件实现代码
    }
}

MobilePhoneFactory是最顶层的抽象工厂,里面包含多个子工厂,抽象工厂不能new实例,实际new实例的是子工厂,子工厂可以自由组合形成一个具体工厂FakeStarFactory,它继承抽象工厂。

class类,抽象工厂里面提供接口抽象不同的类,不能生成具体实例。具体工厂继承extends抽象工厂, 自定义实例,后续扩展时直接extends抽象工厂就可以,不会对其他造成影响。

  • 抽象工厂(抽象类,不能生成具体实例): 声明目标产品的共性。在一个系统里,抽象工厂可以有多个(手机厂被更大的厂收购,这个厂除了手机抽象类,还有平板、游戏机抽象类等),每个抽象工厂对应的这一类的产品,称为“产品族”。
  • 具体工厂(生成产品族里具体的产品): 继承自抽象工厂、实现抽象工厂里声明的方法,用于创建具体产品类。
  • 抽象产品(抽象类,不能生成具体实例): 具体工厂里实现的接口,会依赖一些类,这些类对应到各种具体的细粒度产品(操作系统、硬件),这些具体产品类的共性各自抽离,便对应到了各自的抽象产品类。
  • 具体产品(生成产品族里具体的产品所依赖的更细粒度的产品): 比如具体的一种操作系统、或具体的一种硬件。

解决:工厂与工厂之间

单例模式

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

实现思路:如何保证一个类仅有一个实例?创建一个类(构造函数),new生成任意多的实例对象。不同对象各占一块内存空间,相互独立。单例模式想实现的是,不管创建多少次,只返回第一次创建的唯一实例。所以构造函数要判断自己是否创建过一个实例。

class SingleDog {
    show() {
        console.log('我是一个单例对象')
    }
    static getInstance() {
        // 判断是否已经new过1个实例
        if (!SingleDog.instance) {
            // 若这个唯一的实例不存在,那么先创建它
            SingleDog.instance = new SingleDog()
        }
        // 如果这个唯一的实例已经存在,则直接返回
        return SingleDog.instance
    }
}

const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()

// true
s1 === s2

场景:vuex

Vue组件间通信常用的是 props(父子组件),事件监听(兄弟组件)。组件复杂时将共享的数据抽离放在全局,供组件们按一定的的规则存取数据,保证状态以一种可预测的方式变化。于是有了 Vuex存放共享数据的唯一数据源Store。一个 Vue 实例只能对应一个 Store。

Vuex如何确保Store的唯一性?

Vuex 插件对象内部实现了 install 方法,它会在插件安装时被调用,从而把 Store 注入到Vue实例里。每 install 一次,都会给 Vue 实例注入一个 Store。在 install 方法里:保证一个 Vue 实例只会被 install 一次 Vuex 插件,所以每个 Vue 实例只会有一个全局的 Store。

let Vue // 这个Vue的作用和楼上的instance作用一样
export function install (_Vue) {
  // 判断传入的Vue实例对象是否已经被install过Vuex插件(是否有了唯一的state)
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  // 若没有,则为这个Vue实例对象install一个唯一的Vuex
  Vue = _Vue
  // 将Vuex的初始化逻辑写进Vue的钩子函数里
  applyMixin(Vue)
}

实现一个Storage

实现Storage,使该对象为单例,基于 localStorage 封装。实现方法 setItem(key,value) 和 getItem(key)。

思路:getInstance方法和instance变量。判断逻辑写入静态方法或者构造函数里或用闭包。

静态方法

// 定义Storage
class Storage {
    static getInstance() {
        // 判断是否已经new过1个实例
        if (!Storage.instance) {
            // 若这个唯一的实例不存在,那么先创建它
            Storage.instance = new Storage()
        }
        // 如果这个唯一的实例已经存在,则直接返回
        return Storage.instance
    }
    getItem (key) {
        return localStorage.getItem(key)
    }
    setItem (key, value) {
        return localStorage.setItem(key, value)
    }
}

const storage1 = Storage.getInstance()
const storage2 = Storage.getInstance()

storage1.setItem('name', '李雷')
// 李雷
storage1.getItem('name')
// 也是李雷
storage2.getItem('name')

// 返回true
storage1 === storage2

闭包

// 先实现一个基础的StorageBase类,把getItem和setItem方法放在它的原型链上
function StorageBase () {}
StorageBase.prototype.getItem = function (key){
    return localStorage.getItem(key)
}
StorageBase.prototype.setItem = function (key, value) {
    return localStorage.setItem(key, value)
}

// 以闭包的形式创建一个引用自由变量的构造函数
const Storage = (function(){
    let instance = null
    return function(){
        // 判断自由变量是否为null
        if(!instance) {
            // 如果为null则new出唯一实例
            instance = new StorageBase()
        }
        return instance
    }
})()

// 这里其实不用 new Storage 的形式调用,直接 Storage() 也会有一样的效果 
const storage1 = new Storage()
const storage2 = new Storage()

storage1.setItem('name', '李雷')
// 李雷
storage1.getItem('name')
// 也是李雷
storage2.getItem('name')

// 返回true
storage1 === storage2

全局的模态框

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>单例模式弹框</title>
</head>
<style>
    #modal {
        height: 200px;
        width: 200px;
        line-height: 200px;
        position: fixed;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        border: 1px solid black;
        text-align: center;
    }
</style>
<body>
	<button id='open'>打开弹框</button>
	<button id='close'>关闭弹框</button>
</body>
<script>
    // 核心逻辑,这里采用了闭包思路来实现单例模式
    const Modal = (function() {
    	let modal = null
    	return function() {
            if(!modal) {
            	modal = document.createElement('div')
            	modal.innerHTML = '我是一个全局唯一的Modal'
            	modal.id = 'modal'
            	modal.style.display = 'none'
            	document.body.appendChild(modal)
            }
            return modal
    	}
    })()
    
    // 点击打开按钮展示模态框
    document.getElementById('open').addEventListener('click', function() {
        // 未点击则不创建modal实例,避免不必要的内存占用;此处不用 new Modal 的形式调用也可以,和 Storage 同理
    	const modal = new Modal()
    	modal.style.display = 'block'
    })
    
    // 点击关闭按钮隐藏模态框
    document.getElementById('close').addEventListener('click', function() {
    	const modal = new Modal()
    	if(modal) {
    	    modal.style.display = 'none'
    	}
    })
</script>
</html>

原型模式

概述

JS本质就是原型模式。强类型语言中,原型模式是为了实现类型之间解耦。而 JS 本身类型比较模糊,不存在类型耦合问题,所以平时不会刻意使用原型模式。不必强行把原型模式当作设计模式理解,作为一种编程范式更合适。原型模式是设计模式也是一种编程范式,是 JS面向对象系统实现的根基。

原型模式下,创建一个对象,先找一个对象作为原型,然后克隆原型来创建出一个与原型一样(共享一套数据/方法)的对象。Object.create就是原型模式,凡是借助Prototype实现对象创建和原型继承,就是在应用原型模式。JS中原型模式,不是为了得到副本,而是为了得到与构造函数(类)相对应的类型的实例、实现数据/方法的共享。克隆是实现的方法,但克隆本身不是目的。

Java中类是面向对象的根基,创建两个一样的实例需要new两次传同样的参数,JAVA中针对原型模式设计了一套接口和方法,必要场景下会通过原型方法来应用原型模式。更多情况下,J仍以“实例化类”方式创建对象。所以在以类为中心的语言中,原型模式不是必选项,只在特定场景下用。

JS的es6语法也支持类,但是这里的类其实是原型继承的语法糖。下面两段代码等价:

class Dog {
  constructor(name ,age) {
   this.name = name
   this.age = age
  }
  
  eat() {
    console.log('肉骨头真好吃')
  }
}
function Dog(name, age) {
  this.name = name
  this.age = age
}
Dog.prototype.eat = function() {
  console.log('肉骨头真好吃')
}

原型编程范式

核心思想是利用实例描述对象,用实例作为定义对象和继承的基础。JS 中,原型编程范式的体现就是基于原型链继承。对原型、原型链理解是关键。

原型

JS中,每个构造函数都有一个prototype属性,它指向构造函数的原型对象,这个原型对象中有个 constructor 属性指回构造函数;每个实例都有个__proto__属性,当用构造函数创建实例时,实例的__proto__属性会指向构造函数的原型对象。 具体来说,当我们这样使用构造函数创建一个对象时:

// 创建一个Dog构造函数
function Dog(name, age) {
  this.name = name
  this.age = age
}
Dog.prototype.eat = function() {
  console.log('肉骨头真好吃')
}
// 使用Dog构造函数创建dog实例
const dog = new Dog('旺财', 3)

原型链

上面代码基础上,进行两个方法调用:

// 输出"肉骨头真好吃"
dog.eat()
// 输出"[object Object]"
dog.toString()

没有在 dog 实例里定义 eat 和 toString,还是被成功调用。是因为访问实例的属性/方法时,首先搜索这个实例本身;若实例没有定义对应的属性/方法,会去搜索实例的原型对象;若原型对象中也搜不到,就搜索原型对象的原型对象,这个搜索的轨迹,叫做原型链。

eat 和 toString 调用搜索过程:

这些彼此相连的prototype,就组成一个原型链。 注: 所有 JS 中对象都是原型链顶端的 Object 的实例,除了Object.prototype(手动Object.create(null)创建一个没有任何原型的对象,就不是 Object 的实例)。

ps:混淆 JS 中原型范式和强类型语言中原型模式区别,可能是为了考察对象的深拷贝

模拟 JAVA 中的克隆接口?

JavaScript 实现原型模式?

实现JS中的深拷贝?

对象深拷贝

方法一 JSON.stringify

const liLei = {
    name: 'lilei',
    age: 28,
    habits: ['coding', 'hiking', 'running']
}
const liLeiStr = JSON.stringify(liLei)
const liLeiCopy = JSON.parse(liLeiStr)
liLeiCopy.habits.splice(0, 1) 
console.log('李雷副本的habits数组是', liLeiCopy.habits)
console.log('李雷的habits数组是',  liLei.habits)

引用类型也被成功拷贝,副本和本体互不干扰。这个方法无法处理 function、正则等——只有当对象是个严格的 JSON 对象时,可以用这个方法。

方法二 递归

若属性为值类型,直接返回;为引用类型,递归遍历

function deepClone(obj) {
    // 是 值类型 或 null,则直接return
    if(typeof obj !== 'object' || obj === null) {
        return obj
    }
    
    // 定义结果对象
    let copy = {}
    
    // 如果对象是数组,则定义结果数组
    if(obj.constructor === Array) {
        copy = []
    }
    
    // 遍历对象的key
    for(let key in obj) {
        // 如果key是对象的自有属性
        if(obj.hasOwnProperty(key)) {
            // 递归调用深拷贝方法
            copy[key] = deepClone(obj[key])
        }
    }
    
    return copy
} 

装饰器模式

功能分离,通用基础功能上添加新的自定义逻辑。

es7中定义一个装饰器,通过@装饰器名称 给class添加装饰器。

// 装饰器函数,它的第一个参数是目标类
function classDecorator(target) {
    target.hasDecorator = true
  	return target
}

// 将装饰器“安装”到Button类上
@classDecorator
class Button {
    // Button类的相关逻辑
}

// 验证装饰器是否生效
console.log('Button 是否被装饰了:', Button.hasDecorator)

语法糖

// 具体的参数意义,在下个小节,这里大家先感知一下操作
function funcDecorator(target, name, descriptor) {
    let originalMethod = descriptor.value
    descriptor.value = function() {
    console.log('我是Func的装饰器逻辑')
    return originalMethod.apply(this, arguments)
  }
  return descriptor
}

class Button {
    @funcDecorator
    onClick() { 
        console.log('我是Func的原有逻辑')
    }
}

// 验证装饰器是否生效
const button = new Button()
button.onClick()

@decorator作用

  1. 函数传参 调用装饰器函数,将被装饰者“交给”装饰器
  2. 定义装饰器函数,将“属性描述对象”交到你手里

适配器模式

编写适配器函数,用适配器承接旧接口的参数,可以实现新旧接口无缝衔接。

代理模式

vpn访问谷歌就是代理模式

ES6的proxy

const proxy = new Proxy(obj, handler)

第一个参数是目标对象,handler 也是对象,定义代理行为。通过 proxy 访问目标对象的,handler会拦截,每次访问都要经过 handler 第三方。可以定义getter和setter之前拦截。

事件代理

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>事件代理</title>
</head>
<body>
  <div id="father">
    <a href="#">链接1号</a>
    <a href="#">链接2号</a>
    <a href="#">链接3号</a>
    <a href="#">链接4号</a>
    <a href="#">链接5号</a>
    <a href="#">链接6号</a>
  </div>
</body>
</html>

点击每个 a 标签,都弹出“我是xxx”。意味着至少安装 6 个监听函数给 6 个不同的元素(循环),a 标签增多,性能开销大。考虑事件“冒泡”特性,点击 a 元素,事件会“冒泡”到父元素,从而被监听。那么事件监听函数只需在 div 元素上被绑定一次,不需要在子元素上被绑定 N 次——事件代理。

// 获取父元素
const father = document.getElementById('father')

// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
    // 识别是否是目标子元素
    if(e.target.tagName === 'A') {
        // 以下是监听函数的函数体
        e.preventDefault()
        alert(`我是${e.target.innerText}`)
    }
} )

虚拟代理

图片预加载:先让 img 标签展示占位图,然后创建 Image 实例,让实例的 src 指向真实目标图片地址、观察该实例加载情况 —— 当其对应的真实图片加载完后,即已经有该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时直接取了目标图片的缓存,所以展示速度非常快,从占位图到目标图片的时间差小到用户注意不到。

new Image()是实现虚拟代理的关键

class PreLoadImage {
    constructor(imgNode) {
        // 获取真实的DOM节点
        this.imgNode = imgNode
    }
     
    // 操作img节点的src属性
    setSrc(imgUrl) {
        this.imgNode.src = imgUrl
    }
}

class ProxyImage {
    // 占位图的url地址
    static LOADING_URL = 'xxxxxx'

    constructor(targetImage) {
        // 目标Image,即PreLoadImage实例
        this.targetImage = targetImage
    }
    
    // 该方法主要操作虚拟Image,完成加载
    setSrc(targetUrl) {
       // 真实img节点初始化时展示的是一个占位图
        this.targetImage.setSrc(ProxyImage.LOADING_URL)
        // 创建一个帮我们加载图片的虚拟Image实例
        const virtualImage = new Image()
        // 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
        virtualImage.onload = () => {
            this.targetImage.setSrc(targetUrl)
        }
        // 设置src属性,虚拟Image实例开始加载图片
        virtualImage.src = targetUrl
    }
}

缓存代理

应用于计算量大的场景,用空间换时间——当需要用某个已经计算过的值的时候,不再二次计算,从内存取现成的结果。这种场景下,需要代理帮我们在计算的同时,将计算结果缓存。比如:对传入的参数求和,柯里化

// addAll方法会对你传入的所有参数做求和操作
const addAll = function() {
    console.log('进行了一次新计算')
    let result = 0
    const len = arguments.length
    for(let i = 0; i < len; i++) {
        result += arguments[i]
    }
    return result
}

// 为求和方法创建代理
const proxyAddAll = (function(){
    // 求和结果的缓存池
    const resultCache = {}
    return function() {
        // 将入参转化为一个唯一的入参字符串
        const args = Array.prototype.join.call(arguments, ',')
        
        // 检查本次入参是否有对应的计算结果
        if(args in resultCache) {
            // 如果有,则返回缓存池里现成的结果
            return resultCache[args]
        }
        return resultCache[args] = addAll(...arguments)
    }
})()

保护代理

proxy实现,在 getter 和 setter 函数里校验和拦截,确保变量安全。

策略模式

遵循单一功能原则,扩展开放修改封闭,做对象映射。只需关注计算逻辑本身。

状态模式

遵循单一功能原则,扩展开放修改封闭,做对象映射。但是计算逻辑本身需要对主体有感知。状态-行为映射对象作为主体类对应实例的一个属性添加进去:通过this共享信息

class CoffeeMaker {
  constructor() {
    /**
    这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
  **/
    // 初始化状态,没有切换任何咖啡模式
    this.state = 'init';
    // 初始化牛奶的存储量
    this.leftMilk = '500ml';
  }
  stateToProcessor = {
    that: this,
    american() {
      // 尝试在行为函数里拿到咖啡机实例的信息并输出
      console.log('咖啡机现在的牛奶存储量是:', this.that.leftMilk)
      console.log('我只吐黑咖啡');
    },
    latte() {
      this.american()
      console.log('加点奶');
    },
    vanillaLatte() {
      this.latte();
      console.log('再加香草糖浆');
    },
    mocha() {
      this.latte();
      console.log('再加巧克力');
    }
  }

  // 关注咖啡机状态切换函数
  changeState(state) {
    this.state = state;
    if (!this.stateToProcessor[state]) {
      return;
    }
    this.stateToProcessor[state]();
  }
}

const mk = new CoffeeMaker();
mk.changeState('latte');

观察者模式

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

它也叫发布订阅模式,但有区别,发布者直接触及到订阅者的操作,叫观察者模式,不直接触及到订阅者、由统一的第三方完成实际通信的操作,叫发布-订阅模式

// 定义发布者类
class Publisher {
  constructor() {
    this.observers = []
    console.log('Publisher created')
  }
  // 增加订阅者
  add(observer) {
    console.log('Publisher.add invoked')
    this.observers.push(observer)
  }
  // 移除订阅者
  remove(observer) {
    console.log('Publisher.remove invoked')
    this.observers.forEach((item, i) => {
      if (item === observer) {
        this.observers.splice(i, 1)
      }
    })
  }
  // 通知所有订阅者
  notify() {
    console.log('Publisher.notify invoked')
    this.observers.forEach((observer) => {
      observer.update(this)
    })
  }
}
// 定义订阅者类
class Observer {
    constructor() {
        console.log('Observer created')
    }

    update() {
        console.log('Observer.update invoked')
    }
}

应用场景:vue双向数据绑定,组件通信

迭代器模式

ES6中,针对Array、Map、Set、String、TypedArray、函数的 arguments 对象、NodeList 对象这些原生的数据结构都可以通过for...of...进行遍历。原理都一样。任何数据结构只要具备Symbol.iterator属性(这个属性就是Iterator的具体实现,本质是当前数据结构默认的迭代器生成函数),就可以被遍历——准确地说,是被for...of...循环和迭代器的next方法遍历。 事实上,for...of...的背后是对next方法反复调用。

ES6实现迭代器生成函数

// 编写一个迭代器生成函数
function *iteratorGenerator() {
    yield '1号选手'
    yield '2号选手'
    yield '3号选手'
}

const iterator = iteratorGenerator()

iterator.next()
iterator.next()
iterator.next()

ES5实现迭代器生成函数

// 定义生成器函数,入参是任意集合
function iteratorGenerator(list) {
    // idx记录当前访问的索引
    var idx = 0
    // len记录传入集合的长度
    var len = list.length
    return {
        // 自定义next方法
        next: function() {
            // 如果索引还没有超出集合长度,done为false
            var done = idx >= len
            // 如果done为false,则可以继续取值
            var value = !done ? list[idx++] : undefined
            
            // 将当前值与遍历是否完毕(done)返回
            return {
                done: done,
                value: value
            }
        }
    }
}

var iterator = iteratorGenerator(['1号选手', '2号选手', '3号选手'])
iterator.next()
iterator.next()
iterator.next()