深入不出 — javaScript设计模式(四):技巧型设计模式

450 阅读5分钟

写在前面

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

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

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

系列文章

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

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

前置知识

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

技巧型设计模式

技巧型设计模式是通过一些特定技巧来解决组件的某些方面的问题,这类技巧一般通过实践经验总结得到。

一、 链模式

通过在对象方法中将当前对象返回,实现对同一个对象多个方法的链式调用。从而简化对该对象的多个方法的多次调用时,对该对象的多次引用。

说起链式调用的方便快捷第一时间想到的就是jQuery:

$('#testDom').html().css()  

简易版jquery

实现一个简易版的自定义jQuery来体会一下链模式的思想。

第一步
// jquery通过原型对象来共享属性和方法,并且将原型对象暴露为自身自定义的fn属性,通过fn来使用和拓展原型上的属性或方法
function A() {
  
}

A.fn = A.prototype
第二步
// jquery接受一个选择器入参,并通过fn.init构造函数返回一个jquery的实例。
function A(selector) {
  return new A.fn.init(selector)
}

// 每个jquery实例上除了dom元素,还有length, size()这样的属性和方法。
A.prototype = {
  init: function(selector) {
    this[0] = document.getElementById(selector)
    this.length = 1
    return this
  },

  length: 0,

  size() {
    return this.length
  }
}

A.fn = A.prototype
第三步
// 此时A()返回对象的构造函数实际上是A.prototype.init,而jquery中实际返回的是jquery的实例
function A(selector) {
  return new A.fn.init(selector)
}

A.prototype = {
  constructor: A, // 强化constructor

  init: function(selector) {
    this[0] = document.getElementById(selector)
    this.length = 1
    return this
  },

  length: 0,

  size() {
    return this.length
  }
}

// 所以将A.prototype.init的原型 指向 A的原型,并且在A的原型上强化constructor
A.prototype.init.prototype = A.prototype

A.fn = A.prototype

// 如此一来通过A.fn.init(selector)的实例看起来就与A(selector)的实例一模一样了。
第四步
function A(selector) {
  return new A.fn.init(selector)
}

A.prototype = {
  constructor: A,

  init: function(selector) {
    this[0] = document.getElementById(selector)
    this.length = 1
    return this
  },

  length: 0,

  size() {
    return this.length
  },

  // 添加拓展方法
  extend(obj) {
    for(let key in obj) {
      this[key] = obj[key]
    }
  }
}

A.prototype.init.prototype = A.prototype

A.fn = A.prototype

// 拓展通用方法
A.fn.extend({
  css() {
    console.log('设置css')
    // 链式调用的核心,返回实例
    return this
  },

  html() {
    console.log('设置html')
    // 链式调用的核心,返回实例
    return this
  }
})
测试
const a = A('testDom')
console.log(a.size()) // 1
a.html().css()
// 设置html
// 设置css

javaScript中链模式的核心思想就是通过在对象中的每个方法调用执行完毕后返回当前对象this来实现的。

二、 委托模式

多个对象接收并处理同一请求,他们将请求委托给另一个对象统一处理请求。

委托模式在javaScript中最常见的应用就是处理事件,比如一个列表中每一项都要绑定点击事件。

<ul id="ul">
  <li>1</li>
  <li>2</li>
  <li>3</li>
  <li>4</li>
  <li>5</li>
  <li>6</li>
</ul>

那么每个li(委托者)可以通过事件冒泡机制,将事件处理委托给ul(被委托者)元素。

const ul = document.getElementById('ul')

ul.addEventListener('click', e => {
  if(e.target.nodeName.toLowerCase() === 'li') {
    console.log(e.target.innerHTML)
  }
})

这个例子在享元模式中也使用过,因为他在实现解决请求与委托者之间耦合这一点上符合委托模式思想;在共享对象,节约内存这一点上符合享元模式思想。

委托模式是一种基础技巧,在其他一些设计模式中都有应用,比如享元模式,状态模式,策略模式,命令模式等。

三、 数据访问对象模式

Data Access Object - DAO:抽象和封装对数据源的访问与存储,DAO通过对数据源链接的管理方便对数据的访问与存储。

当把一些数据存储在前端时(比如localStorage),对于一个大型多人开发的web应用可能会存在以下问题:

  • 如何确保自己存储在本地的数据不会覆盖其他人的。
  • 如何知道本地存储的数据是否成功。
  • 如何定期清除本地数据。

期望能够类似操作数据库增删查改那样在前端管理数据,可以使用数据访问对象模式实现。也就是不直接操作数据对象,而是抽象出一个DAO层来做。

数据访问对象类的基本原理:

  • 通过给每个键(key)添加前缀避免冲突。
  • 通过分隔符将值(value)和时间戳组合判断是否过期。
  • 通过回调函数的方式获取操作结果。
// 封装localStorage
function MyLS(prefix, splitSymbol) {
  this.prefix = prefix // key前缀
  this.splitSymbol = splitSymbol || '|-|' // 存储数据与时间戳之间的分隔符,默认为 |-|
}

MyLS.prototype = {
  // 操作状态
  status: {
    SUCCESS: 0, // 成功
    FAILURE: 1, // 失败
    OVERFLOW: 2, // 溢出
    TIMEOUT: 3, // 过期
  },

  // 存储对象
  storage: localStorage || window.localStorage,

  // 获取带前缀的实际存储的key
  getRealKey(key) {
    return this.prefix + key
  },

  /** 
   * 增(改)数据
   * @params key 数据键
   * @params value 数据值
   * @params callback 回调函数
   * @params time 过期时间
   */
  set(key, value, callback, time) {
    // 默认为成功状态
    let status = this.status.SUCCESS

    // 生成实际存储的key
    const realKey = this.prefix + key

    // 生成过期时间戳
    try {
      time = new Date(time).getTime() || time.getTime()
    } catch(e) {
      // 未设置过期时间默认为一周
      time = Date.now() + 7 * 24 * 60 * 60 * 1000
    }

    // 向localstorage增(改)数据
    try {
      // 生成实际存储的value
      const realValue = time + this.splitSymbol + value
      this.storage.setItem(realKey, realValue)
    } catch(e) {
      // 发生溢出
      status = this.status.OVERFLOW
    }

    // 执行回调函数返回操作状态
    callback && callback.call(this, status)
  },

   /** 
   * 查询数据
   * @params key 数据键
   * @params callback 回调函数
   */
  get(key, callback) {
    // 实际存储的key
    const realKey = this.getRealKey(key)

    // 默认成功状态
    let status = this.status.SUCCESS

    // 获取的值
    let value = null

    // 获取数据
    try {
      value = this.storage.getItem(realKey)
    } catch(e) {
      // 获取失败
      value = null
      status = this.status.FAILURE
    }

    
    // 如果成功获取不是Null的数据
    if(value && status !== this.status.FAILURE) {
      // 获取时间戳
      const valueArr = value.split(this.splitSymbol)
      const time = Number(valueArr[0])

      // 判断是否过期
      if(time > Date.now()) {
        value = valueArr[1]
      } else {
        // 已过期 删除数据
        value = null
        status = this.status.TIMEOUT
        this.remove(key)
      }
    }
    
    // 执行回调函数返回操作状态和获取的值
    callback && callback.call(this, status, value)
    
    // 返回获取的值
    return value
  },

     /** 
   * 删除数据
   * @params key 数据键
   * @params callback 回调函数
   */
  remove(key, callback) {
    // 实际存储的key
    const realKey = this.getRealKey(key)

    // 默认失败状态,因为删除数据只有第一次成功,之后都会失败
    let status = this.status.FAILURE

    // 数据值
    let value = null

    try{
      // 尝试获取数据值
      value = this.storage.getItem(realKey)
    } catch (e) {
      // 获取失败,不操作
    }

    if(value) {
      // 如果数据存在,删除数据
      this.storage.removeItem(realKey)
      status = this.status.SUCCESS
    }

    // 执行回调函数返回操作状态
    callback && callback.call(this, status)
  }
  
}
// 测试
const wangLS = new MyLS('wang_')

wangLS.set('name', '王狗蛋', status => {
  console.log(status) // 0
})

wangLS.get('name', (status, value) => {
  console.log(status, value) // 0 王狗蛋
})

wangLS.remove('name', status => {
  console.log(status) // 0
})

wangLS.get('name', (status, value) => {
  console.log(status, value) // 0 null
})

四、 节流模式

对重复的业务逻辑进行节流控制,执行最后一次操作并取消其他操作,以提高性能。

书中所说的节流模式实际就是现在常用的函数节流与防抖中的防抖,至于为什么名称跟现在有所错乱我也不懂。

防抖(debounce)

防抖函数的意思就是:在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。

比如常见的防抖应用场景,搜索输入框,每当input内容发生变化就请求后端接口,频繁的请求接口降低体验,可以使用函数防抖设定停止输入2秒后才请求后端,也就是说在不停输入的过程中不会触发后端请求。

// 防抖函数
function debounce(fn, delay) {
  let timer = null

  return function(...args) {
    if(timer) {
      clearTimeout(timer)
    }

    timer = setTimeout(() => {
        fn.call(this, ...args)
    }, delay)
  }
}
// 使用
const inputDom = document.getElementById('inputDom')

inputDom.addEventListener('input',debounce(function(e) {
  console.log('发送请求')
}, 1000))
节流(throttle)

节流函数的意思就是:规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。

比如常见的节流应用场景,鼠标在一个元素上移动不断触发mousemove事件,使用函数节流控制最多每500毫秒触发一次回调函数。

// 节流函数
function throttle(fn, timeout) {
  let timer = null

  return function(...arg) {
    if(!timer) {
      timer = setTimeout(() => {
        fn.call(this, ...arg)
        timer = null
      }, timeout);
    }else {
      return
    }
  }
}
// 使用
const testDom = document.getElementById('testDom')

testDom.addEventListener('mousemove', throttle(function(e) {
  console.log(e)
}, 1000))

五、 简单模板模式

通过格式化字符串拼凑出视图,避免创建视图时大量节点操作。优化内存开销。

比如在页面中插入一个列表:

const list = [1,2,3,4,5]

const box = document.getElementById('testDom')

const ul = document.createElement('ul')

for(i of list) {
  const li = document.createElement('li')
  li.innerHTML = i
  ul.appendChild(li)
}

box.appendChild(ul)

每一步都有对dom的操作,尤其在遍历数组的时候开销很大。

可以通过将整个dom结构拼接为一个完整字符串后再一次性插入页面,极大的优化性能。

const list = [1,2,3,4,5]

const box = document.getElementById('testDom')

let liStr = ''
list.forEach(i => {
  liStr = liStr + '<li>' + i  + '</li>\n'
})

const ulStr =
  `
    <ul>
      ${liStr}    
    </ul>
  `
box.innerHTML = ulStr

当然书中的简单模板模式还包含了字符串模板库和格式化的方法两部分,也就是针对一些需要生成dom的场景定制化一些模板,通过传入参数匹配模板,格式化字符串简化操作。该模式的核心理念就是用字符串拼接代替频繁的dom操作。

简单模板模式意在解决运用dom操作创建视图时造成资源消耗大、性能低下,操作复杂等问题。

六、 惰性模式

减少每次代码执行时的重复性的分支判断,通过对对象重定义来屏蔽原对象的分支判断。

惰性模式的意思就是说,代码中的一些兼容性判断逻辑,实际上在第一次执行后就可以确定了,不需要每一次执行都判断,那么就可以把真正的执行程序延迟到判断之后。

比如说自定义兼容ie低版本浏览器的事件绑定函数:

function addEvent(dom, type, fn) {
  if(dom.addEventListener) {
    dom.addEventListener(type, fn)
  } else if(dom.attachEvent) {
    dom.attachEvent('on' + type, fn)
  } else {
    dom['on' + type] = fn
  }
}

每次使用的过程中都会走一遍兼容性判断逻辑。

const testDom = document.querySelector('#testDom')

addEvent(testDom, 'click', (e) => {
  console.log(e)
})

实际上进入系统后第一次执行完应该就可以根据系统确定一种事件绑定方式,后面不需要再重复判断,可以采取惰性模式完成:

function addEvent(dom, type, fn) {
  if(dom.addEventListener) {
    addEvent = function(dom, type, fn) {
      dom.addEventListener(type, fn)
    }
  } else if(dom.attachEvent) {
    addEvent = function(dom, type, fn) {
      dom.attachEvent('on' + type, fn)
    }
  } else {
    addEvent = function(dom, type, fn) {
      dom['on' + type] = fn
    }
  }

  // 记得要执行一次,因为第一次是重写addEvent函数没有绑定事件
  addEvent(dom, type, fn)
}

在执行过第一次确定了系统后,延迟创建了适合当前系统的绑定函数。

惰性模式是一种拖延模式,由于对象的创建或者数据的计算会花费高昂的代价(如页面刚加载时无法辨别是该浏览器支持的某个功能,此时创建对象是不够安全的),因此页面会延迟对这一类对象的创建。

突然想到最近工作中封装的一个公共函数需要兼容在Vue2和Vue3中创建自定义指令,刚好可以采用这种方式。

七、 参与者模式

在特定的作用于中执行给定的函数,并将参数原封不动地传递。

所谓参与者模式(在特定作用域执行给定的函数)其实就是bind方法:

bind
const obj = {
  myName: '王狗蛋'
}

function fn(age) {
  console.log(this.myName)
  console.log(age)
}

const bindFn = fn.bind(obj, 18)

fn() // undefined undefined
bindFn() // 王狗蛋 18

手动实现一个bind方法,首先理解函数柯里化:

函数柯里化

函数柯里化的思想是对函数的参数的分割,根据传递参数的不同借助柯里化器伪造其他函数,让这些伪造的函数在执行时调用这个基础函数完成不同功能。

// 函数柯里化
function curry(fn, ...args) {
  // 闭包返回新函数
  return function(...addArgs) {
    // 拼接参数
    const allArgs = args.concat(addArgs)
    return fn.apply(null, allArgs)
  }
}

// 测试

// 求和加法器
function add(a, b) {
  return a + b
}

// 函数柯里化创建加10加法器
const add5 = curry(add, 10)
console.log(add5(5)) // 15
console.log(add5(100)) // 110
使用函数柯里化实现bind函数
// 手写bind方法
function bind(fn, ctx, ...args) {
  return function(...addArgs) {
    const allArgs = args.concat(addArgs)
    return fn.apply(ctx, allArgs)
  }
}
// 测试
const obj = {
  myName: '王狗蛋'
}

function fn(age) {
  console.log(this.myName)
  console.log(age)
}

const bindFn = bind(fn, obj, 18)

fn() // undefined undefined
bindFn() // 王狗蛋 18

函数的柯里化即是将接受多个参数的函数转化为接受一部分参数的新函数,余下的函数保存下来,当函数调用时,返回传入的参数与保存的参数共同执行的结果。

八、 等待者模式

通过对多个异步进程监听,来触发未来发生的动作。

对于等待者模式处理的问题,现在已经有了最佳实践:使用Promise 以及 async/await 语法来处理异步进程,所以不再赘述该模式。