设计模式入门,看这篇文章就够了

324 阅读13分钟

注:该篇文章是JavaScript 设计模式核心原理与应用实践的整理和记录

简单工厂模式

将创建对象的过程单独封装,这样的操作就是工厂模式。同时它的应用场景也非常容易识别:有构造函数的地方,我们就应该想到简单工厂;在写了大量构造函数、调用了大量的 new、自觉非常不爽的情况下,我们就应该思考是不是可以掏出工厂模式重构我们的代码了

function User(name , age, career, work) {
    this.name = name
    this.age = age
    this.career = career 
    this.work = work
}

function Factory(name, age, career) {
    let work
    switch(career) {
        case 'coder':
            work =  ['写代码','写系分', '修Bug'] 
            break
        case 'product manager':
            work = ['订会议室', '写PRD', '催更']
            break
        case 'boss':
            work = ['喝茶', '看报', '见客户']
        case 'xxx':
            // 其它工种的职责分配
            ...
            
    return new User(name, age, career, work)
}

抽象工厂模式

举例:实现一个手机工厂,手机需要操作系统+硬件的组成

那么我们可以实现一个抽象类

class MobilePhoneFactory {
    // 提供操作系统的接口
    createOS(){
        throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
    }
    // 提供硬件的接口
    createHardWare(){
        throw new Error("抽象工厂方法不允许直接调用,你需要将我重写!");
    }
}

抽象工厂不做具体的事情,具体工厂才做

因此我们实现一个具体工厂实现方法

// 具体工厂继承自抽象工厂
class FakeStarFactory extends MobilePhoneFactory {
    createOS() {
        // 提供安卓系统实例
        return new AndroidOS()
    }
    createHardWare() {
        // 提供高通硬件实例
        return new QualcommHardWare()
    }
}

同时我们又可以去抽象具体的产品,比如createOS方法,我们可以创建安卓操作系统,也可以创建苹果操作系统,因此有下面的抽象类和具体类方法

// 定义操作系统这类产品的抽象产品类
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 Singleton {
    show() {
        console.log('我是一个单例对象')
    }
    static getInstance() {
        // 判断是否已经new过1个实例
        if (!Singleton.instance) {
            // 若这个唯一的实例不存在,那么先创建它
            Singleton.instance = new Singleton()
        }
        // 如果这个唯一的实例已经存在,则直接返回
        return Singleton.instance
    }
}

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

// true
s1 === s2

闭包的形式

SingleDog.getInstance = (function() {
    // 定义自由变量instance,模拟私有变量
    let instance = null
    return function() {
        // 判断自由变量是否为null
        if(!instance) {
            // 如果为null则new出唯一实例
            instance = new SingleDog()
        }
        return instance
    }
})()

Vuex的store的实现就是一个单例模式的实践,Vuex是如何确保store唯一的呢?

Vuex在项目中的引入

Vue.use(Vuex)
new Vue({ el: '#app', store })

通过调用Vue.use()方法,我们安装了 Vuex 插件。Vuex 插件是一个对象,它在内部实现了一个 install 方法,这个方法会在插件安装时被调用,从而把 Store 注入到Vue实例里去。也就是说每 install 一次,都会尝试给 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)
}

单例模式面试题

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

静态方法版本:

// 定义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
  1. 实现一个全局唯一的Modal弹窗
<!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>

装饰器模式

在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求

ES7 语法定义了装饰器的使用 @+函数

如果装饰类,target参数就是装饰的类

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

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

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

如果是装饰类的方法

提供3个参数

target:指向类的原型 即Person.prototype

name:装饰的属性名

descriptor:属性描述符,和Object.defineProperty里面的含义一样


class Person {
  @readonly
  name() { return `${this.first} ${this.last}` }
}

function readonly(target, name, descriptor){
  // descriptor对象原来的值如下
  // {
  //   value: specifiedFunction,
  //   enumerable: false,
  //   configurable: true,
  //   writable: true
  // };
  descriptor.writable = false;
  return descriptor;
}

具体实践应用:React 高阶函数 HOC

适配器模式

适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题

业务场景:

比如通过fetch封装一个http库

export default class HttpUtils {
    // get方法
    static get(url) {
      return new Promise((resolve, reject) => {
        // 调用fetch
        fetch(url)
          .then(response => response.json())
          .then(result => {
            resolve(result)
          })
          .catch(error => {
            reject(error)
          })
      })
    }
    
    // post方法,data以object形式传入
    static post(url, data) {
      return new Promise((resolve, reject) => {
        // 调用fetch
        fetch(url, {
          method: 'POST',
          headers: {
            Accept: 'application/json',
            'Content-Type': 'application/x-www-form-urlencoded'
          },
          // 将object类型的数据格式化为合法的body参数
          body: this.changeData(data)
        })
          .then(response => response.json())
          .then(result => {
            resolve(result)
          })
          .catch(error => {
            reject(error)
          })
      })
    }
    
    // body请求体的格式化方法
    static changeData(obj) {
      var prop,
        str = ''
      var i = 0
      for (prop in obj) {
        if (!prop) {
          return
        }
        if (i == 0) {
          str += prop + '=' + obj[prop]
        } else {
          str += '&' + prop + '=' + obj[prop]
        }
        i++
      }
      return str
    }
  }

它的使用

// 定义目标url地址
const URL = "xxxxx"
// 定义post入参
const params = {
    ...
}

// 发起post请求
 const postResponse = await HttpUtils.post(URL,params) || {}
 
 // 发起get请求
 const getResponse = await HttpUtils.get(URL) || {}

加入现在项目中还存在以前封装的一个Ajax库,它的使用像下面这样

// 发送get请求
Ajax('get', url地址, post入参, function(data){
    // 成功的回调逻辑
}, function(error){
    // 失败的回调逻辑
})

那我们不希望手动更改每一个接口调用,通过适配器模式进行改写

// Ajax适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
    const type = type.toUpperCase()
    let result
    try {
         // 实际的请求全部由新接口发起
         if(type === 'GET') {
            result = await HttpUtils.get(url) || {}
        } else if(type === 'POST') {
            result = await HttpUtils.post(url, data) || {}
        }
        // 假设请求成功对应的状态码是1
        result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
    } catch(error) {
        // 捕捉网络错误
        if(failed){
            failed(error.statusCode);
        }
    }
}

// 用适配器适配旧的Ajax方法
async function Ajax(type, url, data, success, failed) {
    await AjaxAdapter(type, url, data, success, failed)
}

代理模式

在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式

Vue源码中使用了代码模式使得我们可以直接通过this.dataKey this.methodKey访问

要求:了解ES6的Proxy

举例:婚介所

// 普通私密信息
const baseInfo = ['age', 'career']
// 最私密信息
const privateInfo = ['avatar', 'phone']

// 用户(同事A)对象实例
const user = {
    ...(一些必要的个人信息)
    isValidated: true,
    isVIP: false,
}

// 掘金婚介所登场了
const JuejinLovers = new Proxy(girl, {
  get: function(girl, key) {
      if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
          alert('您还没有完成验证哦')
          return
      }
      
      //...(此处省略其它有的没的各种校验逻辑)
    
      // 此处我们认为只有验证过的用户才可以购买VIP
      if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
          alert('只有VIP才可以查看该信息哦')
          return
      }
  }
})

业务开发中最常见的代码模式:

事件代理

利用事件冒泡特性,将事件绑定在父元素上监听

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

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

图片懒加载和预加载

缓存代理

它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果

举例:参数求和

// 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)
    }
})()
保护代理

最开始案例婚介所保护隐私,登录注册了才可以访问信息,VIP才可以访问资深信息

观察者模式

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新

比如产品经理拉群,通知程序员需求更新,产品经理就是发布者,程序员就是订阅者

发布者类

// 定义发布者类
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')
    }
}

具体应用1:Vue的响应式原理

在Vue数据双向绑定的实现逻辑里,有这样三个关键角色:

  • observer(监听器):注意,此 observer 非彼 observer。在我们上节的解析中,observer 作为设计模式中的一个角色,代表“订阅者”。但在Vue数据双向绑定的角色结构里,所谓的 observer 不仅是一个数据监听器,它还需要对监听到的数据进行转发——也就是说它同时还是一个发布者
  • watcher(订阅者):observer 把数据转发给了真正的订阅者——watcher对象。watcher 接收到新的数据后,会去更新视图。
  • compile(编译器):MVVM 框架特有的角色,负责对每个节点元素指令进行扫描和解析,指令的数据初始化、订阅者的创建这些“杂活”也归它管

具体应用2:实现一个EventBus

class EventEmitter {
    constructor() {
      // handlers是一个map,用于存储事件与回调之间的对应关系
      this.handlers = {}
    }
  
    // on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
    on(eventName, cb) {
      // 先检查一下目标事件名有没有对应的监听函数队列
      if (!this.handlers[eventName]) {
        // 如果没有,那么首先初始化一个监听函数队列
        this.handlers[eventName] = []
      }
  
      // 把回调函数推入目标事件的监听函数队列里去
      this.handlers[eventName].push(cb)
    }
  
    // emit方法用于触发目标事件,它接受事件名和监听函数入参作为参数
    emit(eventName, ...args) {
      // 检查目标事件是否有监听函数队列
      if (this.handlers[eventName]) {
        // 这里需要对 this.handlers[eventName] 做一次浅拷贝,主要目的是为了避免通过 once 安装的监听器在移除的过程中出现顺序问题
        const handlers = this.handlers[eventName].slice()
        // 如果有,则逐个调用队列里的回调函数
        handlers.forEach((callback) => {
          callback(...args)
        })
      }
    }
  
    // 移除某个事件回调队列里的指定回调函数
    off(eventName, cb) {
      const callbacks = this.handlers[eventName]
      const index = callbacks.indexOf(cb)
      if (index !== -1) {
        callbacks.splice(index, 1)
      }
    }
  
    // 为事件注册单次监听器
    once(eventName, cb) {
      // 对回调函数进行包装,使其执行完毕自动被移除
      const wrapper = (...args) => {
        cb(...args)
        this.off(eventName, wrapper)
      }
      this.on(eventName, wrapper)
    }
  }

观察者模式和发布订阅模式的区别?

产品经理把所有的开发者拉了一个群,直接把需求文档丢给每一位群成员,这种发布者直接触及到订阅者的操作,叫观察者模式。但如果产品经理没有拉群,而是把需求文档上传到了公司统一的需求平台上,需求平台感知到文件的变化、自动通知了每一位订阅了该文件的开发者,这种发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信的操作,叫做发布-订阅模式

迭代器模式

了解ES6的Iterator

手动实现一个迭代器

// 定义生成器函数,入参是任意集合
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()