16

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

节流(throttle)

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

防抖(debounce)

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

两者应用场景

初看觉得两个概念好像差不多啊,到底什么时候用节流什么时候用防抖呢?

防抖常用场景

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

  • 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';

基本防抖实现

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

// 我的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。

参考资料


侯贝贝
329 声望27 粉丝