前端性能优化之防抖与节流「原理/实现/应用」

930 阅读4分钟

在笔者的上一篇文章《前端性能优化之图片懒加载「多种原生实现+vue指令」》中,曾提到过可以使用函数节流来减少懒加载函数的触发次数,今天我们就来详细介绍一下防抖和节流这两兄弟。

概念介绍

什么是函数防抖(debounce):

函数防抖是指在事件被触发 n 秒后再执行回调函数,如果在这 n 秒内事件又被触发,则重新计时。

什么是函数节流(throttle):

函数节流是指在规定的一个单位时间内,回调函数只能被触发一次,如果在同一个单位时间内某事件被多次触发,仍然只会执行一次它的回调函数。

函数节流的应用场景:

  • 按钮提交场景:防止频繁点击按钮导致的多次提交
  • 搜索联想场景:监听搜索框文本变化,根据用户输入向后端请求联想词时,在用户暂停输入 n 秒后再发送,否则用户输入导致频繁触发文本框内容的变动,导致无效请求增加。

函数节流的应用场景:

  • 拖拽/滚动场景:dragscroll 事件,防止位置变动高频触发事件。
  • 缩放场景:监控浏览器窗口大小变化 (resize事件)
  • 动画场景:避免短时间内多次触发动画引起性能问题

二者的目的都是为了降低回调函数的执行频率,需要结合具体的业务场景选择使用。大体来说,如果需要在事件频繁触发过程中执行回调,比如拖动页面懒加载图片时,一边滚动也应该一边加载图片,这时就应该选用函数节流;如果过程中不需要执行回调,只需要事件最后的结果,选用函数防抖。

代码实现

首先我们在html中写一个简单的文本输入框,模拟一个监听输入框内容变化发送请求的过程(类似于搜索框获取联想词的场景):

// 模拟发送 AJAX 请求
const fakeAjax = (value) => {
    console.log('触发请求:', value)
}

const inputEl = document.getElementsByTagName('input')[0];

inputEl.addEventListener('keyup', (e) => {
    fakeAjax(e.target.value);
})

上图是不做任何处理的情况。

防抖

const fakeAjax = (value) => {
    console.log('触发请求:', value)
}

function debounce(func, wait) {
    let timeout;
    return function () {
        const context = this;
        const args = arguments;
        clearTimeout(timeout);
        timeout = setTimeout(function () {
            func.apply(context, args);
        }, wait);
    };
}

const debouncedFakeAjax = debounce(fakeAjax, 500);

const inputEl = document.getElementsByTagName('input')[0];
inputEl.addEventListener('keyup', (e) => {
    debouncedFakeAjax(e.target.value);
})

让我们一起来分析一下 debounce 函数:

  • 接收两个参数:一个是需要防抖的事件回调,一个是防抖的延迟时间
  • 返回一个函数:返回的函数绑定了调用 debounce 时的上下文,并且设定好了计时逻辑,这个新函数在被调用时,会在指定的延迟时间内等待,如果在这段时间内该函数被多次调用,则只会执行最后一次调用。(timeout 对应的是 setTimeout 返回的计时器的 ID)

我们可以把 debounce 函数看成一个工厂,生产我们需要防抖处理的函数。举个例子,假设我们有一个函数 func,我们希望在调用它时进行防抖处理。我们可以这样做:

// 将你需要防抖处理的函数和触发延迟传给我们的工厂 `debounce`
const debouncedFunc = debounce(func, 500);

得到的这个 debouncedFunc 就是防抖处理后的 funcdebouncedFunc 的入参也和 func 一致,将 debouncedFunc 用作回调绑定到监听事件上即可。

来看看防抖处理后的结果:

可以看到,只有输入停止 500 ms后,才会触发回调函数。

节流

计时器实现

const fakeAjax = (value) => {
  console.log('触发请求:', value)
}

function throttle(func, wait) {
  let timeout;
  return function () {
    const context = this;
    const args = arguments;
    if (timeout) {
      return;
    }
    timeout = setTimeout(function () {
      func.apply(context, args);
      timeout = null;
    }, wait);
  };
}

const throttledFakeAjax = throttle(fakeAjax, 1000);

const inputEl = document.getElementsByTagName('input')[0];
inputEl.addEventListener('keyup', (e) => {
  throttledFakeAjax(e.target.value);
})

可以看到,节流的实现大体上和防抖是很相似的。不同点在于,对于防抖而言,每次触发都要清除已有的计时器;对于节流而言,如果已有计时器,只需要 return 即可。

时间戳实现

除了使用计时器实现节流,也可以采用时间戳来计算单位时间(也许是计时器API出来前的做法?)。

function throttle(func, wait) {
    let previous = 0;
    return function() {
        const now = Date.now();
        const context = this;
        const args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    };
}

《前端性能优化之图片懒加载「多种原生实现+vue指令」》中笔者有提到,图片懒加载不要使用时间戳实现的节流,因为快速滑动到某一位置后即刻停下,会出现无法加载图片的情况。观察上面的代码我们可以发现使用时间戳和使用计时器实现节流的不同点:

计时器需要等待单位时间计时器结束后才能执行一次函数,而时间戳只要判断当前时间间隔大于单位时间了就立即触发,这导致了 时间戳版本的节流函数,执行的是单位时间内函数的第一次触发;而计时器版本的节流函数,执行的是单位时间内函数的后一次触发。