Javascript设计模式知识体系汇总

509 阅读9分钟

前言:瞬息万变的前端,也具备一些长久的通用型技术,例如性能优化和设计模式

设计模式就犹如游戏里面的连招技巧一样,这些连招经验就是设计模式

1.工厂模式

用一个工厂函数,创建一个实例,封装创建的过程:

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)
}
    
Factory("yunmu", 18, "coder")

应用场景:

  • jQuery $('div') 创建一个 jQuery 实例
  • React 的React.createElement() 和 Vue 的 h() 创建一个 vnode

2.单例模式

提供全局唯一的对象,无论获取多少次:

应用场景

  • Vuex Redux 的 store ,全局唯一的
  • 全局唯一的 dialog modal
class SingleTon {
    private static instance: SingleTon | null = null
    private constructor() {}
    public static getInstance(): SingleTon {
        if(this.instance == null) {
             this.instance = new SingleTon()
        }
       return this.instance
    }
    fn1() {}
    fn2() {}
}

// const s1 = new SingleTon() // Error: constructor of 'singleton' is private

const s2 = SingleTon.getInstance()
s2.fn1()
s2.fn2()
const s3 = SingleTon.getInstance()
s2 === s3 // true

实现一个全局唯一的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 的形式调用也可以
    	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>

3.代理模式

  • 使用者不能直接访问真实数据,而是通过一个代理层来访问
  • ES Proxy 本身就是代理模式,Vue3 基于它来实现响应式

应用场景实践:

  • 保护代理:下面模拟现实中婚介所,它们就有点类似于代理层,顾客只能通过婚介所间接获取对方身份
// 规定礼物的数据结构由type和value组成
const present = {
    type: '巧克力',
    value: 60,
}

// 为用户增开presents字段存储礼物
const girl = {
  // 姓名
  name: '小美',
  // 自我介绍
  aboutMe: '...'(大家自行脑补吧)
  // 年龄
  age: 24,
  // 职业
  career: 'teacher',
  // 假头像
  fakeAvatar: 'xxxx'(新垣结衣的图片地址)
  // 真实头像
  avatar: 'xxxx'(自己的照片地址),
  // 手机号
  phone: 123456,
  // 礼物数组
  presents: [],
  // 拒收50块以下的礼物
  bottomValue: 50,
  // 记录最近收到的礼物
  lastPresent: present,
}

// 掘金婚介所推出了小礼物功能
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)!==-1 && !user.isVIP) {
        alert('只有VIP才可以查看该信息哦')
        return
    }
  }
  
  set: function(girl, key, val) {
 
    // 最近一次送来的礼物会尝试赋值给lastPresent字段
    if(key === 'lastPresent') {
      if(val.value < girl.bottomValue) {
          alert('sorry,您的礼物被拒收了')
          return
      }
    
      // 如果没有拒收,则赋值成功,同时并入presents数组
      girl.lastPresent = val
      girl.presents = [...girl.presents, val]
    }
  }
 
})
  • 用事件代理(委托)实现多个子元素事件监听
// 获取父元素
const father = document.getElementById('father')

// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
    // 识别是否是目标子元素
    if(e.target.tagName === 'A') {
        // 以下是监听函数的函数体
        e.preventDefault()
        alert(`我是${e.target.innerText}`)
    }
} )
  • 虚拟代理:下图 virtualImage 代替真实 DOM 像图片发送了请求,却未曾渲染
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)
    }
})()

4.迭代器模式

  • 统一遍历的模式
  • 之前可以使用 jQuery 的 each 方法遍历不同的集合对象
  • ES6 之后 使用 迭代器(Iterator),任何结构只要具备 Symbol.iterator 属性,就可以被for...of循环和迭代器的next方法遍历
  • for...of...的背后正是对next方法的反复调用

自定义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()

5.适配器模式

  • 对外统一(入参、调用方式、出参)
  • axios靠一套 API 不仅能在浏览器端调用,而且在 Node 环境同样适用,靠的就是灵活得适配器使用
  • 类似耳机转换头,把一个接口转换为另一个接口

假如我写了这样一个请求库,基于 fetch

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) || {}

这时候如果老板叫我迁移老项目的请求到我这个请求,这下芭比Q了,因为不仅接口名不同,入参方式也不一样

function Ajax(type, url, data, success, failed){
    // 创建ajax对象
    var xhr = null;
    if(window.XMLHttpRequest){
        xhr = new XMLHttpRequest();
    } else {
        xhr = new ActiveXObject('Microsoft.XMLHTTP')
    }
 
   ...(此处省略一系列的业务逻辑细节)
   
   var type = type.toUpperCase();
    
    // 识别请求类型
    if(type == 'GET'){
        if(data){
          xhr.open('GET', url + '?' + data, true); //如果有数据就拼接
        } 
        // 发送get请求
        xhr.send();
 
    } else if(type == 'POST'){
        xhr.open('POST', url, true);
        // 如果需要像 html 表单那样 POST 数据,使用 setRequestHeader() 来添加 http 头。
        xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
        // 发送post请求
        xhr.send(data);
    }
 
    // 处理返回数据
    xhr.onreadystatechange = function(){
        if(xhr.readyState == 4){
            if(xhr.status == 200){
                success(xhr.responseText);
            } else {
                if(failed){
                    failed(xhr.status);
                }
            }
        }
    }
}

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

这时候我们就可以编写一个适配器函数AjaxAdapter ,并用适配器去承接旧接口的参数,无缝衔接!

// 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)
}

6.装饰器模式

  • ES 和 TS 的 Decorator 语法就是装饰器模式。可以为 class 和 method 增加新的功能
  • 以下代码可以在 ts playground 中运行
// class 装饰器
function logDec(target) {
    target.flag = true
}

@logDec
class Log {
    // ...
}

console.log(Log.flag) // true
// method 装饰器
// 每次 buy 都要发送统计日志,可以抽离到一个 decorator 中
function log(target, name, descriptor) {
    // console.log(descriptor.value) // buy 函数
    const oldValue = descriptor.value // 暂存 buy 函数

    // “装饰” buy 函数
    descriptor.value = function(param) {
        console.log(`Calling ${name} with`, param) // 打印日志
        return oldValue.call(this, param) // 执行原来的 buy 函数
    };

    return descriptor
}
class Seller {
    @log
    public buy(num) {
        console.log('do buy', num)
    }
}

const s = new Seller()
s.buy(100)

ngular nest.js 都已广泛使用装饰器。这种编程模式叫做AOP 面向切面编程:关注业务逻辑,抽离工具功能

import { Controller, Get, Post } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Post()
  create(): string {
    return 'This action adds a new cat';
  }

  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

7.原型模式

  • 原型模式不仅是一种设计模式,它还是一种编程范式
  • 在 JavaScript 里,Object.create方法就是原型模式的天然实现,得到相当于继承的对象
  • 在 JavaScript 中,我们使用原型模式,是为了得到与构造函数(类)相对应的类型的实例、实现数据/方法的共享

什么是原型和原型链,什么是深浅拷贝,如何实现,我以前的文章均有涉及,此处就不啰嗦了

8.策略模式

  • 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换

比如此时要做一个差异化询价,即同一个商品,我通过在后台给它设置不同的价格类型,可以让它展示不同的价格

  • 当价格类型为“预售价”时,满 100 - 20,不满 100 打 9 折
  • 当价格类型为“大促价”时,满 100 - 30,不满 100 打 8 折
  • 当价格类型为“返场价”时,满 200 - 50,不叠加
  • 当价格类型为“尝鲜价”时,直接打 5 折

到这里就很容易去写出 if else 的逻辑去操作价格,这样会随着时间越难越维护,如果使用策略模式的话

抽取四种询价逻辑:

  • prePrice - 处理预热价
  • onSalePrice - 处理大促价
  • backPrice - 处理返场价
  • freshPrice - 处理尝鲜价
  • askPrice - 分发询价逻辑

此时就只需要询价逻辑的分发 ——> 询价逻辑的执行

// 处理预热价
function prePrice(originPrice) {
  if(originPrice >= 100) {
    return originPrice - 20
  } 
  return originPrice * 0.9
}

// 处理大促价
function onSalePrice(originPrice) {
  if(originPrice >= 100) {
    return originPrice - 30
  } 
  return originPrice * 0.8
}

// 处理返场价
function backPrice(originPrice) {
  if(originPrice >= 200) {
    return originPrice - 50
  }
  return originPrice
}

// 处理尝鲜价
function freshPrice(originPrice) {
  return originPrice * 0.5
}

function askPrice(tag, originPrice) {
  // 处理预热价
  if(tag === 'pre') {
    return prePrice(originPrice)
  }
  // 处理大促价
  if(tag === 'onSale') {
    return onSalePrice(originPrice)
  }

  // 处理返场价
  if(tag === 'back') {
    return backPrice(originPrice)
  }

  // 处理尝鲜价
  if(tag === 'fresh') {
     return freshPrice(originPrice)
  }
}

但是我们可以使用更为优雅的对象映射来提高可维护性

// 定义一个询价处理器对象
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 新增一个映射关系:

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

9.状态模式

  • 状态模式和策略模式长得像并且解决的问题也没有本质去呗

此时要进行咖啡机的代码逻辑编写

  • 美式咖啡态(american):只吐黑咖啡
  • 普通拿铁态(latte):黑咖啡加点奶
  • 香草拿铁态(vanillaLatte):黑咖啡加点奶再加香草糖浆
  • 摩卡咖啡态(mocha):黑咖啡加点奶再加点巧克力

这时候又很可能写出 if else 逻辑,但是当我们使用策略模式:

const stateToProcessor = {
  american() {
    console.log('我只吐黑咖啡');    
  },
  latte() {
    this.american();
    console.log('加点奶');  
  },
  vanillaLatte() {
    this.latte();
    console.log('再加香草糖浆');
  },
  mocha() {
    this.latte();
    console.log('再加巧克力');
  }
}

class CoffeeMaker {
  constructor() {
    /**
    这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
  **/
    // 初始化状态,没有切换任何咖啡模式
    this.state = 'init';
  }
  
  // 关注咖啡机状态切换函数
  changeState(state) {
    // 记录当前状态
    this.state = state;
    // 若状态不存在,则返回
    if(!stateToProcessor[state]) {
      return ;
    }
    stateToProcessor[state]();
  }
}

const mk = new CoffeeMaker();
mk.changeState('latte'); // 我只吐黑咖啡 加点奶

上面也用到了对象映射的方法,但是不同于策略模式的简答询价,changeState函数有时需要拿到咖啡机这个主体的信息,咖啡机和它的状态处理函数建立关联

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

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');
// 咖啡机现在的牛奶存储量是: 500ml
// 我只吐黑咖啡
// 加点奶

10.观察者模式和发布订阅模式

观察者模式

  • 即常说的绑定事件。一个主题,一个观察者,主题变化之后触发观察者执行
// 一个主题,一个观察者,主题变化之后触发观察者执行
btn.addEventListener('click', () => { ... })

发布订阅模式

即常说的自定义事件,一个 event 对象,可以绑定事件,可以触发事件

// 绑定
event.on("event-key", () => {
  // 事件1
});
event.on("event-key", () => {
  // 事件2
});

// 触发执行
event.emit("event-key");

// 解绑事件,一般组件销毁前进行
event.off("event-key", fn1);
event.off("event-key", fn2);

两者区别

观察者模式

  • Subject 和 Observer 直接绑定,中间无媒介
  • addEventListener 绑定事件

发布订阅模式

  • Publisher 和 Observer 相互不认识,中间有媒介
  • eventBus 自定义事件

11.MVC 和 MVVM

MVC 原理

  • View 传送指令到 Controller
  • Controller 完成业务逻辑后,要求 Model 改变状态
  • Model 将新的数据发送到 View,用户得到反馈

MVVM 直接对标 Vue 即可

  • View 即 Vue template
  • Model 即 Vue data
  • VM 即 Vue 其他核心功能,负责 View 和 Model 通讯