Skip to content

防抖与节流

Published:

为什么需要防抖与节流

在网页上,我们经常需要处理用户的各种操作,比如点击按钮、输入文本、滚动页面等。这些操作通常会触发很多事件,但如果频繁触发,就会带来几个问题:

  1. 性能问题:频繁的事件触发可能导致页面卡顿,特别是在移动设备上。
  2. 网络请求过多:例如每次用户输入都触发一次请求,如果输入速度很快,会发出大量请求,增加请求 IO。
  3. 数据不一致:频繁的异步操作可能导致数据状态混乱。
  4. 用户体验差:频繁的事件触发可能导致用户操作变得迟缓或不准确。

举个例子:当用户在搜索框中输入文字时,每次输入都会触发一个向服务器请求的操作:

input.addEventListener("input", e => {
  fetch(`/api/getOptions?query=${e.target.value}`);
});

了解问题痛点后,让我们来看看常见的解决方案 —— 防抖与节流。这两种方法综合了非同步编程、闭包🔗其余参数🔗 等高级概念,除了是实际常见的问题之外,在面试时也经常会出现的题目。

防抖 Debounce

debounce.gif

防抖的意思是:等一下,如果你还在输入,我就先不做事。它会等用户停止输入一段时间后再触发事件。比如自动门,当人进门后,门不会立刻关上,而是等到没有人再进来时才关门。

以日常生活为例,比如自动门。当有一群人排队等待进入门内时,并不会每有个人进入就执行一次「关门」的动作,而是每次有人进门就刷新关门的时间,直到没人进门时过一段时间就会自动关门。这样一来就节省了大量的「关门与开门的动作」。

拿前面实际应用中输入框搜索的例子来说,当用户输入时并不用急着马上提交请求,而是启动一个计时器,时间到了再提交最新的请求即可,如果在等待的过程中用户有输入的动作就重新计时。

当有大量的输入想要整合为一个输入时,就是使用 Debounce 的好时机。

Debounce 能够有效地延迟函数的执行,确保在用户输入结束后的一段时间内才触发相应的操作,从而避免了不必要的频繁执行,提高了性能和用户体验。

简单的防抖函数的程序流程描述

编写一个 debounce 函数,有两个参数分别是等待被调用的回调函数与等待时间(默认 1000 毫秒)。事先创建 timeout 变量用于存储计时器,当被调用时:

  1. 停止当前执行中的计时器。
  2. 启动新的计时器。
  3. 当计时器完成计时,执行回调函数。

创建函数和闭包

接下来是创建一个函数和闭包。创建一个 debounce 函数,每次调用时返回一个匿名函数,利用闭包的特性使 timeout 变量能够与每个防抖函数共享。

function debounce(callback, delay = 1000) {
  let timeout;
  return (...args) => {};
}

创建计时器

每次调用 debounce 之前,先删除上次的计时器(如果有的话),然后指定在指定延迟的时间之后调用回调函数。

function debounce(callback, delay = 250) {
  let timeout;

  return (...args) => {
    if (timerId) {
      clearTimeout(timerId);
    }
    timeout = setTimeout(() => {
      callback(...args);
    }, delay);
  };
}

节流 Throttle

debounce.gif

节流就像是在说:“我知道你可能很急,但你先别急。它会限制一个函数在一定时间内只能执行一次。

以动漫角色为例,赛亚人变身有冷却时间。一旦变身功能启用,需要经过一段时间才能再次使用。在这段时间内,无论如何尝试都无法成功进行变身。

更实际的例子是,当网页滑到底部时,希望显示或加载一些新内容。因此,使用节流来限制事件触发的次数,防止在短时间内多次触发加载新内容的操作。

当需要限制某个事件发生的次数时,就是使用 Throttle 的好时机。

Throttle 能够有效地控制事件的触发频率,确保在规定的时间内不会过于频繁地触发相同的操作,从而优化性能并提升用户体验。

简单的节流函数

编写一个 throttle 函数,两个参数分别是等待被调用的回调函数等待时间(默认 1000 毫秒),并事先:

  1. 创建 throttleTimeout 变量存储节流计时器
  2. 创建 callbackArgs 变量存储回调函数的参数

当闭包被执行时:

  1. 将回调函数中的参数记录到 callbackArgs 中,以便在最后一次输入时使用
  2. 如果节流计时器存在,退出函数
  3. 执行回调函数(带入最新的参数)
  4. 启动节流计时器于 throttleTimeout

当节流计时器结束时:

  1. 取消 throttleTimeout 的内容,表示已无计时
  2. 再次调用一次回调函数(带入最新的参数)

创建函数与闭包

创建一个 throttle 函数,每次调用时返回一个匿名函数,并使用闭包(Closure)的特性让 throttleTimeout 与 callbackArgs 变量可以与每个节流函数共享。

function throttle(callback, delay = 1000) {
  let throttleTimeout = null;
  let callbackArgs = null;

  return (...args) => {};
}

当闭包被执行时

首先存储最新的回调参数,这样才能在计时器结束时检查是否还有未执行的更新触发的回调函数存在,如果有就再执行一次整个回调函数。

callbackArgs = args;

接着,当计时器不存在时就直接退出整个函数。

if (throttleTimeout) {
  return;
}

接着,执行回调函数。

callback(...callbackArgs);

因为这项回调函数已经执行过了,所以取消闭包外的记录,改为 null(无)。`

callbackArgs = null;

创建计时器

来到最重要的计时器部分:当计时器结束就取消其记录,检查是否还有未执行的回调参数存在,如果有就再执行一次整个回调函数。

throttleTimeout = setTimeout(() => {
  throttleTimeout = null;
  if (callbackArgs) {
    callback(...callbackArgs);
  }
}, delay);

最终例子如下:

function throttle(callback, delay = 1000) {
  // 创建一个闭包使以下变量可被所有节流事件共享
  // 变量一:节流计时器
  // 变量二:最近一次发生的事件
  let throttleTimeout = null;
  let callbackArgs = null;

  // 返回一个匿名函数(回调事件处理),输入回调的参数
  return (...args) => {
    // 每次迭代中存储当下最新的事件
    callbackArgs = args;

    // 当节流计时器正在执行:离开函数
    if (throttleTimeout) {
      return;
    }

    // 当节流计时器没有在执行:执行回调函数并且启动节流计时器
    // 执行回调函数(使用已记录的最新事件)
    callback(...callbackArgs);

    // 清空已记录的最新事件
    callbackArgs = null;

    // 创建一个新的节流计时器,记录于 throttleTimeout 中,在启动期间挡下传入的函数被执行
    // 当计时结束,如果记录中有最新事件就再重新执行一次
    throttleTimeout = setTimeout(() => {
      // 当计时结束,清空 throttleTimeout 让传入的函数可被执行
      throttleTimeout = null;
      // 当计时器结束,检查是否还有未执行的事件,如果有就再执行一次整个回调函数
      if (callbackArgs) {
        callback(...callbackArgs);
      }
    }, delay);
  };
}