深入不出 — javaScript设计模式(三):行为型设计模式

1,038 阅读15分钟

写在前面

生活中的很多事做多了熟练以后经常能结出一些套路,使用套路能让事情的处理变得容易起来。而设计模式就是写代码的套路,好比剑客的剑谱,说白了就是一个个代码的模板。

就算从没看过设计模式的人工作中肯定多多少少也接触或不经意间就使用过某种设计模式。因为代码的原理都是一样的,实现某个需求的时候可能自然而然就以最佳的模式去实现了。

这些模式已经有前人做了整理总结,不仅可以直接拿来使用,在阅读开源库源码时也会发现这些库都大量的使用了设计模式。

系列文章

深入不出 — javaScript设计模式(一):创建型设计模式
深入不出 — javaScript设计模式(二):结构型设计模式
深入不出 — javaScript设计模式(三):行为型设计模式
深入不出 — javaScript设计模式(四):技巧型设计模式
深入不出 — javaScript设计模式(五):架构型设计模式

本系列内容主要来自于对张容铭所著《JavaScript设计模式》一书的理解与总结(共5篇),由于文中有我自己的代码实现,并使用了部分新语法,抛弃了一些我认为繁琐的内容,甚至还有对书中 "错误" 代码的修正。所以如果发现我理解有误,代码写错的地方麻烦务必指出!非常感谢!

前置知识

掌握javaScript基础语法,并对js原理(特别是原型链)有较深理解。

行为型设计模式

行为型设计模式用于不同对象之间职责划分或算法抽象,行为型设计模式不仅仅设计类和对象,还涉及类或对象之间的交流模式并加以实现。

一、 模板方法模式

父类中定义一组操作算法骨架,而将一些实现步骤延迟到子类中,使得子类可以不改变父类的算法结构的同时可重新定义算法中某些实现步骤。

第一层含义,定义操作算法骨架:比如一个弹窗类,无论弹窗长什么模样,有什么动效,打开过程的细节如何,这些类都有文案(text),都实现init(创建弹窗)和open(显示弹窗)方法的这个骨架是一样的。

function SuperAlert(text1, text2) {
  this.text1 = text1
  this.text2 = text2
  this.fn1 = function () {
    console.log('fn1')
  }
  this.fn2 = function () {
    console.log('fn2')
  }
  this.fn3 = function () {
    console.log('fn3')
  }
}
SuperAlert.prototype = {
  init() {
    console.log(this.text1)
    console.log(this.text2)
  },

  open() {
    this.fn1()
    this.fn2()
    this.fn3()
  }
}

const superIntance = new SuperAlert('111', '222')
superIntance.init() // 111 222
superIntance.open() // fn1 fn2 fn3

第二层含义,将一些实现步骤延迟到子类中,子类不改变算法结构(子类仍然是弹窗,任然有init和open)的同时,可以重新定义其中的某些步骤(open中的过程不一样)。

// 子类
function subAlert(text1, text2, text3) {
  // 继承父类基础构造
  SuperAlert.call(this, text1, text2)
  // 设置子类自有属性
  this.text3 = text3
}
// 继承父类方法
subAlert.prototype = new SuperAlert()
// 拓展init方法
subAlert.prototype.init = function () {
  // 自有的步骤
  console.log(this.text3)
  // 继承原有的Init方法
  SuperAlert.prototype.init.call(this)
}

const subInstance = new subAlert('111', '222', '333')
subInstance.init() // 333 111 222
subInstance.open() // fn1 fn2 fn3

二、 观察者模式

又称作发布-订阅者模式或消息机制,定义了一种依赖关系,解决了主题对象与观察者之间功能的耦合。

观察者模式的应用可以说非常广泛,书上说观察者模式与发布订阅模式相同,我感觉两者间还是有一些差别,但是核心理念是一样的。

我理解的核心理念用一句话通俗的解释就是:‘我’先定义好了代码,但是什么时候执行由‘你’控制(通知)。比如说:

  • dom上的点击事件绑定的函数,不是立即执行的,当发生点击后通知我执行。
  • promise中的then,当resolve了才执行。
  • vue中的数据监听,数据每次发生变化的时候执行。

大部分设置回调函数的地方,都用了这种发布-订阅的模式理念。而观察者模式和发布订阅模式的区别在于是否有‘中间人’。

观察者

观察者模式中观察者与被观察对象存在耦合关系,观察者(Observer)直接订阅(Subscribe)主题(Subject),而当主题被激活的时候,会触发观察者里的事件,没有中间人,用代码实现类似这样:

// 主题类
function Subject() {
  this.state = 0
  this.observes = [] // 观察者数组
}
Subject.prototype = {
  getState() {
    return this.state
  },

  setState() {
    this.state = this.state + 1
    // state变化时通知所有观察者
    this.noticeAllObserves()
  },

  noticeAllObserves() {
    // 触发所有观察者的事件
    this.observes.forEach(observe => {
      observe.update()
    })
  },

  addObserve(observe) {
    this.observes.push(observe)
  }
}

// 观察者类
function Observe(name, subject) {
  this.name = name
  this.subject = subject
  // 把自己添加到主题的观察者数组
  this.subject.addObserve(this)
}
Observe.prototype = {
  update() {
    console.log(this.name, this.subject.getState())
  }
}
// 创建被观察的对象
const data1 = new Subject()

// 观察者1
const watcher1 = new Observe('watcher1', data1)
// 观察者2
const watcher2 = new Observe('watcher2', data1)

data1.setState() // watcher1 1; watcher2 1
data1.setState() // watcher1 2; watcher2 2
data1.setState() // watcher1 3; watcher2 3

可以看到在观察者模式中,观察者是知道 Subject 的,Subject 一直保持对观察者进行记录。
而发布订阅模式就像给他们添加了一个中间人,使得两者松散耦合,并不需要知道对方的存在。

发布订阅模式

发布订阅模式会建立一个调度中心,通信的双方都来中心订阅(on)或者发布(emit):

// 调度中心
let eventEmitter  = {
  list: {}, // 缓存列表

  // 订阅
  on(event, fn) {
    // 如果该事件没注册过则初始化该事件的队列
    if(!this.list[event]) {
      this.list[event] = []
    }
    this.list[event].push(fn)
  },

  // 发布
  emmit(event, ...arg) {
    // 如果该事件没注册过直接返回
    if(!this.list[event]) {
      return false
    }

    this.list[event].forEach(fn => {
      fn.apply(this, arg)
    })
  }
}
function user1(state) {
  console.log('用户1收到state变化:', state)
}
function user2(state) {
  console.log('用户2收到state变化:', state)
}

// 订阅事件
eventEmitter.on('stateChange', user1)
eventEmitter.on('stateChange', user2)


const subject = {
  state: 0,
  setState() {
    this.state = this.state + 1
    // 数据变化时发布
    eventEmitter.emmit('stateChange', this.state)
  }
}

subject.setState() // 用户1收到state变化: 1; 用户2收到state变化: 1

subject.setState() // 用户1收到state变化: 2; 用户2收到state变化: 2

可以看到订阅者把自己想订阅的事件注册到调度中心,当发布者发布该事件到调度中心,也就是该事件触发时,由调度中心统一调度订阅者注册到调度中心的处理代码。

三、 状态模式

当一个对象的内部状态发生改变时,会导致其行为的改变,这看起来像是改变了对象。

当业务中需要根据一个状态进行不同操作的时候,可能会写类似这样的多分支代码:

function fn(state) {
  if(state === 'state1') {
    // 状态1对应操作
  } else if(state === 'state2') {
    // 状态2对应操作
  }else if(state === 'state3') {
    // 状态3对应操作
  }
  // ...
}

如果还要对符合状态进行判断,那么开销是翻倍的:

function fn(firstState, secState) {
  if(firstState === 'state1') {
    // 状态1对应操作
  } else if(firstState === 'state2') {
    // 状态2对应操作
  }else if(firstState === 'state3') {
    // 状态3对应操作
  }else if(firstState === 'state4' && secState === 'stete1') {
    // 状态4对应操作
    // 状态1对应操作
  }
  // ...
}

使用状态模式可以减少代码中的判断语句,并且使每种判断的情况独立存在方便管理

function Action() {
  // 缓存当前状态
  let _currentState = {}

  // 状态与动作的映射
  const states = {
    state1() {
      console.log('状态1对应操作')
    },
    state2() {
      console.log('状态2对应操作')
    },
    state3() {
      console.log('状态3对应操作')
    }
  }

  // 修改状态方法
  function changeState(...args) {
    // 重置当前状态
    _currentState = {}

    if(args.length) {
      args.forEach(state => {
        _currentState[state] = true
      })
    }

    return this
  }

  // 执行方法
  function goes() {
    console.log('触发一次动作')
    for(let state in _currentState) {
      if(states[state]) {
        states[state]()
      }
    }
    return this
  }

  // 返回接口方法
  return {
    change: changeState,
    goes
  }
}
const action = new Action()

action
  .change('state1')
  .goes()
  .change('state3', 'state2')
  .goes()

  /* 
    触发一次动作
    状态1对应操作
    触发一次动作
    状态3对应操作
    状态2对应操作
  */

如此一来只要改变了状态就改变了对象的执行结果,可以对每一种情况独立管理,解决了条件分支之间的耦合情况。

四、 策略模式

将定义的一组算法封装起来,使其相互之间可以替换。封装的算法具有一定独立性,不会随客户端变化而变化。

比如促销有很多策略,七折、五折、满100反50等等,这些策略可以封装为一个策略模式方法。

// 策略模式
const priceStrategy = (function() {
  // 内部算法对象
  const strategy = {
    // 7折
    percent70(price) {
      return price * 0.7
    },

    // 5折
    percent50(price) {
      return price * 0.5
    },

    // 满100返30
    return30(price) {
      return Math.floor(price / 100) * 30
    },

    // 满100返50
    return50(price) {
      return Math.floor(price / 100) * 50
    }
  }

  // 暴露策略算法调用接口
  return function(algorithm, price) {
    return strategy[algorithm] && strategy[algorithm](price)
  }
})()
const price1 = priceStrategy('percent50', 1000)
const price2 = priceStrategy('return30', 1090)

console.log(price1) // 500
console.log(price2) // 300

与状态模式类似的都是在内部封装一个对象,通过返回的接口对内部对象调用,不同的是不需要状态的管理,不同策略之间是相互独立的算法。

策略模式最主要的特色是创建一系列的算法,每组算法处理的业务都是相同的,只是处理的过程或者处理的结果不一样。

对分支语句的优化的模式对比

工厂方法模式,状态模式,策略模式,都是对分支语句的优化处理,但侧重点不同:

  • 工厂方法模式是一种创建型模式,最终目的是创建对象。
  • 状态模式是行为性模式,其核心是对状态的控制来决定表现行为,状态之间通常是不能相互替代的,也就是不同状态事件做的可能不是同一件事。(比如状态1是去吃饭,状态2是去睡觉)
  • 策略模式也是行为模式,但其核心是对同一件的事的不同处理方式,处理的业务逻辑相同,同一种策略模式的最终产出结果是一定的。(比如促销策略每种算法最终返回的一定是价格结果;表单验证策略无论每种算法怎么验证,最终返回验证是否通过;做的都是同一件事)

五、 职责链模式

解决请求的发送者与请求的接受者之间的耦合,通过职责链上的多个对象分解请求流程,实现请求在多个对象之间的传递,直到最后一个对象完成请求的处理。

简单来说就是把一个复杂的长流程业务拆分成一步一步的独立过程,每一步就像是一道工序,只用保证自己份内工作的完成。

比如用户操作后要从后端请求数据,对数据进行处理后显示不同的组件,那么就可以创建这样一条职责链:

function getData () {
  // 请求数据...

  // 如果数据请求成功,进行下一步工序:处理数据
  fommartData(data)
}

function formatData(data) {
  // 处理数据...

  // 将处理完的数据交给下一步工序:显示组件
  showComponent(result)
}

function showComponent(result) {
  // 根据处理完的数据显示组件
  
  // 目标完成 
}

可以看到每个步骤中只需要关心自己的入参做完份内的工作后传递给下一个步骤,流程的拆解可以减少开发过程中受到的制约(比如某一步的需求还不确定),同时也能方便对每一步进行单元测试。

职责链模式定义了请求的传递方向,通过多个对象对请求的传递,实现一个复杂的逻辑操作。

六、 命令模式

将请求与实现解耦并封装成独立对象,从而使不同的请求对客户端的实现参数化。

核心理念就是不对目标对象直接操作,而是建立一个中间对象,中间对象中封装一系列的操作并暴露出一个执行接口,通过调用执行接口(传入一个个命令)操作目标对象,实现请求与实现的解耦。

说白了就是对一个的对象操作进行上一层封装,比如说一个富文本编辑器有很多操作:复制,粘贴,文本加粗,插入标题等,可以用命令模式做如下实现:

// 富文本命令模块
const editorCommand = (function () {
  // 方法合集
  const Action = {
    action1() {
      console.log('复制')
    },
    action2() {
      console.log('粘贴')
    },
    action3() {
      console.log('文本加粗')
    },
    action4() {
      console.log('插入标题')
    }
  }

  // 暴露执行接口
  return {
    excute(command) {
      Action[command]()
    }
  }
})()

editorCommand.excute('action2') // 粘贴
editorCommand.excute('action4') // 插入标题

如此一来使得发出请求与具体的实现进行了解耦,在需要操作的地方只发出命令而不影响如何实现。

七、 访问者模式

针对于对象结构中的元素,定义在不改变该对象的前提下访问结构元素的新方法。

我理解访问者模式的核心在于数据与数据操作之间的解耦,而这一点在js中最常见的的实现方式就是通过call/apply方法。

比如需要对类数组使用数组的方法,但并不改变类数组本身:

function fn() {
  const args = Array.prototype.slice.call(arguments, 1, 3)
  console.log(args)
}

fn(1, 2, 3, 4) // [2, 3]

将数据的操作独立于数据,或者说根据当前访问者的需求改变数据的操作方法,而不通过改变原数据实现操作方法的拓展。

八、 中介者模式

通过中介者对象封装一系列对象之间的交互,使对象之间不再相互引用,降低他们之间的耦合。有时通过中介者对象也可以改变对象之间的交互。

在观察者模式一章书中说观察者模式就是发布订阅模式,我查阅了一些资料后发现二者存在一定区别,于是在观察者模式那里中分别写了观察者模式发布订阅模式

但是看到中介者模式这里才发现原来作者将我所写的发布订阅模式称为中介者模式

发布订阅模式跟观察者模式最大的区别就是中间多了一个'中间人',建立了一个调度中心,所以称为中介者模式倒也贴切。

九、 备忘录模式

在不破坏对象的封装性的前提下,在对象之外捕获并保存该对象内部的状态以便日后对象使用或者对象恢复到以前的某个状态。

备忘录模式的核心思想就是缓存数据,降低获取数据的成本,比如常见的列表翻页,每次翻页都要去后端请求数据,可以使用备忘录模式实现如下:

// 分页数据备忘录
function Page() {
  // 缓存
  const cache = {}

  return function(page) {
    // 如果缓存过当页数据
    if(cache[page]) {
      // 直接在页面显示页码和数据
      showPage(page, cache[page])
    } else {
      // 如果没有缓存,从后端请求数据
      getData(page).then(res => {
        if(res.code === 200) {
          // 在页面显示页码和数据
          showPage(page, cache[page])

          // 缓存数据
          cache[page] = res.data
        }
      })
    }
  }
}

备忘录模式的最主要任务是对现有的数据或状态做缓存,为将来某个时刻做准备。这一点在javaScript中常常运用于需要前后端交互获取数据的场景,以降低请求过程中时间和流量的开销。

缺点是当数据量过大时,会严重占用系统提供的资源,降低系统性能。

十、 迭代器模式

在不暴露对象内部结构的同时,可以顺序地访问聚合对象内部的元素。

所谓迭代器,首先使用对象肯定是是一组顺序的聚合数据,比如数组,类数组,字符串,nodeList等。而迭代器的作用就是可以进行:顺序的访问他们内部的每一个元素(遍历),查找下一个元素(next),是否还有下一个元素(hasNext)等操作。

实现一个简易的数组,字符串迭代器
// 容器类
function Container(data) {
  this.data = data
}
Container.prototype = {
 // 生成迭代器
  getItrator() {
    return new Itrator(this)
  }
}

// 迭代器类
function Itrator(container) {
  this.data = container.data
  this.index = 0
}

Itrator.prototype = {
  // 获取下一个元素
  next() {
    if(this.hasNext()) {
      return this.data[this.index++]
    } else {
      return null
    }
  },
  
  // 获取是否还有下一个元素
  hasNext() {
    if(this.index > this.data.length - 1) {
      return false
    }
    return true
  }
}
const strItrator = new Container('abc').getItrator()
console.log(strItrator.next())  // a
console.log(strItrator.hasNext())  // true
console.log(strItrator.next())  // b
console.log(strItrator.next())  // c
console.log(strItrator.hasNext())  // false 

const arrItrator = new Container([1,2,3]).getItrator()
console.log(arrItrator.next())  // 1
console.log(arrItrator.hasNext())  // true 
console.log(arrItrator.next()) // 2
console.log(arrItrator.next())  // 3
console.log(arrItrator.hasNext()) // false

es6 Iterator

es6的Iterator一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作。

在es6原生实现了兼容多种数据结构的迭代器,具体包含:

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象
let arr = ['a', 'b', 'c'];
let iter = arr[Symbol.iterator]();

iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }
const arr = ['red', 'green', 'blue'];

for(let v of arr) {
  console.log(v); // red green blue
}

let arr = ['a', 'b', 'c'];
for (let pair of arr.entries()) {
  console.log(pair);
}
// [0, 'a']
// [1, 'b']
// [2, 'c']

迭代器模式使得可以顺序的访问聚合对象的每一个元素,极大的简化了代码中的循环语句,这些简化实际上将循环隐藏到了迭代器中。解决了对象的使用者与对象内部结构直之间的耦合,为操作对象提供了统一的接口。

十一、 解释器模式

对于一种语言,给出其文法表示形式,并定义一种解释器,通过使用这种解释器来解释语言中定义的句子。

我理解的解释器模式的就是根据一种给定的语法格式,编译成另一种需要的语法格式。

比如将抽象语法树编译成dom,或者将es6语法编异成es5等。vue中允许用户通过模板语法(template)编写组件,并通过compiler模块进行编译,也是一种解释器模式。