JS设计模式浅尝

115 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第7天,点击查看活动详情 >>

本篇博文源于阅读JavaScript 设计模式核⼼原理与应⽤实践的总结

SOLID设计原则

  • 单一功能原则(Single Responsibility Principle)
  • 开放封闭原则(Opened Closed Principle)
  • 里氏替换原则(Liskov Substitution Principle)
  • 接口隔离原则(Interface Segregation Principle)
  • 依赖反转原则(Dependency Inversion Principle)

单例模式

实现一个Storage

描述:

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

实现:

// 先实现一个基础的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

实现一个全局的模态框

描述:

实现一个全局唯一的Modal弹框

实现:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>单例模式弹框</title>
  <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>
</head>
<body>
  <button id="open">打开弹框</button>
  <button id="close">关闭弹框</button>
  
  <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的形式调用也可以,和storeage同理
      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>
</body>
</html>

\

原型模式

对象的深拷贝

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
}

\

装饰器模式

React中的装饰器:HOC

编写一个高阶组件,它的作用是把传入的组件丢进一个有红色边框的容器里(拓展其样式)

import React,{Component} from 'react'

const BorderHOC = WrappedComponent => class extends Component{
  render(){
    return <div style={{border: 'solid 1px red'}}>
              <WrappedComponent />
           </div>
  }
}

使用:

import React,{Component} from 'react'
import BorderHOC from './BorderHOC'

// 用BorderHOC装饰目标组件
@BorderHOC
class TargetComponent extends React.Component{
  render(){
    // 目标组件具体的业务逻辑
  }
}

// export出去的其实是一个被包裹后的组件
export default TargetComponent

\

代理模式

使用代理模式建立一个婚介所

// 规定礼物的数据结构有type和value组成
const present = {
  type: '巧克力',
  value: 60
}

// 为用户增开presents字段存储礼物
const girl={
  // 姓名
  name:'小美',
  // 自我介绍
  aboutMe:'...',
  // 年龄
  age:24,
  // 职业
  career:'teacher',
  // 假头像
  fakeAvatar: 'xxx',
  // 真实头像
  avatar: 'xxx',
  // 手机号
  phone:12345,
  // 礼物数组
  presents:[],
  // 拒收50元以下的礼物
  bottomValue:50,
  // 记录最近一次收到的礼物
  lastPresent:present,
}

// 婚介所开张了
const Lovers=new Proxy(girl,{
  get:function(girl,key){
    if(baseInfo.indexOf(key)!==-1&&!user.isValidated){
      alert('你还没有完成验证')
      return
    }
    
    // 此处我们认为只有验证过的用户才可以购买VIP
    if(user.isValidated&&privateInfo.indexOf(key)!==-1&&!user.isVIP){
      alter('只有VIP才可以查看该信息')
      return
    }
  },
  
  set:function(girl,key,val){
    // 最近一次送来的礼物会尝试给lastPresent字段
    if(key==='lastPresent'){
      if(val.value<girl.bottomValue){
        alert('sorry,您的礼物被拒收了')
        return
      }
      
      // 如果没有被拒收,则赋值成功,同时并入present数组
      girl.lastPresent=val
      girl.presents=[...girl.presents,val]
    }
  }
}
})

\

事件代理

一个父元素下有多个子元素,需要为每个子元素监听点击事件

<!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>
const father=document.getElementById('father')

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

\

虚拟代理

图片的预加载:为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬,常见的操作是先让这个img标签展示一个占位图,然后创建一个Image实例,让这个Image实例的src指向真实的目标图片地址、观察该Image实例的加载情况——当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将DOM上的img元素的src指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了

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

class ProxyImage{
  // 占位图的url地址
  static LOADING_URL='xxx'
  
  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)
  }
})()

\

保护代理

目前实现保护代理时,考虑的首要方案就是ES6中的Proxy

策略模式

if-else策略改造

// 询价方法,接受价格标签和原价为入参
function askPrice(tag, originPrice) {

  // 处理预热价
  if(tag === 'pre') {
    if(originPrice >= 100) {
      return originPrice - 20
    } 
    return originPrice * 0.9
  }
  
  // 处理大促价
  if(tag === 'onSale') {
    if(originPrice >= 100) {
      return originPrice - 30
    } 
    return originPrice * 0.8
  }
  
  // 处理返场价
  if(tag === 'back') {
    if(originPrice >= 200) {
      return originPrice - 50
    }
    return originPrice
  }
  
  // 处理尝鲜价
  if(tag === 'fresh') {
     return originPrice * 0.5
  }
}

改造后

// 定义一个询价处理器对象
const priceProcessor = {
  pre(originPrice) {
    if (originPrice >= 100) {
      return originPrice - 20;
    }
    return originPrice * 0.9;
  },
  onSale(originPrice) {
    if (originPrice >= 100) {
      return originPrice - 30;
    }
    return originPrice * 0.8;
  },
  back(originPrice) {
    if (originPrice >= 200) {
      return originPrice - 50;
    }
    return originPrice;
  },
  fresh(originPrice) {
    return originPrice * 0.5;
  },
};

// 询价函数
function askPrice(tag, originPrice) {
  return priceProcessor[tag](originPrice)
}

priceProcessor.newUser = function (originPrice) {
  if (originPrice >= 100) {
    return originPrice - 50;
  }
  return originPrice;
}

\

状态模式

把状态-行为映射对象作为主体类对应实例的一个属性添加进去就行

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

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

\

\

\

策略模式与状态模式的区别

  • 策略模式和状态模式事相似的,它们都封装行为、都通过委托来实现行为分发
  • 策略模式中的行为函数是“潇洒”的行为函数,它们不依赖调用主体、互相平行、各自为政,井水不犯河水
  • 状态模式中的行为函数,首先是和状态主体之间存在着关联,由状态主体把它们串在一起;正因为关联着同样的一个主体,所以不同状态对应的行为函数可能并不会特别割裂

观察者模式——发布/订阅模式

Vue的响应式原理

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

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

\

实现observer

首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的getter和setter函数,这样单反这个对象的某个属性发生了改变,就会触发setter函数,进而通知到订阅者,这个setter函数,就是我们的监听器:

// observe方法遍历并包装对象属性
function observe(target){
  // 若target是一个对象,则遍历它
  if(target&&typeof target==='object'){
    Object.keys(target).forEach((key)=>{
      // defineReactive方法会给目标属性装上“监听器”
      defineReactive(target,key,target[key])
    })
  }
}

// 定义defineReactive方法
function defineReactive(target,key,val){
  // 属性值也可能是object类型,这种情况下需要调用observe进行递归便利
  observe(val)
  // 为当前属性安装监听器
  Object.defineProperty(target,key,{
    // 可枚举
    enumerable:true,
    // 不可配置
    configurable:false,
    get:function(){
      return val;
    },
    // 监听器函数
    set:function(value){
      // 通知所有订阅者
      dep.notify()
    }
  })
}

下面实现订阅者Dep:

// 定义订阅者Dep
class Dep{
  constructor(){
    // 初始化订阅队列
    this.subs=[]
  }
  
  // 增加订阅者
  addSub(sub){
    this.subs.push(sub)
  }
  
  // 通知订阅者(是不是所有的代码都似曾相识)
  notify(){
    this.subs.forEach((sub)=>{
      sub.update()
    })
  }
}

\

实现一个Event Bus/Event Emitter

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[evnetName].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)
  }
}

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

  • 发布者直接触及到订阅者的操作,叫做观察者模式
  • 发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信和操作,叫做发布-订阅模式

\

为什么要有观察者模式?观察者模式,解决的其实是模块间的耦合问题,有它在,即便是两个分离的、毫不相关的模块,也可以实现数据通信。但观察者模式仅仅是减少了耦合,并没有完全地解决耦合问题——被观察者必须去维护一套观察者的集合,这些观察者必须实现统一的方法供被观察者调用,两者之间还是有着说不清、道不明的关系。

而发布-订阅模式,则是快刀斩乱麻了——发布者完全不用感知订阅者,不用关心它怎么实现回调方法,事件的注册和触发都发生在独立于双方的第三方平台(事件总线)上。发布-订阅模式下,实现了完全地解耦。

但这并不意味着,发布-订阅模式就比观察者模式“高级”。在实际开发中,我们的模块解耦诉求并非总是需要它们完全解耦。如果两个模块之间本身存在关联,且这种关联是稳定的、必要的,那么我们使用观察者模式就足够了。而在模块与模块之间独立性较强、且没有必要单纯为了数据通信而强行为两者制造依赖的情况下,我们往往会倾向于使用发布-订阅模式。

\

迭代器模式

写一个能够生成迭代器对象的迭代器生成函数

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