手写防抖、节流函数、自定义深拷贝函数、自定义事件总线

155 阅读14分钟
  • 防抖: 连续触发某个事件的时候,等待固定时间才处理响应函数
  1. 频繁点击按钮, 触发事件
  2. 输入框频繁输入内容, 进行搜索
  • 节流: 固定时间内,只会触发一次操作

比如打飞机, 无论用户按下多少次空格, 一秒之内只会发送一次子弹

认识防抖和节流函数

防抖和节流的概念其实最早并不是出现在软件工程中,防抖是出现在电子元件中,节流出现在流体流动中。而JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理。而对于某些频繁的事件处理会造成性能的损耗,我们就可以通过防抖和节流来限制事件频繁的发生。

防抖和节流函数目前已经是前端实际开发中两个非常重要的函数,也是面试经常被问到的面试题。但是很多前端开发者面对这两个功能,有点摸不着头脑:

  • 某些开发者根本无法区分防抖和节流有什么区别(面试经常会被问到);
  • 某些开发者可以区分,但是不知道如何应用;
  • 某些开发者会通过一些第三方库来使用,但是不知道内部原理,更不会编写;

接下来我们会一起来学习防抖和节流函数,我们不仅仅要区分清楚防抖和节流两者的区别,也要明白在实际工作中哪些场景会用到,并且我会带着大家一点点来编写一个自己的防抖和节流的函数,不仅理解原理,也学会自己来编写。

认识防抖debounce函数

我们用一幅图来理解一下它的过程:当事件触发时,相应的函数并不会立即触发,而是会等待一定的时间;当事件密集触发时,函数的触发会被频繁的推迟;只有等待了一段时间也没有事件触发,才会真正的执行响应函数。

防抖的应用场景很多:

  • 输入框中频繁的输入内容,搜索或者提交信息;
  • 频繁的点击按钮,触发某个事件;
  • 监听浏览器滚动事件,完成某些特定操作;
  • 用户缩放浏览器的resize事件;

防抖函数的案例

我们都遇到过这样的场景,在某个搜索框中输入自己想要搜索的内容,比如想要搜索一个MacBook,当我输入m时,为了更好的用户体验,通常会出现对应的联想内容,这些联想内容通常是保存在服务器的,所以需要一次网络请求;当继续输入ma时,再次发送网络请求;那么macbook一共需要发送7次网络请求。

这大大损耗我们整个系统的性能,无论是前端的事件处理,还是对于服务器的压力,但是我们需要这么多次的网络请求吗?

不需要,正确的做法应该是在合适的情况下再发送网络请求。

  • 比如如果用户快速的输入一个macbook,那么只是发送一次网络请求;
  • 比如如果用户是输入一个m想了一会儿,这个时候m确实应该发送一次网络请求;
  • 也就是我们应该监听用户在某个时间,比如500ms内,没有再次触发时间时,再发送网络请求;

这就是防抖的操作:只有在某个时间内,没有再次触发某个函数时,才真正的调用这个函数。

认识节流throttle函数

我们用一幅图来理解一下节流的过程,当事件触发时,会执行这个事件的响应函数,如果这个事件会被频繁触发,那么节流函数会按照一定的频率来执行函数,不管在这个中间有多少次触发这个事件,执行函数的频繁总是固定的。

节流的应用场景:

  • 监听页面的滚动事件;
  • 鼠标移动事件;
  • 用户频繁点击按钮操作;
  • 游戏中的一些设计;

节流函数的应用场景

很多人都玩过类似于飞机大战的游戏,在飞机大战的游戏中,我们按下空格会发射一个子弹,很多飞机大战的游戏中会有这样的设定,即使按下的频率非常快,子弹也会保持一定的频率来发射,比如1秒钟只能发射一次,即使用户在这1秒钟按下了10次,子弹会保持发射一颗的频率来发射,但是事件是触发了10次的,响应的函数只触发了一次。

生活中的例子:防抖和节流

生活中防抖的例子:

比如说有一天我上完课,我说大家有什么问题来问我,我会等待五分钟的时间。如果在五分钟的时间内,没有同学问我问题,那么我就下课了;在此期间,a同学过来问问题,并且帮他解答,解答完后,我会再次等待五分钟的时间看有没有其他同学问问题;如果我等待超过了5分钟,就点击了下课(才真正执行这个时间)。

生活中节流的例子:

比如说有一天我上完课,我说大家有什么问题来问我,但是在一个5分钟之内,不管有多少同学来问问题,我只会解答一个问题;如果在解答完一个问题后,5分钟之后还没有同学问问题,那么就下课。

案例准备

我们通过一个搜索框来延迟防抖函数的实现过程:监听input的输入,通过打印模拟网络请求,测试发现快速输入一个macbook共发送了7次请求,显示我们需要对它进行防抖操作。

Underscore库的介绍

事实上我们可以通过一些第三方库来实现防抖操作:

  • lodash
  • underscore

这里使用underscore,我们可以理解成lodash是underscore的升级版,它更重量级,功能也更多,目前我看到underscore还在维护,但是lodash已经很久没有更新了。

Underscore的官网: underscorejs.org/

Underscore的安装有很多种方式:

  • 下载Underscore,本地引入;
  • 通过CDN直接引入;
  • 通过包管理工具(npm)管理安装;

这里我们直接通过CDN:

<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.1/underscore-umd-min.js"></script>

Underscore实现防抖和节流

自定义防抖函数

我们按照如下思路来实现:

  • 防抖基本功能实现:绑定this和传入参数
  • 优化一:添加第一次立即执行
  • 优化二:添加取消功能
  • 优化三:优化返回值

基本实现

function debounce(fn, delay) {
  // 1.定义一个定时器, 保存上一次的定时器
  let timer = null

  // 2.input事件真正执行的函数, 里面有参数
  const _debounce = function(...args) {
    // 取消上一次的定时器
    if (timer) clearTimeout(timer)
    // 延迟执行
    timer = setTimeout(() => {
      // 调用外部传入的函数
      fn.apply(this, args)
    }, delay)
  }

  return _debounce
}

使用:

// 定义我们的事件
let count = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

inputEl.input = debounce(inputChange ,2000)

注意:添加this和参数之后,防抖函数就能解决实际项目中80%的需求了,一定要记住。

优化一:添加立即执行功能

默认情况下,防抖函数是最后一次事件再延迟一定的时间再执行的,如果我们希望第一次立即执行,可以添加一个参数:

function debounce(fn, delay, immediate = false) {
  // 1.定义一个定时器, 保存上一次的定时器
  let timer = null
  // 是否立即执行过
  let isInvoke = false

  // 2.真正执行的函数
  const _debounce = function(...args) {
    // 取消上一次的定时器
    if (timer) clearTimeout(timer)

    // 判断是否需要立即执行
    if (immediate && !isInvoke) {
      fn.apply(this, args)
      isInvoke = true
    } else {
      // 延迟执行
      timer = setTimeout(() => {
        // 外部传入的真正要执行的函数
        fn.apply(this, args)
        // 重新赋默值, 没有立即执行过
        isInvoke = false
      }, delay)
    }
  }

  return _debounce
}

使用:

// 定义我们的事件
let count = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

// 第三个参数传true
inputEl.input = debounce(inputChange ,2000, true)

优化二:添加取消功能

因为函数也是一个对象,所以我们给返回的_debounce函数再添加一个cancel。

function debounce(fn, delay, immediate = false) {
...
...
 // 封装取消功能
  _debounce.cancel = function() {
    if (timer) clearTimeout(timer)
    timer = null
    isInvoke = false
  }
 return _debounce
}

使用:

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

const debounceChange = debounce(inputChange, 3000)
inputEl.oninput = debounceChange

// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
  debounceChange.cancel()
}

优化三:获取返回值

拿到传入的fn的返回值,我们使用两种方法,一种是callBack,一种是Promise

使用callBack比较简单,不做解释。使用Promise就是_debounce函数执行完毕,我们返回Promise。

function debounce(fn, delay, immediate = false, resultCallback) {
  // 1.定义一个定时器, 保存上一次的定时器
  let timer = null
  let isInvoke = false

  // 2.真正执行的函数
  const _debounce = function(...args) {
    return new Promise((resolve, reject) => {
      // 取消上一次的定时器
      if (timer) clearTimeout(timer)

      // 判断是否需要立即执行
      if (immediate && !isInvoke) {
        const result = fn.apply(this, args)
        // 将真正执行函数的返回值通过callBack返回出去
        if (resultCallback) resultCallback(result)
        resolve(result)
        isInvoke = true
      } else {
        // 延迟执行
        timer = setTimeout(() => {
          // 外部传入的真正要执行的函数
          const result = fn.apply(this, args)
          if (resultCallback) resultCallback(result)
          resolve(result)
          isInvoke = false
          timer = null
        }, delay)
      }
    })
  }

  // 封装取消功能
  _debounce.cancel = function() {
    if (timer) clearTimeout(timer)
    timer = null
    isInvoke = false
  }

  return _debounce
}

使用:

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
  // 返回值
  return "aaaaaaaaaaaa"
}

// 方式一: 通过回调函数拿到返回值
const debounceChange = debounce(inputChange, 3000, false, (res) => {
  console.log("拿到真正执行函数的返回值:", res)
})
inputEl.oninput = debounceChange

// 方式二: 通过Promise拿到返回值
const tempCallback = (...args) => {
  // debounceChange就是_debounce
  // _debounce执行的时候才会返回Promise,就相当于我们手动调用_debounce
  // 注意:是debounceChange().then,而不是debounceChange.then
  debounceChange.apply(inputEl, args).then(res => {
    console.log("Promise的返回值结果:", res)
  })
}
inputEl.oninput = tempCallback

// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
  debounceChange.cancel()
}

添加返回值这个比较复杂,作用也不大,我们只要记住:绑定this,立即执行,添加取消,就行了。

自定义节流函数

我们按照如下思路来实现:

  • 节流函数的基本实现:绑定this和传入参数
  • 优化一:第一次可以不执行
  • 优化二:节流最后一次也可以执行
  • 优化三:优化添加取消功能
  • 优化四:优化返回值问题

节流函数的实现逻辑:

基本实现

function throttle(fn, interval) {
  // 1.记录上一次的开始时间
  let lastTime = 0

  // 2.事件触发时, 真正执行的函数
  const _throttle = function(...args) {
    // 2.1.获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    // 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    const remainTime = interval - (nowTime - lastTime)

    if (remainTime <= 0) {
      // 2.3.真正触发函数
      fn.apply(this, args)
      // 2.4.保留上次触发的时间
      lastTime = nowTime
    }
  }

  return _throttle
}
// 默认第一个时间间隔一定会触发,因为第一次是个很大的负值,最后一次事件不触发

使用:

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

inputEl.oninput = throttle(inputChange, 3000)

上面我们实现的效果是绑定this和参数了,并且第一个时间间隔一定会执行,最后一个时间间隔不会执行,如果我们想第一个时间间隔和最后一个时间间隔的执行都可以控制,可以优化如下:

优化一:第一次不触发

function throttle(fn, interval, options = { leading: true, trailing: false }) {
  const { leading, trailing } = options
  // 1.记录上一次的开始时间
  let lastTime = 0

  // 2.事件触发时, 真正执行的函数
  const _throttle = function(...args) {

    // 2.1.获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    // 让第一次不触发
    if (lastTime == 0 && leading === false) lastTime = nowTime

    // 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    const remainTime = interval - (nowTime - lastTime)
    if (remainTime <= 0) {
      // 2.3.真正触发函数
      fn.apply(this, args)
      // 2.4.保留上次触发的时间
      lastTime = nowTime
    }
  }

  return _throttle
}
// 默认第一个时间间隔一定会触发,因为第一次是个很大的负值, 最后一次如果没超过时间间隔是不会触发的

使用:

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

inputEl.oninput = throttle(inputChange, 3000, {leading: false})

优化二:让最后一个时间间隔也执行

其实就是加个定时器:


function throttle(fn, interval, options = { leading: true, trailing: false }) {
  // 1.记录上一次的开始时间
  const { leading, trailing } = options
  let lastTime = 0
  let timer = null

  // 2.事件触发时, 真正执行的函数
  const _throttle = function(...args) {

    // 2.1.获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    if (lastTime == 0 && leading === false) lastTime = nowTime

    // 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    const remainTime = interval - (nowTime - lastTime)
    if (remainTime <= 0) {
      // 已经要执行了,前面添加的定时器就取消掉
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.3.真正触发函数
      fn.apply(this, args)
      // 2.4.保留上次触发的时间
      lastTime = nowTime
      // 事件触发的时候,后面没必要添加定时器了,直接return掉 
      return
    }

    // 最后只添加一个定时器
    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null
        // 如果第一次执行,leading为true,这个定时器就相当于下一次循环的第一次,所以肯定会执行,执行完我们把lastTime设置为当前时间
        // 如果第一次不执行,直接设置默认的0
        lastTime = !leading ? 0: new Date().getTime()
        fn.apply(this, args)
      }, remainTime)
    }
  }

  return _throttle
}
// 默认第一个时间间隔会触发, 最后一次如果没超过时间间隔是不会触发的

使用:

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

inputEl.oninput = throttle(inputChange, 3000, {leading: true, trailing: true})

优化三:添加取消功能

如果我们设置trail:true, 就是最后也会执行一次,如果我们在执行之前想取消掉,怎么做呢?

function throttle(fn, interval, options = { leading: true, trailing: false }) {
  // 1.记录上一次的开始时间
  const { leading, trailing } = options
  let lastTime = 0
  let timer = null

  // 2.事件触发时, 真正执行的函数
  const _throttle = function(...args) {

    // 2.1.获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    if (!lastTime && !leading) lastTime = nowTime

    // 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    const remainTime = interval - (nowTime - lastTime)
    if (remainTime <= 0) {
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.3.真正触发函数
      fn.apply(this, args)
      // 2.4.保留上次触发的时间
      lastTime = nowTime
      return
    }

    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null
        lastTime = !leading ? 0: new Date().getTime()
        fn.apply(this, args)
      }, remainTime)
    }
  }

  // 添加取消方法
  _throttle.cancel = function() {
    if(timer) clearTimeout(timer)
    timer = null
    lastTime = 0
  }

  return _throttle
}

使用:

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

const _throttle = throttle(inputChange, 3000, { leading: false, trailing: true,})
inputEl.oninput = _throttle

// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
  _throttle.cancel()
}

优化四:添加返回值

两种方式,一种是回调函数,一种是Promise

function throttle(fn, interval, options = { leading: true, trailing: false }) {
  // 1.记录上一次的开始时间
  const { leading, trailing, resultCallback } = options
  let lastTime = 0
  let timer = null

  // 2.事件触发时, 真正执行的函数
  const _throttle = function(...args) {
    return new Promise((resolve, reject) => {
      // 2.1.获取当前事件触发时的时间
      const nowTime = new Date().getTime()
      if (!lastTime && !leading) lastTime = nowTime

      // 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
      const remainTime = interval - (nowTime - lastTime)
      if (remainTime <= 0) {
        if (timer) {
          clearTimeout(timer)
          timer = null
        }

        // 2.3.真正触发函数
        const result = fn.apply(this, args)
        if (resultCallback) resultCallback(result)
        resolve(result)
        // 2.4.保留上次触发的时间
        lastTime = nowTime
        return
      }

      if (trailing && !timer) {
        timer = setTimeout(() => {
          timer = null
          lastTime = !leading ? 0: new Date().getTime()
          const result = fn.apply(this, args)
          if (resultCallback) resultCallback(result)
          resolve(result)
        }, remainTime)
      }
    })
  }

  _throttle.cancel = function() {
    if(timer) clearTimeout(timer)
    timer = null
    lastTime = 0
  }

  return _throttle
}

使用:

const inputEl = document.querySelector("input")

let counter = 0
const inputChange = function(event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
  return 11111111111
}

// 这里实际调用的其实是_throttle
const _throttle = throttle(inputChange, 3000, { 
  leading: false, 
  trailing: true,
  // 1. 通过回调函数拿到返回值(推荐)
  resultCallback: function(res) {
    console.log("resultCallback:", res)
  }
  })
  inputEl.oninput = _throttle

// 2. 通过Promise拿到返回值(麻烦)
const tempCallback = (...args) => {
  // 当inputEl.oninput = tempCallback的时候, 实际调用的是tempCallback,参数是在这里面的
  // 但是我们最终要调用_throttle, 所以我们手动调用,并绑定this和参数
  _throttle.apply(inputEl, args).then(res => {
    console.log("Promise:", res)
  })
}
inputEl.oninput = tempCallback

// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
  _throttle.cancel()
}

自定义深拷贝函数

前面我们已经学习了对象相互赋值的一些关系,分别包括:

  • 引入的赋值:指向同一个对象,相互之间会影响;
  • 对象的浅拷贝:只是浅层的拷贝,内部引入对象时,依然会相互影响(Object.assign()或者展开运算符...);
  • 对象的深拷贝:两个对象不再有任何关系,不会相互影响;

前面我们已经可以通过一种方法来实现深拷贝了:JSON.stringify和JSON.parse,这种深拷贝的方式其实对于函数、Symbol等是没有任何处理的,并且如果存在对象的循环引用,也会报错的,因为不能将循环的结构转成JSON(比如对象里面有个属性指向自己,其实这是可以的,比如window里面就有一个window指向自己,所以我们可以一直window.window.window)

自定义深拷贝函数步骤:

  1. 自定义深拷贝的基本功能;
  2. 数组、函数、Set、Map、Symbol的value和key做特殊处理
  3. 对循环引用的处理;
function isObject(value) {
  const valueType = typeof value
  return (value !== null) && (valueType === "object" || valueType === "function")
}

function deepClone(originValue, map = new WeakMap()) {
  // map放到全局中肯定不行,因为把所有拷贝信息放到一个map中肯定是不行的
  // map放到局部也是不行,因为每次递归调用都会创建一个新的map,那我们就拿不到上层的map信息了
  // 所以我们把map放到参数中,并且第二次调用deepClone再把这个map传过去
  
  // 判断如果是函数类型, 那么直接使用同一个函数
  if (typeof originValue === "function") {
    return originValue
  }
  
  // 判断是否是一个Set类型
  if (originValue instanceof Set) {
    return new Set([...originValue])
  }
  // 判断是否是一个Map类型
  if (originValue instanceof Map) {
    return new Map([...originValue])
  }
  // 这里对Set、Map其实是浅层拷贝,开发中其实也足够用了

  // 判断如果是Symbol的value, 那么创建一个新的Symbol
  if (typeof originValue === "symbol") {
    return Symbol(originValue.description)
  }

  // 判断传入的originValue是否是一个对象类型
  if (!isObject(originValue)) {
    return originValue
  }
  // 如果原来已经有值了,那么直接使用原来创建的值
  if (map.has(originValue)) {
    return map.get(originValue)
  }

  // 判断传入的对象是数组, 还是对象,因为数组也是对象
  const newObject = Array.isArray(originValue) ? []: {}
  // 1. 将每个值对应的拷贝的对象先保存起来
  map.set(originValue, newObject)
  for (const key in originValue) {
    newObject[key] = deepClone(originValue[key], map)
  }

  // 对Symbol作为key进行特殊的处理
  const symbolKeys = Object.getOwnPropertySymbols(originValue)
  for (const sKey of symbolKeys) {
    // const newSKey = Symbol(sKey.description)
    newObject[sKey] = deepClone(originValue[sKey], map)
  }
  
  return newObject
}

自定义事件总线

Vue2的时候有事件总线,用于数据之间的跨组件传递,Vue3的时候移除了事件总线,因为事件总线不应该包含在Vue框架中的,Vue3中官方推荐我们使用Mitt。当然我们也可以使用自己的事件总线,下面我们就实现一下:

自定义事件总线属于一种观察者模式,其中包括三个角色:

  • 发布者(Publisher):发出事件(Event);
  • 订阅者(Subscriber):订阅事件(Event),并且会进行响应(Handler);
  • 事件总线(EventBus):无论是发布者还是订阅者都是通过事件总线作为中台的;

当然我们可以选择一些第三方的库:

  • Vue2默认是带有事件总线的功能;
  • Vue3中推荐一些第三方库,比如mitt;

当然我们也可以实现自己的事件总线:

  • 事件的监听方法on;
  • 事件的发射方法emit;
  • 事件的取消监听off;
class HYEventBus {
  constructor() {
    // 对象的key是事件名称
    // 对象的value是事件数组,数组中是对象,对象里面有eventCallback和thisArg
    this.eventBus = {}
  }

  // 监听事件:传入事件名称,回调的函数,绑定的this
  on(eventName, eventCallback, thisArg) {
    let handlers = this.eventBus[eventName]
    if (!handlers) {
      handlers = []
      this.eventBus[eventName] = handlers
    }
    handlers.push({
      eventCallback,
      thisArg
    })
  }
  // 发送事件:传入事件名称和参数
  emit(eventName, ...payload) {
    const handlers = this.eventBus[eventName]
    if (!handlers) return
    handlers.forEach(handler => {
      // 调用回调函数,并绑定this
      handler.eventCallback.apply(handler.thisArg, payload)
    })
  }
  // 销毁事件:传入事件名称和回调函数
  off(eventName, eventCallback) {
    const handlers = this.eventBus[eventName]
    if (!handlers) return
    // 因为我们是边遍历数组,边移除数组中的数据,所以最好对数组做一个拷贝
    const newHandlers = [...handlers]
    for (let i = 0; i < newHandlers.length; i++) {
      const handler = newHandlers[i]
      if (handler.eventCallback === eventCallback) {
        // 因为有[fn1,fn2,fn3,fn2]也就是数组中有相同的函数的情况
        // 所以下标我们都以handlers中的为准
        // 或者使用filter,然后重新赋值,也可以
        const index = handlers.indexOf(handler)
        handlers.splice(index, 1)
      }
    }
  }
}

const eventBus = new HYEventBus()

// main.js
eventBus.on("abc", function() {
  console.log("监听abc1", this)
}, {name: "why"})

const handleCallback = function() {
  console.log("监听abc2", this)
}
eventBus.on("abc", handleCallback, {name: "why"})

// utils.js
eventBus.emit("abc", 123, 321, 456)

// 移除监听
eventBus.off("abc", handleCallback)
eventBus.emit("abc", 123)