Published on

高频事件优化:防抖与节流

在 Web 开发中,某些 DOM 事件(如 resize, scroll, input, mousemove)的触发频率极高,可能在短时间内被触发数百次。如果事件监听器 (event listener) 的回调函数中包含了复杂的计算或 DOM 操作,这种高频次的执行会严重阻塞 JavaScript 主线程,导致 UI 卡顿和糟糕的用户体验。为了解决这一问题,防抖 (Debounce)节流 (Throttle) 作为两种核心的函数率限制技术应运而生。它们通过控制回调函数的执行时机与频率,在保证必要响应的同时,极大地优化了应用性能。

节流 (Throttle):时间窗口内的“阀门”

节流的核心思想是:在指定的时间间隔内,无论事件被触发多少次,回调函数最多只执行一次。

机制: 它像一个“阀门”,设定了一个流量阈值。在第一个事件触发时,它会立即执行回调,然后关闭“阀门”。在设定的 duration 时间内,所有后续的事件触发都会被忽略。直到 duration 结束后,“阀门”才会再次打开,准备响应下一次事件。

基础实现

基础 Throttle 实现

/**
 * @param {Function} func 要节流的函数
 * @param {number} duration 节流的时间间隔 (ms)
 */
function throttle(func, duration) {
  // 通过闭包维持一个“是否应等待”的状态
  let shouldWait = false;
  
  return function (...args) {
    if (!shouldWait) {
      func.apply(this, args);
      shouldWait = true;
      
      setTimeout(() => {
        shouldWait = false;
      }, duration);
    }
  };
}

高级配置与应用

leadingtrailing 选项

成熟的 throttle 实现(如 Lodash 库)通常提供 leading (首次是否立即执行) 和 trailing (末次是否执行) 两个布尔值选项,以满足不同场景:

  • { leading: true, trailing: false } (默认): 立即响应第一次操作,然后进入冷却。适用于防止重复提交的按钮点击。
  • { leading: false, trailing: true }: 第一次不执行,只在时间窗口的末尾响应最后一次操作。适用于需要等待操作稳定后再响应的场景,如拖拽结束后的位置计算。
  • { leading: true, trailing: true }: 既能立即响应,又能保证最后一次被忽略的操作得到处理。适用于需要即时反馈,又不能丢失最终状态的场景,如复杂表单的实时校验。

防抖 (Debounce):"冷静期"后的执行

防抖的核心思想是:事件被触发后,延迟 duration 时间再执行回调。如果在这 duration 时间内事件被再次触发,则重新开始计时。

机制: 它总是在等待一个“冷静期”。只有当事件停止触发,并经过一个完整的 duration 后,回调函数才会被真正执行。它关注的是最后一次操作。

基础实现

基础 Debounce 实现

/**
 * @param {Function} func 要防抖的函数
 * @param {number} duration 防抖的延迟时间 (ms)
 */
function debounce(func, duration) {
  // 通过闭包维持一个定时器 ID
  let timeout;
  
  return function (...args) {
    // 如果定时器已存在,则清除它,实现“重新计时”
    clearTimeout(timeout);
    
    // 设置一个新的定时器
    timeout = setTimeout(() => {
      func.apply(this, args);
    }, duration);
  };
}

典型应用场景

  • 搜索框输入 (input 事件): 仅在用户停止输入后,才发起 API 请求。
  • 窗口 resize 事件: 仅在用户完成窗口尺寸调整后,才重新计算布局。
  • 自动保存: 仅在用户停止编辑一段时间后,才触发保存操作。

实践中的关键陷阱

错误:在事件监听器回调中重复声明

debouncethrottle 的核心在于利用闭包 (Closure) 来维持其内部状态(shouldWaittimeout)。如果在事件监听器的回调函数内部才去调用 debouncethrottle,那么每一次事件触发,都会创建一个全新的、状态独立的防抖/节流函数,从而使其完全失效。

错误示例:

// ❌ 每次点击,都会创建一个新的 debounce 实例,timeout 永远无法被共享和清除
button.addEventListener('click', function handleButtonClick() {
  const debouncedFunc = debounce(() => console.log('Clicked!'), 500);
  debouncedFunc();
});

正确实践:

// ✅ 在 addEventListener 之前,就创建好唯一的 debounce 实例
const debouncedClickHandler = debounce(function handleButtonClick() {
  console.log('Clicked!');
}, 500);

button.addEventListener('click', debouncedClickHandler);