防抖与节流

什么是防抖?

防抖是指触发高频事件后n秒内函数只会执行一次,如果n秒内高频事件再次被触发,则重新计算时间。

什么是节流?

节流是指高频事件触发,但在n秒内只会执行一次,所以节流会稀释函数的执行频率。

防抖与节流的作用是什么?

防抖与节流是用来处理事件频繁地触发所产生的技术,都是用来限制事件频繁地发生的

事件频繁触发可能造成的问题:
1、一些浏览器事件如window.onresize、window.onmousemove等触发频率非常高的事件会造成浏览器的性能问题。
2、 向后台请求数据会频繁地触发,对服务器造成不必要的压力。
3、而要限制事件处理函数的频繁调用,有防抖与节流两种技术来解决这个麻烦。

函数节流(throttle)

函数节流的主要思想为在函数需要频繁触发时,函数触发一次后,只有大于设定的执行周期后才会第二次执行改函数。该方法适合多次事件按照时间做平均分配触发

函数节流的使用场景:

1、窗口调整(resize)
2、页面滚动(scroll)
3、DOM元素的拖拽功能实现(mousemove)
4、类似电商抢购商品时的疯狂点击抢购按钮(mousedown)

现在来考虑另外一个场景,一个左右两列布局的查看文章页面,左侧为文章大纲结构,右侧为文章内容。现在需要添加一个功能,就是当用户滚动阅读右侧文章内容时,左侧大纲相对应部分高亮显示,提示用户当前阅读位置。

这个功能的实现思路比较简单,滚动前先记录大纲中各个章节的垂直距离,然后监听 scroll 事件的滚动距离,根据距离的比较来判断需要高亮的章节。伪代码如下:

// 监听scroll事件
wrap.addEventListener('scroll', e => {
  let highlightId = ''
  // 遍历大纲章节位置,与滚动距离比较,得到当前高亮章节id
  for (let id in offsetMap) {
    if (e.target.scrollTop <= offsetMap[id].offsetTop) {
      highlightId = id
      break
    }
  }
  const lastDom = document.querySelector('.highlight')
  const currentElem = document.querySelector(`a[href="#${highlightId}"]`)
  // 修改高亮样式
  if (lastDom && lastDom.id !== highlightId) {
    lastDom.classList.remove('highlight')
    currentElem.classList.add('highlight')
  } else {
    currentElem.classList.add('highlight')
  }  
})

功能是实现了,但这并不是最优方法,因为滚动事件的触发频率是很高的,持续调用判断函数很可能会影响渲染性能。实际上也不需要过于频繁地调用,因为当鼠标滚动 1 像素的时候,很有可能当前章节的阅读并没有发生变化。所以我们可以设置在指定一段时间内只调用一次函数,从而降低函数调用频率,这种方式我们称之为“节流”。

实现节流函数的过程和防抖函数有些类似,只是对于节流函数而言,有两种执行方式,在调用函数时执行最先一次调用还是最近一次调用,所以需要设置时间戳加以判断。我们可以基于 debounce() 函数加以修改,代码如下所示:

const throttle = (func, wait = 0, execFirstCall) => {
  let timeout = null
  let args
  let firstCallTimestamp


  function throttled(...arg) {
    if (!firstCallTimestamp) firstCallTimestamp = new Date().getTime()
    if (!execFirstCall || !args) {
      console.log('set args:', arg)
      args = arg
    }
    if (timeout) {
      clearTimeout(timeout)
      timeout = null
    }
    // 以Promise的形式返回函数执行结果
    return new Promise(async(res, rej) => {
      if (new Date().getTime() - firstCallTimestamp >= wait) {
        try {
          const result = await func.apply(this, args)
          res(result)
        } catch (e) {
          rej(e)
        } finally {
          cancel()
        }
      } else {
        timeout = setTimeout(async () => {
          try {
            const result = await func.apply(this, args)
            res(result)
          } catch (e) {
            rej(e)
          } finally {
            cancel()
          }
        }, firstCallTimestamp + wait - new Date().getTime())
      }
    })
  }
  // 允许取消
  function cancel() {
    clearTimeout(timeout)
    args = null
    timeout = null
    firstCallTimestamp = null
  }
  // 允许立即执行
  function flush() {
    cancel()
    return func.apply(this, args)
  }
  throttled.cancel = cancel
  throttled.flush = flush
  return throttle
}

节流与防抖都是通过延迟执行,减少调用次数,来优化频繁调用函数时的性能。不同的是,对于一段时间内的频繁调用,防抖是延迟执行后一次调用,节流是延迟定时多次调用。

下面是通用封装节流函数:
封装:

// 主要使用的是函数闭包的概念,保存上次点击的时刻,与下一次点击的时刻做对比
function throttle(callback, delay){
    let last = 0 
    return function () {
        const now = Date.now()
        if(now - last > delay){ // 从第二次点击开始都需要有间隔时间
            // 现在函数中的this为点击事件对象的this,也就是返回的函数内的this
            callback.apply(this, arguments)
            last = now
        }
    }
}
/*
    下面是测试
*/
function handleClick() {
    console.log('点击事件')
}
document.getElementById('throttle').onclick = throttle(handleClick, 1000)

函数防抖(debounce)

函数防抖的主要思想为在规定时间内,只让最后一次的处理事件的效果生效,而让前面的处理事件不生效。该方法适合多次事件一次响应的情况

函数防抖的使用场景:

1、实时的搜索框(实时根据输入框的数据发送ajax请求)(keyup)
2、文本输入的验证(连续输入文字后发送ajax请求进行验证,但是只验证最后一次输入的内容)(input)
3、判断scroll是否滚动到底部(scrolll)

试想这样的一个场景,有一个搜索输入框,为了提升用户体验,希望在用户输入后可以立即展现搜索结果,而不是每次输入完后还要点击搜索按钮。最基本的实现方式应该很容易想到,那就是绑定 input 元素的键盘事件,然后在监听函数中发送 AJAX 请求。伪代码如下:

const ipt = document.querySelector('input')
ipt.addEventListener('input', e => {
  search(e.target.value).then(resp => {
    // ...    
  }, e => {
    // ...
  })
})

但其实这样的写法很容易造成性能问题。比如当用户在搜索“xuanyu”这个词的时候,每一次输入都会触发搜索:

  1. 搜索:"x"
  2. 搜索:"xu"
  3. 搜索:"xua"
  4. 搜索:"xuan"
  5. 搜索:"xuany"
  6. 搜索:"xuanyu"

而实际上,只有最后一次搜索结果是用户想要的,前面进行了 5 次无效查询,浪费了网络带宽和服务器资源。

所以对于这类连续触发的事件,需要添加一个“防抖”功能,为函数的执行设置一个合理的时间间隔,避免事件在时间间隔内频繁触发,同时又保证用户输入后能即时看到搜索结果。

要实现这样一个功能我们很容易想到使用 setTimeout() 函数来让函数延迟执行。就像下面的伪代码,当每次调用函数时,先判断 timeout 实例是否存在,如果存在则销毁,然后创建一个新的定时器。

// 代码1
const ipt = document.querySelector('input')
let timeout = null
ipt.addEventListener('input', e => {
  if(timeout) {
    clearTimeout(timeout)
    timeout = null
  }
  timeout = setTimeout(() => {
    search(e.target.value).then(resp => {
      // ...    
    }, e => {
      // ...
    })  
  }, 500)  
})

问题确实是解决了,但这并不是最优答案,或者说我们需对这个防抖操作进行一些“优化”。

试想一下,如果另一个搜索框也需要添加防抖,是不是也要把 timeout 相关的代码再编写一次?而其实这个操作是完全可以抽取成公共函数的。

在抽取成公共函数的同时,还需要考虑更复杂的情况:

  • 参数和返回值如何传递?
  • 防抖化之后的函数是否可以立即执行?
  • 防抖化的函数是否可以手动取消?

具体代码如下所示,首先将原函数作为参数传入 debounce() 函数中,同时指定延迟等待时间,返回一个新的函数,这个函数包含 cancel 属性,用来取消原函数执行。flush 属性用来立即调用原函数。

通用防抖函数封装:

// 主要使用的是函数闭包的概念,保存上次点击的时刻,与下一次点击的时刻做对比
function debounce(callback, delay){
    return function () {
        // 保存this和arguments
        const _self = this
        const args = arguments
        // 清除待执行的定时器任务
        if(callback.timer){
            clearTimeout(callback.timer)
        }
        // 将定时器挂载到函数对象上
        callback.timer = setTimeout(()=>{// 因为是箭头函数其实可以不在外部保存this直接绑定this
            callback.apply(_self, args)
            // 执行完后删除函数的timer属性
            delete callback.timer
        },delay)
    }
}
/*
    下面是测试
*/
function handleClick() {
    console.log('点击事件')
}
document.getElementById('throttle').onclick = debounce(handleClick, 1000)
Last modification:May 27, 2020
如果觉得我的文章对你有用,请随意赞赏