每日源码分析 - lodash(debounce.js和throttle.js)

6,980 阅读9分钟

本系列使用 lodash 4.17.4

前言

本文件引用了isObject函数

import isObject from './isObject.js' 判断变量是否是广义的对象(对象、数组、函数), 不包括null

正文

import isObject from './isObject.js'

/**
 * Creates a debounced function that delays invoking `func` until after `wait`
 * milliseconds have elapsed since the last time the debounced function was
 * invoked. The debounced function comes with a `cancel` method to cancel
 * delayed `func` invocations and a `flush` method to immediately invoke them.
 * Provide `options` to indicate whether `func` should be invoked on the
 * leading and/or trailing edge of the `wait` timeout. The `func` is invoked
 * with the last arguments provided to the debounced function. Subsequent
 * calls to the debounced function return the result of the last `func`
 * invocation.
 *
 * **Note:** If `leading` and `trailing` options are `true`, `func` is
 * invoked on the trailing edge of the timeout only if the debounced function
 * is invoked more than once during the `wait` timeout.
 *
 * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
 * until the next tick, similar to `setTimeout` with a timeout of `0`.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @since 0.1.0
 * @category Function
 * @param {Function} func The function to debounce.
 * @param {number} [wait=0] The number of milliseconds to delay.
 * @param {Object} [options={}] The options object.
 * @param {boolean} [options.leading=false]
 *  Specify invoking on the leading edge of the timeout.
 * @param {number} [options.maxWait]
 *  The maximum time `func` is allowed to be delayed before it's invoked.
 * @param {boolean} [options.trailing=true]
 *  Specify invoking on the trailing edge of the timeout.
 * @returns {Function} Returns the new debounced function.
 * @example
 *
 * // Avoid costly calculations while the window size is in flux.
 * jQuery(window).on('resize', debounce(calculateLayout, 150))
 *
 * // Invoke `sendMail` when clicked, debouncing subsequent calls.
 * jQuery(element).on('click', debounce(sendMail, 300, {
 *   'leading': true,
 *   'trailing': false
 * }))
 *
 * // Ensure `batchLog` is invoked once after 1 second of debounced calls.
 * const debounced = debounce(batchLog, 250, { 'maxWait': 1000 })
 * const source = new EventSource('/stream')
 * jQuery(source).on('message', debounced)
 *
 * // Cancel the trailing debounced invocation.
 * jQuery(window).on('popstate', debounced.cancel)
 *
 * // Check for pending invocations.
 * const status = debounced.pending() ? "Pending..." : "Ready"
 */
function debounce(func, wait, options) {
  let lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime

  let lastInvokeTime = 0
  let leading = false
  let maxing = false
  let trailing = true

  if (typeof func != 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +wait || 0
  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

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }

  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = setTimeout(timerExpired, wait)
    // Invoke the leading edge.
    return leading ? invokeFunc(time) : result
  }

  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime
    const timeWaiting = wait - timeSinceLastCall

    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }

  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime

    // Either this is the first call, activity has stopped and we're at the
    // trailing edge, the system time has gone backwards and we're treating
    // it as the trailing edge, or we've hit the `maxWait` limit.
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // Restart the timer.
    timerId = setTimeout(timerExpired, remainingWait(time))
  }

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }

  function cancel() {
    if (timerId !== undefined) {
      clearTimeout(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

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = setTimeout(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}

export default debounce

使用方式

函数防抖(debounce)

函数防抖(debounce)和函数节流(throttle)相信有一定前端基础的应该都知道,不过还是简单说一下

防抖(debounce)就是把多个顺序的调用合并到一起(只执行一次),这在某些情况下对性能会有极大的优化(后面使用场景会说几个)。

图片来自css-tricks

debounce

在lodash的options中提供了一个leading属性,这个属性让其在开始的时候触发。

图片来自css-tricks

leading

// debounce函数的简单使用
var log = function() {
    console.log("log after stop moving");
}
document.addEventListener('mousemove', debounce(log, 500))

函数节流(throttle)

使用throttle时,只允许一个函数在 X 毫秒内执行一次。

比如你设置了400ms,那么即使你在这400ms里面调用了100次,也只有一次执行。跟 debounce 主要的不同在于,throttle 保证 X 毫秒内至少执行一次。

在lodash的实现中,throttle主要借助了debounce来实现。

// throttle函数的简单使用
var log = function() {
    console.log("log every 500ms");
}
document.addEventListener('mousemove', throttle(log, 500))

使用场景

我尽量总结一下debounce和throttle函数实际的应用场景

防抖(debounce)

1. 自动补全(autocomplete)性能优化

自动补全很多地方都有,基本无一例外都是通过发出异步请求将当前内容作为参数传给服务器,然后服务器回传备选项。

那么问题来了,如果我每输入一个字符都要发出个异步请求,那么异步请求的个数会不会太多了呢?因为实际上用户可能只需要输入完后给出的备选项

这时候就可以使用防抖,比如当输入框input事件触发隔了1000ms的时候我再发起异步请求。

2. 原生事件性能优化

想象一下,我有个使用js进行自适应的元素,那么很自然,我需要考虑我浏览器窗口发生resize事件的时候我要去重新计算它的位置。现在问题来了,我们看看resize一次触发多少次。

window.addEventListener('resize', function() {
  console.log('resize')
})

至少在我电脑上,稍微改变一下就会触发几次resize事件,而用js去自适应的话会有较多的DOM操作,我们都知道DOM操作很浪费时间,所以对于resize事件我们是不是可以用debounce让它最后再计算位置?当然如果你觉得最后才去计算位置或者一些属性会不太即时,你可以继续往下看看函数节流(throttle)

节流(throttle)

和防抖一样,节流也可以用于原生事件的优化。我们看下面几个例子

图片懒加载

图片懒加载(lazyload)可能很多人都知道,如果我们浏览一个图片很多的网站的话,我们不希望所有的图片在一开始就加载了,一是浪费流量,可能用户不关心下面的图片呢。二是性能,那么多图片一起下载,性能爆炸。

那么一般我们都会让图片懒加载,让一个图片一开始在页面中的标签为

<img src="#" data-src="我是真正的src">

当我屏幕滚动到能显示这个img标签的位置时,我用data-src去替换src的内容,变为

<img src="我是真正的src" data-src="我是真正的src">

大家都知道如果直接改变src的话浏览器也会直接发出一个请求,在红宝书(JS高程)里面的跨域部分还提了一下用img标签的src做跨域。这时候图片才会显示出来。

关于怎么判断一个元素出现在屏幕中的,大家可以去看看这个函数getBoundingClientRect(),这里就不扩展的讲了

好的,那么问题来了,我既然要检测元素是否在浏览器内,那我肯定得在scroll事件上绑定检测函数吧。scroll函数和resize函数一样,滑动一下事件触发几十上百次,读者可以自己试一下。

document.addEventListener('scroll', function() {
  console.log('scroll')
})

好的,你的检测元素是否在浏览器内的函数每次要检查所有的img标签(至少是所有没有替换src的),而且滑一次要执行几十次,你懂我的意思。

throttle正是你的救星,你可以让检测函数每300ms运行一次。

拖动和拉伸

你以为你只需要防备resizescroll么,太天真了,看下面几个例子。

或者想做类似原生窗口调整大小的效果

那么你一定会需要mousedownmouseupmousemove事件,前两个用于拖动的开始和结束时的状态变化(比如你要加个标识标识开始拖动了)。mousemove则是用来调整元素的位置或者宽高。那么同样的我们来看看mousemove事件的触发频率。

document.addEventListener('mousemove', function() {
  console.log('mousemove')
})

我相信你现在已经知道它比scroll还恐怖而且可以让性能瞬间爆炸。那么这时候我们就可以用函数节流让它300ms触发一次位置计算。

源码分析

debounce.js

这个文件的核心和入口是debounced函数,我们先看看它

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

  lastArgs = args       // 记录最后一次调用传入的参数
  lastThis = this       // 记录最后一次调用的this
  lastCallTime = time   // 记录最后一次调用的时间

  if (isInvoking) {
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    if (maxing) {
      // Handle invocations in a tight loop.
      timerId = setTimeout(timerExpired, wait)
      return invokeFunc(lastCallTime)
    }
  }
  if (timerId === undefined) {
    timerId = setTimeout(timerExpired, wait)
  }
  return result
}

这里面很多变量,用闭包存下的一些值

其实就是保存最后一次调用的上下文(lastThis, lastAargs, lastCallTime)还有定时器的Id之类的。

然后下面是执行部分, 由于maxing是和throttle有关的,为了理解方便这里暂时不看它。

  // isInvoking可以暂时理解为第一次或者当上一次触发时间超过设置wait的时候为真
  if (isInvoking) {
    // 第一次触发的时候没有加timer
    if (timerId === undefined) {
      // 和上文说的leading有关
      return leadingEdge(lastCallTime)
    }
    //if (maxing) {
    //  // Handle invocations in a tight loop.
    //  timerId = setTimeout(timerExpired, wait)
    //  return invokeFunc(lastCallTime)
    //}
  }
  // 第一次触发的时候添加定时器
  if (timerId === undefined) {
    timerId = setTimeout(timerExpired, wait)
  }

接下来我们看看这个timerExpired的内容

  function timerExpired() {
    const time = Date.now()
    // 这里的这个判断基本只用作判断timeSinceLastCall是否超过设置的wait
    if (shouldInvoke(time)) {
      // 实际调用函数部分
      return trailingEdge(time)
    }
    // 如果timeSinceLastCall还没超过设置的wait,重置定时器之后再进一遍timerExpired
    timerId = setTimeout(timerExpired, remainingWait(time))
  }

trailingEdge函数其实就是执行一下invokeFunc然后清空一下定时器还有一些上下文,这样下次再执行debounce过的函数的时候就能够继续下一轮了,没什么值得说的

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }

总结一下其实就是下面这些东西,不过提供了一些配置和可复用性(throttle部分)所以代码就复杂了些。

// debounce简单实现
var debounce = function(wait, func){
  var timerId
  return function(){
    var thisArg = this, args = arguments
    clearTimeout(last)
    timerId = setTimeout(function(){
        func.apply(thisArg, args)
    }, wait)
  }
}

throttle.js

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
  }
  return debounce(func, wait, {
    'leading': leading,
    'maxWait': wait,
    'trailing': trailing
  })
}

其实基本用的都是debounce.js里面的内容,只是多了个maxWait参数,还记得之前分析debounce的时候被我们注释的部分么。

  if (isInvoking) {
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    // **看这里**,如果有maxWait那么maxing就为真
    if (maxing) {
      // Handle invocations in a tight loop.
      timerId = setTimeout(timerExpired, wait)
      return invokeFunc(lastCallTime)
    }
  }
  if (timerId === undefined) {
    timerId = setTimeout(timerExpired, wait)
  }

可以看到remainingWait和shouldInvoke中也都对maxing进行了判断

总结一下其实就是下面这样

// throttle的简单实现,定时器都没用
var throttle = function(wait, func){
  var last = 0
  return function(){
    var time = +new Date()
    if (time - last > wait){
      func.apply(this, arguments)
      last = curr 
    }
  }
}

本文章来源于午安煎饼计划Web组 - 梁王