函数防抖(debounce)和节流(throttle)以及lodash的debounce源码赏析

函数节流和去抖的出现场景,通常都伴随着客户端 DOM 的事件监听。好比scroll resize等事件,这些事件在某些场景触发很是频繁。
好比,实现一个原生的拖拽功能(不能用 H5 Drag&Drop API),须要一路监听 mousemove 事件,在回调中获取元素当前位置,而后重置 dom 的位置(样式改变)。若是咱们不加以控制,每移动必定像素而触发的回调数量是会很是惊人的,回调中又伴随着 DOM 操做,继而引起浏览器的重排与重绘,性能差的浏览器可能就会直接假死,这样的用户体验是很是糟糕的。
咱们须要作的是下降触发回调的频率,好比让它 500ms 触发一次,或者 200ms,甚至 100ms,这个阈值不能太大,太大了拖拽就会失真,也不能过小,过小了低版本浏览器可能就会假死,这样的解决方案就是函数节流,英文名字叫「throttle」。git

节流(throttle)

函数节流的核心是,让一个函数不要执行得太频繁,减小一些过快的调用来节流。也就是在一段固定的时间内只触发一次回调函数,即使在这段时间内某个事件屡次被触发也只触发回调一次。github

防抖(debounce)

函数防抖(debounce)和节流是一对经常被放在一块儿的场景。防抖的原理是在事件被触发n秒后再执行回调,若是在这n秒内又被触发,则从新计时。也就是说事件来了,先setTimeout定个时,n秒后再去触发回调函数。它和节流的不一样在于若是某段时间内事件以间隔小于n秒的频率执行,那么这段时间回调只会触发一次。节流则是按照200ms或者300ms定时触发,而不只仅是一次。ajax

二者应用场景

初看以为两个概念好像差很少啊,到底何时用节流何时用防抖呢?编程

防抖经常使用场景

防抖的应用场景是连续的事件响应咱们只触发一次回调,好比下面的场景:segmentfault

  • resize/scroll 触发统计事件
  • 文本输入验证,不用用户输一个文字调用一次ajax请求,随着用户的输入验证一次就能够

节流经常使用场景

节流是个很公平的函数,隔一段时间就来触发回调,好比下面的场景:浏览器

  • DOM 元素的拖拽功能实现(mousemove)
  • 计算鼠标移动的距离(mousemove)
  • 搜索联想(keyup)

为何这些适合节流而不是防抖呢?
咱们想一想哈,按照防抖的概念若是n秒内用户接二连三触发事件,则防抖会在用户结束操做结束后触发回调。 那对于拖动来讲,我拖了半天没啥反应,一松手等n秒,啪。元素蹦过来了,这仍是拖动吗?这是跳动吧,2333;闭包

lodash源码实现

基本节流实现

function throttle(func, gapTime){
    if(typeof func !== 'function') {
        throw new TypeError('need a function');
    }
    gapTime = +gapTime || 0;
    let lastTime = 0;
    
    return function() {
        let time = + new Date();
        if(time - lastTime > gapTime || !lastTime) {
            func();
            lastTime = time;
        }
    }
}

setInterval(throttle(() => {
    console.log('xxx')
}, 1000),10)

如上,没10ms触发一次,但事实上是每1s打印一次 'xxx';app

基本防抖实现

弄清防抖的原理后,咱们先来实现一个简单的 debounce 函数。dom

// 个人debounce 实现
function my_debounce(func, wait) {

    if(typeof func !== 'function') {
        throw new TypeError('need a function');
    }
    wait = +wait || 0;

    let timeId = null;

    return function() {
        // console.log('滚动了滚动了');  // 测试时可放开
        const self = this;
        const args = arguments;

        if(timeId) {
            clearTimeout(timeId);   // 清除定时器,从新设定一个新的定时器
        }
        timeId =  setTimeout(() => {
            func.apply(self, args); // arguments 是传给函数的参数,这里是 event  对象

        }, wait);

    }

}

咱们来分析一下这个函数, 首先它是一个闭包。它的核心是 定时器的设置,若是第一次进来, timeId 不存在直接设置一个延迟 wait 毫秒的定时器; 若是timeId 已经存在则先清除定时器再 从新设置延迟。
如上所说,若是在延迟时间内 来了一个事件,则从这个事件到来的时候开始定时。
用该防抖函数试一下对scroll去抖效果。我在 防抖函数中放开日志 console.log('滚动了滚动了');, 而后
对滚动添加事件响应.函数式编程

function onScroll_1() {
   console.log('执行滚动处理函数啦');  
}
window.addEventListener('scroll', my_debounce(onScroll_1, 1000));

打开页面,不断滚动能够在控制台看到以下图的console.

从图中能够看出,我触发了90次滚动响应,但实际上 滚动处理函数执行了一次。
嗯,对上面简单的例子咱们分析下不一样状况下,4秒时间内防抖调用的时机和次数.

  1. 每隔1.5秒滚动一次,4秒内等待1秒触发的状况下,会调用响应函数 2次
  2. 每隔 0.5 秒滚动一次,4秒内等待1秒触发的状况下,一次也不会调用。

下图展现了这两种状况下定时器设置和函数调用状况(费死个猴劲画的,凑合看不清楚的能够留言)

从上面的分析来看,这个单纯的 防抖函数仍是有个硬伤的,是什么呢?
那就是每次触发定时器就从新来,每次都从新来,若是某段时间用户一直一直触发,防抖函数一直从新设置定时器,就是不执行,频繁的延迟会致使用户迟迟得不到响应,用户一样会产生“这个页面卡死了”的观感。
既然如此,那咱们是否是能够设置一个最常等待时间,超过这个事件无论还有没有事件在触发,就去执行函数呢?或者我可不能够设置第一次触发的时候当即执行函数,再次触发的时候再去防抖,也就是说无论如何先 响应一次,告诉那些 心急的 用户我响应你啦,我是正常的,接下来慢慢来哦~
答案是,都是能够的。这些属于更自由的配置,加上这些, debounce 就是一个成熟的防抖函数了。嗯,是哒~成熟的

既然说到成熟,我们仍是来看下大名鼎鼎的==lodash==库是怎么将 debounce 成熟的吧!

loadsh中debounce源码解读

为了方便,咱们忽略lodash 开始对function的注释完里整版在这 。成熟的 debounce 也才 100多行而已,小场面~~

先来看下完整函数,里面加上了我本身的理解,而后再详细分析

function debounce(func, wait, options) {
  let lastArgs,     // debounced 被调用后被赋值,表示至少调用 debounced一次
    lastThis,   // 保存 this
    maxWait,     // 最大等待时间
    result,      // return 的结果,可能一直为 undefined,没看到特别的做用
    timerId,    // 定时器句柄
    lastCallTime    // 上一次调用 debounced 的时间,按上面例子能够理解为 上一次触发 scroll 的时间

  let lastInvokeTime = 0  // 上一次执行 func 的时间,按上面例子能够理解为 上次 执行 时的时间
  let leading = false     // 是否第一次触发时当即执行
  let maxing = false     // 是否有最长等待时间
  let trailing = true    // 是否在等待周期结束后执行用户传入的函数

  // window.requestAnimationFrame() 方法告诉浏览器您但愿执行动画并请求浏览器在下一次重绘以前调用指定的函数来更新动画。该方法使用一个回调函数做为参数,这个回调函数会在浏览器重绘以前调用。
  // 下面的代码我先注释,能够先不关注~意思是没传 wait 时 会在某个时候 调用 window.requestAnimationFrame()
  <!--const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')-->
  //  以上代码被我注释,能够先不关注

 // 这个很好理解,若是传入的 func 不是函数,抛出错误,老子干不了这样的活
  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
  }

//  执行 用户传入的 func
//  重置 lastArgs,lastThis
//  lastInvokeTime 在此时被赋值,记录上一次调用 func的时间
  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

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

//  setTimeout 一个定时器
  function startTimer(pendingFunc, wait) {
  // 先不关注这个
    //if (useRAF) {
      //return root.requestAnimationFrame(pendingFunc)
    //}
    return setTimeout(pendingFunc, wait)
  }

//  清除定时器
  function cancelTimer(id) {
    // 先不关注
    //if (useRAF) {
      //return root.cancelAnimationFrame(id)
    //}
    clearTimeout(id)
  }

//  防抖开始时执行的操做
//  lastInvokeTime 在此时被赋值,记录上一次调用 func的时间
//  设置了当即执行func,则执行func, 不然设置定时器
  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = startTimer(timerExpired, wait)
    // Invoke the leading edge.
    return leading ? invokeFunc(time) : result
  }

//  计算还须要等待多久
//  没设置最大等待时间,结果为 wait - (当前时间 - 上一次触发(scroll) )  时间,也就是  wait - 已经等候时间
//  设置了最长等待时间,结果为 最长等待时间 和 按照wait 计算还须要等待时间 的最小值
  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime
    const timeWaiting = wait - timeSinceLastCall

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

// 此时是否应该设置定时器/执行用户传入的函数,有四种状况应该执行
// 1, 第一次触发(scroll)
// 2. 距离上次触发超过 wait, 参考上面例子中 1.5 秒触发一次,在3s触发的状况
// 3.当前时间小于 上次触发时间,大概是系统时间被人为日后拨了,原本2018年,系统时间变为 2017年了,嘎嘎嘎
// 4. 设置了最长等待时间,而且等待时长不小于 最长等待时间了~ 参考上面例子,若是maxWait 为2s, 则在 2s scroll 时会执行
  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime

    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 = startTimer(timerExpired, remainingWait(time))
  }

// 执行用户传入的 func 以前的最后一道屏障  func os: 执行我一次能咋地,这么多屏障?
// 重置 定时器
// 执行 func
// 重置 lastArgs = lastThis 为 undefined
  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) {
      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

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        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
  //  下面这句话证实 debounced 我是入口函数,是正宫娘娘!
  return debounced
}

export default debounce

第一看是否是有点晕?不要紧,咱们结合例子理一遍 这个成熟的 debounce 是如何运做的。

用demo 理解 loadsh debounce

调用以下:

function onScroll_1() {
   console.log('执行滚动处理函数啦');  
}
window.addEventListener('scroll', debounce(onScroll_1, 1000));
  1. 每 1500 ms 触发(scroll)一次
  2. 每 600 ms 触发(scroll)一次

再来看一下入口函数 debounced。

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

    lastArgs = args     //  args 是 event 对象,是点击、scroll等事件传过来的
    lastThis = this
    lastCallTime = time

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

1500ms时scroll,开始执行 debounced:

  1. 首先判断shouldInvoke(time),由于第一次 lastCallTime === undefined 因此返回true;
  2. 而且此时 timerId === undefined, 因此执行 leadingEdge(lastCallTime);
  3. 在 leadingEdge(lastCallTime) 函数中,设置 lastInvokeTime = time,这个挺关键的,而且设定一个 1000ms的定时器,若是leading 为true,则invokefunc,咱们没有设置leading这种状况不表~
  4. 1500ms~2500ms 之间没什么事,定时器到点,执行 invokeFunc(time);
  5. invokeFunc 中再次设置 lastInvokeTime, 并重置 lastThis,lastArgs;
  6. 第一次 scroll 完毕,接下来是 3000ms,这种间隔很大的调用与单纯的 debounce 没有太大差异,4s结束会执行 2次。

每 600ms 执行一次:
先用文字描述吧:
首次进入函数时由于 lastCallTime === undefined 而且 timerId === undefined,因此会执行 leadingEdge,若是此时 leading 为 true 的话,就会执行 func。同时,这里会设置一个定时器,在等待 wait(s) 后会执行 timerExpired,timerExpired 的主要做用就是触发 trailing。

若是在还未到 wait 的时候就再次调用了函数的话,会更新 lastCallTime,而且由于此时 isInvoking 不知足条件,因此此次什么也不会执行。

时间到达 wait 时,就会执行咱们一开始设定的定时器timerExpired,此时由于time-lastCallTime < wait,因此不会执行 trailingEdge。

这时又会新增一个定时器,下一次执行的时间是 remainingWait,这里会根据是否有 maxwait 来做区分:

若是没有 maxwait,定时器的时间是 wait - timeSinceLastCall,保证下一次 trailing 的执行。

若是有 maxing,会比较出下一次 maxing 和下一次 trailing 的最小值,做为下一次函数要执行的时间。

最后,若是再也不有函数调用,就会在定时器结束时执行 trailingEdge。
简单画了个以时间为轴,函数执行的状况:
看不懂的多看两遍吧~~~

在没配置其余参数的状况下,连续触发也是不执行,那咱们增长一下 maxWait试一下:

function onScroll_1() {
   console.log('执行滚动处理函数啦');  
}
window.addEventListener('scroll', debounce(onScroll_1, 1000, {
    maxWait: 1000
}));

文字描述过程:
首次进入函数时由于 lastCallTime === undefined 而且 timerId === undefined,因此会执行 leadingEdge,这里会设置一个定时器,在等待 wait(s) 后会执行 timerExpired,timerExpired 的主要做用就是触发 trailing。

若是在还未到 wait 的时候就再次调用了函数的话,会更新 lastCallTime,而且由于此时 isInvoking 不知足条件,因此此次什么也不会执行。

时间到达 wait 时,就会执行咱们一开始设定的定时器timerExpired,此时由于time-lastCallTime < wait,若是因此不会执行 trailingEdge。可是若是设置了maxWait,这里还会判断 time-lastInvokeTime > maxWait,(参考上图中1600ms处,会执行) 若是是则 trailingEdge。

这时又会新增一个定时器,下一次执行的时间是 remainingWait,这里会根据是否有 maxwait 来做区分:

若是没有 maxwait,定时器的时间是 wait - timeSinceLastCall,保证下一次 trailing 的执行。

若是有 maxing,会比较出下一次 maxing 和下一次 trailing 的最小值,做为下一次函数要执行的时间。

最后,若是再也不有函数调用,就会在定时器结束时执行 trailingEdge

常见问题,防抖函数如何传参

其实纠结这个问题的同窗,看看函数式编程会理解一些~
其实很简单,my_debounce会返回一个函数,那在函数调用时加上参数就OK了~~

window.addEventListener('scroll', my_debounce(onScroll_1, 1000)('test'));

咱们的 onScroll_1 这样写,就把'test' 传给 params了。。

function onScroll_1(params) {
    console.log('onScroll_1', params);   // test
    console.log('执行滚动处理函数啦');  
}

不过通常咱们不会这样写吧,由于新传的值会将 原来的 event 给覆盖掉,也就拿不到 scroll 或者 mouseclick等事件对象 event 了~~
那你说,我既想获取到 event对象,又想传参,怎么办?
个人办法是,在本身的监听函数上动手脚,好比个人onScroll 函数这样写:

function onScroll(param) {
    console.log('param:', param);  // test
       return function(event) {
           console.log('event:', event);  // event
   }
}

以下这样使用 debounce

window.addEventListener('scroll', my_debounce(onScroll('test'), 1000));

控制台的日志确实如此~~

loadsh中throttle

有了 debounce的基础loadsh对throttle的实现就很是简单了,就是一个传了 maxWait的debounce.

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
  })
}

上面已经分析了这种状况,它的结果是若是接二连三触发则每隔 wait 秒执行一次func。

参考资料

相关文章
相关标签/搜索