读书整理 - 理解JavaScript设计模式(一)

374 阅读14分钟

最近在翻阅国内两位大佬撰写的《JavaScript设计模式》《JavaScript设计模式与开发实践》, 受益匪浅。 这篇文章, 就将通过这两本书介绍的加上自己的理解, 整合起来.也算是当成学习的笔记, 如果有错误的地方欢迎各位小伙伴指正。

此系列计划写三篇,共计 36 个设计模式。

理解

设计模式其实与我们的工作息息相关,可能随意写的代码封装就涉及到设计模式,就像鸣人一样有时候会放出九尾的力量但不会自由使用,希望读完这篇后可以对常用的一些设计模式有个概念的理解,为了赢取白富美当上村长而努力吧,少年!!

这里不会赘述基本的概念, 不理解原型, 闭包, 继承等基础概念的希望能先把基础打牢再学习

设计模式的主题是把不变的事物和变化的分离开来, 但切记生搬硬套, 导致代码反而更加复杂, 得不偿失。

工厂模式

工厂模式:创建一个对象,并且暴露, 里面添加逻辑用来满足多种实例操作

举例: 老板要招人, 分别是 php, java, ios, 要发布不一样的JD, 现在我要用工厂模式写一个招聘对象, 暴露出来, 可以实现所有职位的操作。

Code:

function JoBFactory(type, content) {
    // 安全模式类, 下面有介绍
    if (this instanceof JoBFactory) {
        return new this[type](content)
    }
    else {
        return new JoBFactory(type, content)
    }
}
// 把需要的类加入到原型里, 这样只会暴露一个JoBFactory, 集成了所有分类
JoBFactory.prototype = {
    PHP: function (content) {
        this.content = content
        ...
    },
    Java: function () {
        ...
    }
    ...
}

解析: 把所有的分类实例加入到一个对象的原型里,要使用的时候,只需取一个 JoBFactory操作即可

安全模式类:为了防止忽略new 字符, 做了判断, 调用时只需要 JoBFactory() 或者 new JoBFactory() 都可以返回实例。如JoBFactory('PHP', '世界上最好的语言')

工厂模式不仅仅用来new出实例,它的作用就是隐藏了实例的复杂度,只需要提供一个接口,简单明了。这在工作中是非常常见的。

建造者模式

  • 工厂模式:不管你想干啥,只给你一个你想要的的对象
  • 建造者模式:主要针对复杂业务的解耦,算是工厂的一种拆解拼接。我可以将你的需求分解多个对象创建,更关心的是创建对象的过程。

举例: 我们公司是卖车的,用户下单要买车,这个车呢:

品牌:迈巴赫、林肯、宾利、特斯拉[如果不选品牌,默认特斯拉] 
颜色:赤橙黄绿青蓝紫...[如果不选颜色,默认黄色]
动力:燃油、电力、混合动力[如果不选动力,默认电力]

购买人的一些基本信息[包括姓名电话, 而且可以修改]
针对购买人选择的车型返回对车型的简单描述[可以修改]
...

最后会生成一个订单, 内容包括购买人跟想买车的所有信息, 如果使用工厂模式,是不是会产生很多个工厂方法,而且不能做到灵活的运用及复用。这时候建造者模式就出来了,我们可以把, 购买人, 反馈结果 拆离, 最后拼接在一起。

Code:

// 创建一个车
var Car = function (params) {
    this.color = params && params.color || 'yellow'; // 颜色
    this.brand = params && params.brand || 'Tesla'; // 品牌
    this.power = params && params.power || 'electric'; // 动力
}
// 提供原型方法
Car.prototype = {
    getColor : function () {
        return this.color;
    },
    getBrand : function () {
        return this.brand;
    },
    getPower : function () {
        return this.power;
    }
}

// 创建购买人
var Client = function (name, phone) {
    this.name = name
    this.phone = phone || '110'
}

Client.prototype.changePhone = function (phone) {
    this.phone = phone;
}
 
// 创建反馈
var FeedBack = function(brand){
    var that = this;
    (function(brand,that){
        switch (brand){
            case 'Tesla':
                // that.brand = brand;
                that.information = '特斯拉是好车'
                break
            case 'Rolls' :
                that.information = '劳斯来时是好车'
        }
    })(brand,that)
}

// 这里进行拼接
var CarOrder = function (name) {
    var object = new Car();
    object.client  = new Client(name);
    object.feedBack = new FeedBack(object.brand);
    return object;
}

var orderCar = new CarOrder('xiatian');

console.log(orderCar.color) // yellow
console.log(orderCar.client.name) // xiatian

orderCar.client.changePhone('119')
console.log(orderCar.client.phone) // 119

原型模式

原型模式: 使用原型继承对象,创建新的对象,实现方法和属性的共享及扩展

举例: 写个轮播组件,支持上下滑动, 渐隐滑动切换。 我们可以发现,有很多属性方法都是可以共用的,包括图片数组, 图片容器,切换方法, 创建方法... 所以这时候我们就需要继承来实现, 对属性共享, 并且重写切换方法

Code:

// 图片轮播类
var LoopImages = function (imgArr, container) {
    this.imgArr = imgArr;
    this.container = container;
}
// 将耗时操作放入原型中
LoopImages.prototype = {
    // 创建轮播图片
    createImage: function () {
        console.log('LoopImage create')
    },
    // 切换图片
    changeImage: function () {
        console.log('LoopImage change')
    }
}

// 上下滑动切换类
var SlideLoopImages = function(imgArr, container) {
    // 使用call的特性,继承基类的构造函数, 相当于 ES6 中的 super(params)
    // 注意, 这里无法继承基类的原型方法
    LoopImages.call(this, imgArr, container)
}

// 类式继承, 与构造函数一起就叫组合继承
// ps: 组合继承的缺点就是调用了两次父类构造函数,并且会有多余的数据在原型里 (imgArr, container)
SlideLoopImages.prototype = new LoopImages()

SlideLoopImages.prototype.changeImage = function() {
    console.log('SlideLoopImage change')
}


// 渐隐切换类
var FadeLoopImages = function(imgArr, container) {
    LoopImages.call(this, imgArr, container)
}

FadeLoopImages.prototype = new LoopImages()

FadeLoopImages.prototype.changeImage = function() {
    console.log('FadeLoopImage change')
}

// 拓展原型方法
FadeLoopImages.prototype.getImageLength = function() {
   return this.imgArr.length
}


// 测试
var slide = new SlideLoopImages([1, 2, 3], 'slide')
console.log(slide.container) // slide

var fade = new FadeLoopImages([1, 2, 3], 'fade')
fade.getImageLength() // 3

单例模式

单例模式: 保证一个类仅有一个实例, 而且可以全局访问。

通常的单例模式实现:

const singleton = function(name) {
  this.name = name
  this.instance = null
}

singleton.prototype.getName = function() {
  console.log(this.name)
}

singleton.getInstance = function(name) {
  if (!this.instance) { // 关键语句
    this.instance = new singleton(name)
  }
  return this.instance
}

// test
const a = singleton.getInstance('a') // 通过 getInstance 来获取实例
const b = singleton.getInstance('b')
console.log(a === b)

应用场景:JQuery中的$、Vuex中的Store、Redux中的Store等

JavaScript 是无类的语言, 而且 JS 中的全局对象符合单例模式两个条件。很多时候我们把全局对象当成单例模式来使用, 所以这样其实就很简单

单例模式既可以管理命名空间也可以实现模块的区分

举例:我们需要编写一个代码库A, 其中还分很多的模块, 模块中也有许多方法

Code:

var A = {
    Util: {
        util_method1: function () {}
        util_method1: function () {}
    },
    Tool: {
        tool_method1: function () {}
        tool_method1: function () {}
    }
    ....
}

// 测试
A.Util.util_method1();
A.Tool.tool_method1();

外观模式

外观模式: 大白话就是把很多复杂判断的东西整合在一起, 暴露一个统一的方法或者函数,最常用于兼容问题

举例: 每个前端都头疼的IE兼容问题,创建DOM事件监听, IE9以下需要用attachEvent, 不可能每个监听都写一遍if判断吧

Code:

function addEvent(dom, type, fn) {
    if (dom.addEventListener) {
        dom.addEventListener(type, fn, false);
    } else if (dom.attachEvent) {
        dom.attachEvent('on', type, fn)
    } else {
        // 唯独支持 on + '事件名' 的浏览器
        dom['on' + type] = fn
    }
}

// 测试
var input = document.getElementById('myinput');
addEvent(input, 'click', function () {
    console.log('绑定事件')
})

ps: 《JavaScript设计模式》书中又提到外观模式可以封装多个功能,统一暴露出来, 其实我觉得那个例子跟单例模式没啥区别,就不再赘述。

适配器模式

适配器模式: 大白话就是送 给你个转接头,能让你插遍世界上所有的插座。比如Vuecomputed

举例: 前端工作开发中最常见的就是封装了ajax请求方法,可是突然来了个新后端,由于比较菜,返回的数据格式发生了变化,难道我们还要在原来封装的方法里再修改嘛?那要再来一个新的后端呢?(前端可以让步,但绝对不能当舔狗!!先怼回去啊!... 然后再改... )

Code:

// 原来的结构 { name: 'me', type: 'boy', title: 'bug' }
// 现在的结构 [ 'me', 'boy', 'bug'  ]

// 适配器
function ajaxAdapter(data) {
    return {
        name: data[0],
        type: data[1],
        title: data[2]
    }
}

$.ajax({
    url: 'xxx.php',
    success: function (data, success) {
        if (data) {
            doSomething(ajaxAdapter(data))
        }
    }
})

代理模式

代理模式: 干啥子中间都要插一个中间商,赚差价, 也类似明星的经纪人,他跟商家谈价钱,一切 ok 后再给个合同给明星就行了

代理模式在日常开发中有很多应用场景, 它有很多小分类, 在JavaScript中最常用的是虚拟代理和缓存代理,而且在是否需要代理模式前要先确定是否真的不方便访问某个对象,不能硬来,不然也只是徒增代码复杂度而已

虚拟代理

虚拟代理: 把一些需要开销很多大的对象,延迟到真正需要它的时候才去创建。 比如: 图片预加载。

举例: 图片预加载,未加载前先用loading占位, 然后异步的方式加载图片,加载完毕后填充到节点里面

Code:

// 图片对象
var MyImage = (function (){
   var imgNode = document.createElement('img')
   document.body.appendChild(imgNode)
   
   return {
       setSrc: function (src) {
           imgNode.src = src
       }
   }
})()

// 代理对象,加入 loading 占位, onload 之后用图片填充
var proxyImage = (function () {
   var img = new Image;
   img.onload = function () {
       myImage.setSrc(this.src)
   }
    return {
      setSrc: function(src) {
        myImage.setSrc('loading.jpg') // 本地 loading 图片
        img.src = src
      }
    }
})()

很多小伙伴会说这样的为何不整在一起,其实书中也说了面向对象的设计的原则--单一职责原则,如果职责过多,内部发生变化,就会被破坏。比如这个例子, 如果在未来如果不需要预加载, 只要改成请求本体代替请求代理对象就行。

缓存代理

缓存代理:可以对开销大的运算结果停供暂时的缓存,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果,比如 React 优化中提到的记忆化技术( memoize-one )

举例: 计算乘积

Code:

// 求乘积
var mult = function() {
    var a = 1;
    for (var i = 0, l = arguments.length; i < l; i++) {
        a = a * arguments[i]
    }
    return a
}

// 缓存代理函数
var proxyMult = (function () {
   // 闭包特性, cache 一直存在
   var cache = {}
   return function () {
       var args = Array.prototype.join.call(arguments, ',')
       
       // 缓存里存在 key 为 args, 返回之前存储的 value 值 
       if (args in cache) {
           return cache[args];
       }
       // 否则重新计算并且存储参数和结果
       return cache[args] = mult.apply(this, arguments)
   }
})()

装饰者模式

装饰者模式:在不改变原对象的基础上,给对象进行包装, 添加新的功能。用来满足更加复杂的需求

举例: 我们使用ES7的装饰器语法实现对象字段的只读功能

Code:

function readonly(target, key, descriptor) {
    descriptor.writable = false
    return descriptor
}

class Test {
    @readonly
    name: 'xiatian'
}

let test = new Test()
t.name = '111' // 不可修改

桥接模式

桥接模式:将抽象部分与实现部分分离开来,使两者都可以独立的变化,并且可以一起和谐地工作。抽象部分和实现部分都可以独立的变化而不会互相影响,降低了代码的耦合性,提高了代码的扩展性。

其实桥接模式在我们的工作代码里随处可见,说的通俗点就是拆分代码,使得代码可以复用和扩展。而且拆分的代码之间有桥梁可以互相连通

举例:实现弹窗组件:普通消息提醒,错误提醒。每一种提醒的展示方式还都不一样。这是一个典型的多维度变化的场景。

Code:

// 一. 创建弹窗类
// 这两个类就是前面提到的抽象部分,也就是扩充抽象类,它们都包含一个成员animation。
function MessageDialog(animation) {
    this.animation = animation;
}
MessageDialog.prototype.show = function () {
    this.animation.show();
}
function ErrorDialog(animation) {
    this.animation = animation;
}
ErrorDialog.prototype.show = function () {
    this.animation.show();
}

// 二. 创建动画类
// 两种弹窗通过show方法进行显示,但是显示的动画效果不同。我们定义两种显示的效果类如下:
function LinerAnimation() {
}
LinerAnimation.prototype.show = function () {
    console.log("it is liner");
}
function EaseAnimation() {
}
EaseAnimation.prototype.show = function () {
    console.log("it is ease");
}
<! -- 上面两个类都是抽象类 -->

// 三. 具体实现
// MessageDialog 与 LinerAnimation 链接在了一起,一起工作,互不影响
var message = new MessageDialog(new LinerAnimation());
message.show();
var error = new ErrorDialog(new EaseAnimation());
error.show();

命令模式

命令模式:将执行的命令封装,解决命令发起者与命令执行者之间的耦合,每一条命令实质上是一个操作。命令的是使用者不必了解命令执行者的命令接口是如何实现的,只需要知道如何调用。

个人理解其实就像是JQuery插件的用法,只需执行某个命令,传入特定参数就可以, 无需关心命令执行者是谁,做了什么操作

举例: 大项目分工,菜单模块,一部分人画页面,一部分人做按钮实现逻辑。使用命令模式拆分, 绑定点击事件时,只需要知道命令接口是什么,不关心内部实现逻辑

// 智能命令,不存在接收者
var MenuBar = {
    refresh: function () {
        console.log('刷新菜单界面')
    }
}

var SubMenu = {
    add: function () {
        console.log('新增子菜单')
    },
    del: function () {
        console.log('删除子菜单')
    }
}

// 绑定事件
var bindClick = function (button, func) {
    button.onclick = func
}

bindClick(button1, MenuBar.refresh)
bindClick(button2, SubMenu.add)
bindClick(button3, SubMenu.del)

可能会有人说这不是命令模式,其实对于JavaScript而言,它是将函数作为一等对象的语言,命令模式早就融入到了JavaScript语言里, 运算块可以直接封装到函数里, 四处传递。

传统的命令模式在JavaScript 中实现很没有必要,徒增代码复杂度, 有兴趣的小伙伴可以去翻阅《JavaScript设计模式与开发实践》

组合模式

组合模式:又称为"部分-整体"模式,将对象组合成树形结构以表示“部分整体”的层次结构(树型结构)。组合模式是的用户对单个对象和组合对象的使用具有一致性。

命令模式中有一个概念叫做宏命令, 它的意思就是一组命令之包含许多子命令,内部可以互相组合,互不影响。 而它们都有统一的execute执行接口。其实这跟组合模式有着异曲同工之妙。

举例:想象我们现在手上有个万能遥控器, 当我们回家, 按一下开关, 下列事情将被执行:

  1. 打开空调
  2. 打开电视和音响
  3. 关门、打开电脑、打开QQ

Code:


// 宏命令, list 存储所有子命令, 统一的 execute 执行接口
const MacroCommand = function() {
  return {
    lists: [],
    add: function(task) {
      this.lists.push(task)
    },
    excute: function() { // ①: 组合对象调用这里的 excute,
      for (let i = 0; i < this.lists.length; i++) {
        this.lists[i].excute()
      }
    },
  }
}

var openAcCommond = {
    excute: function () {
        console.log('打开空调')
    }
}

// 电视跟音响是连在一起的,可以用一个宏命令包裹
var openTvCommond = {
    excute: function () {
        console.log('打开电视')
    }
}
var openSoundCommond = {
    excute: function () {
        console.log('打开音响')
    }
}

var maroCommond1 = MacroCommand()
maroCommond1.add(openTvCommond)
maroCommond1.add(openSoundCommond)

//关门、打开电脑、打开QQ 也包裹成一个宏命令、
var closeDoorCommond = {
    excute: function () {
        console.log('关门')
    }
}
var openPcCommond = {
    excute: function () {
        console.log('打开电脑')
    }
}
var openQQCommond = {
    excute: function () {
        console.log('打开QQ')
    }
}

var maroCommond2 = MacroCommand()
maroCommond2.add(closeDoorCommond)
maroCommond2.add(openPcCommond)
maroCommond2.add(openQQCommond)

// 把所有的命令整合到一起
var maroCommond = MacroCommand()
maroCommond.add(openAcCommond)
maroCommond.add(maroCommond1)
maroCommond.add(maroCommond2)

// 给遥控器绑定单机事件, 可以操作所有的命令
document.getElementById('button').onclick = function() {
    maroCommond.execute()
}

// test
// 打开空调
// 打开电视
// 打开音响   
// 关门
// 打开电脑
// 打开QQ

个人理解, 组合模式的重点就是对于组合对象的操作可以实现对所有子对象的覆盖,两者操作的一致性。在业务工作中, 事件委派其实也是组合模式的一种理念体现。(ps: 这边有大佬说是应该理解为是代理模式的体现,子元素把事件代理到了父元素上面)

接触过 antd 的小伙伴对于里面的 Form 表单 应该很熟悉, 它把 Form 作为组合对象,Form.Item, Input 等创建的其实就是叶子对象。从而形成了一种层次结构,最后也通过组合对象的 form 可以进行全部表单操作。(个人理解, 不对勿喷)

享元模式

享元模式:一种性能优化方案,使用共享技术拆分内外部对象支持大量细颗粒的对象。大白话就是减少对象创建的个数,提取共用的属性为内部对象,变化的拆离成外部对象

举例:某商家有 50 种男款内衣和 50 种款女款内衣, 需要模特来展示它们。

方案一:各新建 50 个男女模特,穿上不同的衣服展示。这显然是有性能问题的 Code:

const Model = function(gender, underwear) {
  this.gender = gender
  this.underwear = underwear
}

Model.prototype.takephoto = function() {
  console.log(`${this.gender}穿着${this.underwear}`)
}

for (let i = 1; i < 51; i++) {
  const maleModel = new Model('male', `第${i}款衣服`)
  maleModel.takephoto()
}

for (let i = 1; i < 51; i++) {
  const female = new Model('female', `第${i}款衣服`)
  female.takephoto()
}

他们的性别属性是可以共享的,只是穿着的衣服是改变的,所以我们只需要创建两个模特,让他们接连换上50种衣服就可以

方案二:创建 1 个男模特 1 个女模特, 分别试穿 50 款内衣

const Model = function(gender) {
  this.gender = gender
}

Model.prototype.takephoto = function() {
  console.log(`${this.sex}穿着${this.underwear}`)
}

const maleModel = new Model('male')
const femaleModel = new Model('female')

for (let i = 1; i < 51; i++) {
  maleModel.underwear = `第${i}款衣服`
  maleModel.takephoto()
}

for (let i = 1; i < 51; i++) {
  femaleModel.underwear = `第${i}款衣服`
  femaleModel.takephoto()
}

但是这样也存在问题

  1. 通过构造函数 new 两个模特,但是在其他的系统里不一定一开始就需要创建所有的对象
  2. 给模特手动设置 underwear, 在复杂的系统里并不合适,往往更加复杂

方案三:

const Model = function(gender) {
  this.gender = gender
}

Model.prototype.takephoto = function() {
  console.log(`${this.gender}穿着${this.underwear}`)
}

// 优化第一点
// 创建工厂对象, 在需要的时候执行 createModel, 如果已存在则不再创建 model
const modelFactory = (function() {
  // 闭包存储 modelGender
  const modelGender = {}
  return {
    createModel: function(gender) {
      if (modelGender[gender]) {
        return modelGender[gender]
      }
      return modelGender[gender] = new Model(gender)
    }
  }
}())

// add 方法创建 model, 存储 underwear 操作
// copy 设置 underwear
const modelManager = (function() {
  // modelObj 用来保存所有外部状态
  const modelObj  = {}
  return {
    add: function(gender, i) {
      modelObj[i] = {
        underwear: `第${i}款衣服`
      }
      return modelFactory.createModel(gender)
    },
    copy: function(model, i) { // 优化第二点
      model.underwear = modelObj[i].underwear
    }
  }
}())

for (let i = 1; i < 51; i++) {
  const maleModel = modelManager.add('male', i)
  modelManager.copy(maleModel, i)
  maleModel.takephoto()
}

for (let i = 1; i < 51; i++) {
  const femaleModel = modelManager.add('female', i)
  modelManager.copy(femaleModel, i)
  femaleModel.takephoto()
}

ps: 大部分场景不需要如此,反而增加了开销

享元模式在工作中也是很常用的,比如在画表格组件的时候,都会定义很多对象: 数据对象,分页对象,行列对象,查询对象。。如果一个页面有很多表格,又有很多表格页面的时候就难免对定义很多相似对象,这时候就可以使用分享模式拆分出通用的属性,比如分页的每页行数,当前页数、页数切换方法、查询的条件属性、重置按钮方法,相似的都是可以共享的。

阶段总结

个人觉得,还是那句话, 学设计模式是为了更好地学习编程理念,能够更好地实现代码的拆分,提高代码质量, 完全不是为了生搬硬套, 反而增加代码复杂度。只要时刻记住设计模式的主题,即使有时候不用任何模式,代码也可以写的很漂亮。死学概念的人终究被概念要求所束缚