从简单到复杂:手写节流(Throttle)全攻略

124 阅读3分钟

从简单到复杂:手写节流(Throttle)全攻略

1) 最简单:时间戳版(前沿触发)

  • 场景:按钮防连点、滚动监听等,首次应立刻响应。
  • 思路:记录“上次执行时间”,只有超过间隔才执行。
function throttle(fn, delay) {
  let lastInvokeTime = 0; // 0 表示首次立即执行
  return function (...args) {
    const now = Date.now();
    if (now - lastInvokeTime >= delay) {
      lastInvokeTime = now;
      return fn.apply(this, args);
    }
  };
}
  • 特点:首次立即执行;时间窗内的触发被丢弃;实现最简单。

2) 定时器版(后沿触发)

  • 场景:不希望首次立刻执行,但希望“最后一次”被执行(如输入联想、窗口调整完成后的布局)。
  • 思路:开始触发时启动一个定时器,等到期执行一次。
function throttleTrailing(fn, delay) {
  let timerId = null;
  return function (...args) {
    if (timerId != null) return;
    const context = this;
    timerId = setTimeout(() => {
      timerId = null;
      fn.apply(context, args);
    }, delay);
  };
}
  • 特点:首次不会执行;只要持续触发,等窗口结束后会补一次执行。

3) 混合版:前沿 + 后沿可配

  • 场景:既想要“首次立即执行”,又不想错过“最后一次触发后的收尾”;或需要灵活配置不同策略。
  • 思路:时间戳决定前沿是否执行,定时器安排尾部补一次。
function throttleHybrid(fn, delay, options = { leading: true, trailing: true }) {
  let lastInvokeTime = 0;
  let timerId = null;
  let lastArgs;
  let lastContext;

  function invoke(now) {
    lastInvokeTime = now;
    fn.apply(lastContext, lastArgs);
    lastArgs = lastContext = null;
  }

  function startTimer(remaining) {
    timerId = setTimeout(() => {
      timerId = null;
      if (options.trailing !== false && lastArgs) {
        invoke(Date.now());
      }
    }, remaining);
  }

  return function (...args) {
    const now = Date.now();
    if (lastInvokeTime === 0 && options.leading === false) {
      lastInvokeTime = now; // 禁止前沿:首次不执行
    }

    lastArgs = args;
    lastContext = this;

    const remaining = delay - (now - lastInvokeTime);

    if (remaining <= 0) {
      if (timerId) {
        clearTimeout(timerId);
        timerId = null;
      }
      invoke(now);
    } else if (!timerId && options.trailing !== false) {
      startTimer(remaining);
    }
  };
}
  • 常见配置:
    • 只前沿:{ leading: true, trailing: false }
    • 只后沿:{ leading: false, trailing: true }
    • 全开(默认):{ leading: true, trailing: true }

4) 进阶增强:取消与立即执行

  • 需求:组件卸载时取消尾部任务;或在需要时“立刻执行一次”。
function throttleWithControls(fn, delay, options = { leading: true, trailing: true }) {
  let lastInvokeTime = 0;
  let timerId = null;
  let lastArgs;
  let lastContext;

  function invoke(now) {
    lastInvokeTime = now;
    fn.apply(lastContext, lastArgs);
    lastArgs = lastContext = null;
  }

  function startTimer(remaining) {
    timerId = setTimeout(() => {
      timerId = null;
      if (options.trailing !== false && lastArgs) {
        invoke(Date.now());
      }
    }, remaining);
  }

  function throttled(...args) {
    const now = Date.now();
    if (lastInvokeTime === 0 && options.leading === false) {
      lastInvokeTime = now;
    }

    lastArgs = args;
    lastContext = this;

    const remaining = delay - (now - lastInvokeTime);

    if (remaining <= 0) {
      if (timerId) {
        clearTimeout(timerId);
        timerId = null;
      }
      invoke(now);
    } else if (!timerId && options.trailing !== false) {
      startTimer(remaining);
    }
  }

  throttled.cancel = function () {
    if (timerId) {
      clearTimeout(timerId);
      timerId = null;
    }
    lastInvokeTime = 0;
    lastArgs = lastContext = null;
  };

  throttled.flush = function () {
    if (timerId) {
      clearTimeout(timerId);
      timerId = null;
    }
    if (lastArgs) {
      invoke(Date.now());
    }
  };

  return throttled;
}

5) 常见错误与避坑

  • 在判断前重置“上次时间”为当前时间,导致差值≈0,永远不执行。
  • 被拦截时更新时间戳,导致后续时间窗错乱或一直不触发。
  • 在函数定义时缓存 this(如 let that = this),丢失动态上下文,应使用 fn.apply(this, args)
  • 拼写与命名不清晰:建议使用 lastInvokeTime/invokeTime

6) 如何选择版本

  • 要“简单、首次立刻响应”:时间戳版(前沿)
  • 要“只在结束时执行一次”:定时器版(后沿)
  • 要“首次响应+尾部补一次”或可配置:混合版(带 leading/trailing
  • 要可取消/立即执行:增强版(带 cancel/flush

7) 使用示例

  • 滚动监听(简单前沿)
const onScroll = throttle(() => {
  console.log('scroll at', window.scrollY);
}, 200);

window.addEventListener('scroll', onScroll);
  • 输入联想(后沿或混合)
const onInput = throttleHybrid(fetchSuggest, 300, { leading: false, trailing: true });
input.addEventListener('input', (e) => onInput(e.target.value));
  • 按钮防连点(前沿)
button.addEventListener('click', throttle(submit, 1000));