JavaScript中的防抖函数与节流函数

JS中的防抖(debouncing)与节流(throttling)是用来控制一个函数在一定时间内执行的次数(频次),他俩个用处相近、但又不完全相同。

出现原因

为什么会出现这俩个技巧呢?换句话说,为什么要控制函数执行的频次?我们看下面这个例子,当我们在区域内进行滚动时,如果只是监听scroll事件就去执行函数的时候,函数在1s内被执行的次数要超过30次。

监听滚动, 触发函数的次数

0

如果我们在回调函数中做大量运算或Dom操作,函数如此高的执行频次就会造成页面的卡顿。为了避免这种情况,防抖与节流就起到了至关作用

防抖函数

当在一段时间内事件被连续调用时,防抖函数会控制这段时间内函数只会被执行一次。其基本原理就是当函数被调用时设置一个setTimout定时器来延时去执行真正的函数。

延时结束后执行(trailing)

如上图,延时结束后执行是指在这段延时时间内,不再触发函数,则真正的函数才会被执行。其原理是在真正函数被执行之前,如果函数再次被调用,则重置这个延时器。这种延时结束后执行是最普通、使用最频繁的一种。例如:

  1. 当用户输入内容进行请求时,为避免无用的请求,当输入停止时进行请求。(对请求函数进行防抖控制)
  2. 监听窗口改变时,我们只需要计算最终的窗口大小即可。(监听resize时,对回调函数进行防抖控制)

我们也可以手动做一下测试,尝试不同频率的点击下方Click,观察防抖函数带来的改变。

不控制触发事件:

防抖控制触发事件:

延时开始前执行(leading)

如上图,延时开始前执行是指在这段延时时间内,连续的触发函数,只会在最开始执行一次。其原理是设置定时器前执行一次真正的函数,这时定时器作用只是为了标识此次延时时间内不能执行函数。这种延时开始前执行使用场景比较少,例如:

  1. 当用户点击刷新按钮时,可以尽早的执行函数。因为前后执行其实是一样的效果。本质上只是为了防止用户疯狂刷新

同样,我们可以在下方通过不同频率的点击Click进行测试。

不控制触发事件:

防抖控制触发事件:

节流函数

节流函数的原理其实和防抖函数基本相同,不同的是,节流函数会设置一个最长等待执行时间,也就是说节流函数控制在一定时间内函数一定会执行一次。

像之前的防抖函数(延时结束后执行),如果我们一直在触发事件,那定时器会一直处于重置状态,真正的函数永远不会被执行,而节流函数会保证在一定时间内,执行一次。想象一下,如果我们要实现触底加载功能,监听scroll事件,在滚动状态下,我们不仅要控制频次,还需要隔一段时间去检查距底部距离。 这时,我们就需要节流函数(throttle)来控制。

同样你可以在下方俩侧进行测试,左侧因为使用debounce进行控制,只有当滚动停止时才会判断距底部距离,所以会造成卡顿的效果,而右边的通过throttle控制,体验上要好。

1
2
3
4
1
2
3
4

参考

附录

lodash的debounce源码解析

function debounce(func, wait, options) {
  let lastArgs, // 记录上一次参数
    lastThis, // 记录上一次this值
    maxWait, // 最大等待时间, 用于节流函数(throttle)
    result, // 要返回的结果
    timerId, // 保存定时器
    lastCallTime // 记录上一次触发时间

  let lastInvokeTime = 0 // 记录上一次真正执行的时间
  let leading = false // 是否 延时开始前执行的 标识
  let maxing = false // 是否 传入maxWait 的标识
  let trailing = true // 是否 延时结束后执行 的标识

  // 通过设置wait = 0 可以跳过 requestAnimationFrame
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

  // 传入的func不是函数 抛出错误
  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +wait || 0 // 转化成数值

  // 如果options是对象,对参数进行赋值
  if (isObject(options)) {
    leading = !!options.leading
    maxing = 'maxWait' in options
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait 
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }

  //执行真正的函数
  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    // 将上一次参数、this值赋值为空,这样trailingEdge阶段不会再次执行
    lastArgs = lastThis = undefined 
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }

  // 开始定时器,优先使用requestAnimationFrame
  function startTimer(pendingFunc, wait) {
    if (useRAF) {
      root.cancelAnimationFrame(timerId);
      return root.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }

  // 取消定时器
  function cancelTimer(id) {
    if (useRAF) {
      return root.cancelAnimationFrame(id)
    }
    clearTimeout(id)
  }

  // 延时开始前阶段(leading阶段)
  function leadingEdge(time) {
    // 设置被调用时间
    lastInvokeTime = time
    // 开始设置定时器,进入延时结束后阶段(trailing阶段)
    timerId = startTimer(timerExpired, wait)
    // 如果传入的leading为true,代表延时开始前要执行,立即执行。
    return leading ? invokeFunc(time) : result
  }

  // 计算剩余时间
  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime // 距离上一次触发时间
    const timeSinceLastInvoke = time - lastInvokeTime // 距离上一次执行时间
    const timeWaiting = wait - timeSinceLastCall // 等待剩余时间

    // 如果有maxWait,则选取timeWaiting 和 还需等待被执行的时间 最小值,
    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }

  // 判断是否可以调用,有四种情况下返回true
  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime

    // 1. lastCallTime为undefined,第一次被触发
    // 2. 距离上一次被触发的时间 大于 wait,既此次触发是在延时结束后 trailing阶段。
    // 3. 系统时间倒叙
    // 4. 传入了maxWait,且距上次调用时间已经超过了最大等待时间,应该被执行
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  // 判断是否被再次触发啦,重置定时器,计算剩余的时间
  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      // 如果可以调用,则进入延时结束后阶段(trailing阶段)
      return trailingEdge(time)
    }
    // Restart the timer.
    timerId = startTimer(timerExpired, remainingWait(time))
  }

  // 延时结束后阶段(trailing阶段)
  function trailingEdge(time) {
    timerId = undefined

    // 如果lastArgs有值代表再次被触发,因为lastArgs在debounced函数中被赋值
    // 1. trailing为false,代表延时结束后不执行。
    // 2. lastArgs为undefined,当leading传入true,会直接执行函数,将lastArgs设置空,
    //    当且仅当debounced函数被触发一次,且leading为true时。
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }

  // 取消定时器,将闭包中的变量置位初始值
  function cancel() {
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    lastInvokeTime = 0
    lastArgs = lastCallTime = lastThis = timerId = undefined
  }

  // 立即执行,进入延时结束后阶段。
  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }

  // 是否已经处于延时阶段,定时器不为空则代表处于该阶段
  function pending() {
    return timerId !== undefined
  }

  function debounced(...args) {
    const time = Date.now()
    const isInvoking = shouldInvoke(time)

    lastArgs = args
    lastThis = this
    lastCallTime = time // 每次被触发,重新对lastCallTime赋值

    // 允许被调用
    if (isInvoking) {
      // 第一次被触发,定时器不存在,进入延时开始前阶段(leading阶段)
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      // 如果传了maxWait,跳过延时开始前阶段(leading阶段)
      if (maxing) {
        timerId = startTimer(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    // 定时器不存在时,设置定时器
    if (timerId === undefined) {
      timerId = startTimer(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}

lodash的throttle源码解析

/**
 * 节流函数其实是调用防抖函数(debounce),传入maxWait,
 * 我们也可以自己使用debounce函数来主动传入一个maxWait
 */
function throttle(func, wait, options) {
  // 默认延时阶段前后都会执行函数
  let leading = true
  let trailing = true

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }
  // 调用防抖函数(debounce)传入maxWait, maxWait === wait
  // 不接受 options.maxWait 是因为maxWait大于、或小于wait都不合理。
  return debounce(func, wait, {
    leading,
    trailing,
    'maxWait': wait,
  })
}