聊聊节流与防抖

闲言

最近和朋友聊天时聊到一个问题:我们在百度 input 框输入的时候,如何判断用户已经输入完毕,然后自动给他检索关键词。

我是这样想的:
1.在用户停止输入时,到往后某一个时间点输入事件不再触发。这就可以说明用户已经输入完毕了。
2.用户持续输入,监听输入事件的回调函数肯定会连续触发,那么我们就需要控制 检索接口 调用频率(不可能用户输入一个字我就去发一次 ajax)。

防抖的场景描述清楚了,我们直接来说说相关概念吧。

防抖

DeBounce,事件持续触发,但是回调里要执行的相关方法只会在 事件触发 n 秒后 执行,如果 事件触发 n 秒内 又触发该事件,那就以新的事件触发时间为准,n 秒后才执行。

按照这个概念,我们根据前边说的搜索框的例子,可以得到如下代码:

html

<!-- 搜索框 -->
<input id="search" type="text" />
<!-- 模拟搜索页面效果 -->
<div id="searchPage"></div>

js

let searchInput = document.getElementById('search');

// 防抖
function deBounce(fn, delay) {
  let timer;
  return function () {
    clearTimeout(timer);
    timer = setTimeout(fn, delay)
  }
}

let searchPage = document.getElementById('searchPage');
// 调用接口
function ajaxReq() {
  console.log('ajax request had send!', new Date())
  searchPage.innerHTML = `您搜索关键词:${searchInput.value}`;
}
searchInput.oninput = deBounce(ajaxReq, 1000);

显然,这是符合我们预期的,但是这代码还存在优化空间。

优化

this

首先,正常逻辑来说,在不使用 debounce 函数时,ajaxReq 中打印 this 值是:

使用了 debounce 后,this 指向 window 对象。

我们将 debounce 函数修改如下:

function deBounce(fn, delay) {
    let timer;
        return function () {
            clearTimeout(timer);
            timer = setTimeout(() => {
                fn.apply(this);
            }, delay)
    }
}

现在 this 在 ajaxReq 中指向正确了。

event 对象

Js 在事件处理函数中会提供事件对象 event。在不使用 debounce 函数时,ajaxReq 中打印 event 对象:

使用了 debounce 函数后,打印 event 对象为 undefined。

我们继续对 debounce 函数进行优化:

function deBounce(fn, delay) {
    let timer;
    return function () {
        let args = arguments;
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay)
    }
}

此时,我们已经优化了两个问题:

1.this 指向
2.event 对象

节流

Throttle,控制调用函数频率,每隔一段时间,只执行一次。一般有两种实现方案,一种是时间戳,一种是定时器。

我们此时还以 input 框为例子,场景变为:在用户输入的过程中,隔 n 秒只发一次 ajax。

时间戳

当触发事件的时候,取出当前的时间戳,减去之前的时间戳(开始值为 0),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。

html

<!-- 搜索框 -->
<input id="search" type="text" />
<!-- 模拟搜索页面效果 -->
<div id="searchPage"></div>

js

// 节流-时间戳
function throttle(fn, interval) {
  let previous = 0;
  return function () {
    let context = this;
    let args = arguments;
    let now = new Date();
    if (now - previous > interval) {
      fn.call(context, args);
      previous = now;
    }
  }
}

let searchPage = document.getElementById('searchPage');
// 调用接口
function ajaxReq(e) {
    console.log(e);
  console.log('ajax request had send!', new Date())
  searchPage.innerHTML = `您搜索关键词:${searchInput.value}`;
}
searchInput.oninput = throttle(ajaxReq, 1000);

大家可以尝试:第一次输入时事件立刻执行。一直输入的话,每过 1s 会执行一次。当输入间隔小于 1s 时,是不会去发 ajax 的。

定时器

当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。

// 节流-计时器
function throttle(fn, interval) {
  let previous = 0;
  let timer;
  return function () {
    let context = this;
    let args = arguments;
    if (!timer) {
      timer = setTimeout(() => {
          timer = null;
        fn.call(context, args);
      }, interval);
    }
  }
}

大家可以尝试:第一次输入时,时间不会立即执行,而是在 1 秒后才执行。一直输入的话,每过 1s 会执行一次。当事件停止时,它还会再执行一次。

定时器 + 时间戳

现在希望:第一次输入时事件立刻执行,一直输入的话,每过 1s 会执行一次。当事件停止时,它还会再执行一次。

// 节流-计时器 + 时间戳
function throttle(fn, interval) {
  let previous = 0;
  let timer;
  return function () {
    let context = this;
    let args = arguments;
    let now = new Date();
        if (now - previous > interval) {
            // 若计时器存在,则清除计时器,并将timer置空
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
            previous = now;
      fn.call(context, args);
        } else if (!timer) {
      timer = setTimeout(() => {
          previous = new Date();
          timer = null;
        fn.call(context, args);
      }, interval);
    }
  }
}

区别

区别: 函数节流不管事件触发有多频繁,都会保证在规定时间内一定会执行一次真正的事件处理函数,而函数防抖只是在最后一次事件后才触发一次函数。 比如在页面的无限加载场景下,我们需要用户在滚动页面时,每隔一段时间发一次 Ajax 请求,而不是在用户停下滚动页面操作时才去请求数据。这样的场景,就适合用节流技术来实现。

参考

前端性能——JS的防抖和节流是什么?
loadsh 源码
聊聊lodash的debounce实现
函数节流(throttle)与函数去抖(debounce)
JavaScript专题之跟着underscore学防抖
JavaScript专题之跟着 underscore 学节流