彻底搞懂Throttle限速的实现与应用场景

宇文文亭 工具 阅读 2,531
赞 20 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

我一般写 throttle 都用这个版本,简洁、稳定、边界情况处理得也干净。直接上代码:

彻底搞懂Throttle限速的实现与应用场景

function throttle(func, delay) {
  let timerId = null;
  let lastExecTime = 0;

  return function (...args) {
    const currentTime = Date.now();
    const remaining = delay - (currentTime - lastExecTime);
    const context = this;

    if (remaining <= 0) {
      if (timerId) {
        clearTimeout(timerId);
        timerId = null;
      }
      func.apply(context, args);
      lastExecTime = currentTime;
    } else if (!timerId) {
      timerId = setTimeout(() => {
        func.apply(context, args);
        lastExecTime = Date.now();
        timerId = null;
      }, remaining);
    }
  };
}

这段代码我已经在好几个项目里用了,包括表单防重复提交、滚动事件监听、resize 处理。核心逻辑是:如果距离上次执行还没到 delay 时间,就用 setTimeout 补上最后一次执行机会;否则立刻执行,并重置计时。

好处是啥?它既保证了首次能立刻响应(不像有些实现第一次就要等 delay),又不会在频繁触发时漏掉最后一次。比如用户快速滚动,松手后你还能拿到最后一次的位置做计算,这对懒加载或者视差滚动很重要。

这里注意我踩过好几次坑:早期我图省事直接用 debounce 改了个 delay 当 throttle 用,结果就是滚动结束前完全没反应,页面卡成幻灯片。后来才明白,debounce 是“只执行最后一次”,throttle 是“固定频率执行”,语义完全不同。

这几种错误写法,别再踩坑了

先说最常见的一种——只靠 setTimeout 控制,不记录时间戳。

// ❌ 错误写法一:单纯依赖定时器,容易失步
function throttleBad1(func, delay) {
  let timerId = null;
  return function (...args) {
    if (!timerId) {
      timerId = setTimeout(() => {
        func.apply(this, args);
        timerId = null;
      }, delay);
    }
  };
}

问题在哪?假设 delay 是 300ms,但函数每 200ms 就被调一次。那每次进来都发现 timerId 存在,直接跳过,结果就是整个过程中 func 一次都没执行。直到某次间隔超过 300ms 才触发,严重滞后。

我当时在一个监控打点项目里用了这种写法,用户点击按钮根本不上报数据,排查半天才发现是 throttle 把所有事件全挡了……

再来一个更隐蔽的:

// ❌ 错误写法二:用标志位控制,但不清除定时器
function throttleBad2(func, delay) {
  let canRun = true;
  return function (...args) {
    if (!canRun) return;
    canRun = false;
    func.apply(this, args);
    setTimeout(() => {
      canRun = true;
    }, delay);
  };
}

看起来好像没问题?但如果你触发频率刚好卡在 delay 边缘,比如每 301ms 调一次,而 delay 是 300ms,那每次都能进,等于没限速。更糟的是,如果中间有异常中断,setTimeout 可能挂掉,canRun 永远回不去 true,直接锁死。

我以前在移动端 touchmove 里这么写,滑动几下后突然监听失效,调试器看函数根本不进,折腾了半天发现是 canRun 被卡住了。

实际项目中的坑

我在一个 H5 活动页里做过一个“滑动抽奖”功能,用户手指滑动时实时上报位置给服务端算轨迹。接口 QPS 有限制,必须加 throttle。

一开始我就用的简化版 throttle,结果线上出现两种情况:

  • 低端机滑动慢,上报太勤快,直接被接口限流
  • 高端机滑动快,throttle 因为节流太久没上报,轨迹断了好几段

最后改成了带 leading 和 trailing 配置的版本(虽然这次没贴出来),并且根据设备性能动态调整 delay。比如通过 navigator.deviceMemory 判断内存大小,内存小的设备 delay 加 50ms,避免卡顿。

还有个细节:绑定事件的时候一定要记得保存返回函数,不然没法解绑。

const throttledHandler = throttle(handleScroll, 200);
window.addEventListener('scroll', throttledHandler);

// 后续想移除监听?
// ❌ window.removeEventListener('scroll', handleScroll); // 没用!
// ✅ 必须用原返回值
window.removeEventListener('scroll', throttledHandler);

这个坑我栽过两次。第一次是在 React 里 useEffect 清理事件,写错了引用,导致内存泄漏;第二次是在单页应用路由切换时没清,多个页面的 scroll 事件叠加,页面疯狂抖动。

另外提醒一点:不要在 throttle 里面处理 DOM 操作,尤其是强制同步布局那种。比如 getBoundingClientRect() 这类会触发重排的 API,你要是每 100ms 就来一次,页面肯定卡。

正确做法是只在 throttle 里记状态或发消息,真正操作 DOM 放到 requestAnimationFrame 里去做:

const updatePosition = throttle((x, y) => {
  // 只更新数据
  store.scrollX = x;
  store.scrollY = y;
}, 100);

window.addEventListener('scroll', () => {
  updatePosition(window.scrollX, window.scrollY);
});

// 然后在 rAF 里统一处理渲染
function animate() {
  renderBasedOnStore(store); // 安全地读取并更新 UI
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

要不要用 Lodash?

说实话,Lodash 的 _.throttle 功能很全,支持 options.leading、trailing、maxWait,但我现在基本不用了。

原因很简单:项目里为了这么一个函数引入几十 KB 的库,划不来。而且很多场景你根本不需要那么复杂的配置,反而容易误配。比如把 trailing 设成 false,结果用户松手后没反馈,体验很差。

我现在都是按需封装,甚至根据不同场景写不同版本。比如表单提交我用“执行后锁定 delay 时间”的简单版,而滚动监听用上面那个精准时间控制的版本。

当然你要已经用了 Lodash,那就继续用也没问题。但别为了 throttle 单独引一遍,真没必要。

以上是我踩坑后的总结,希望对你有帮助

throttle 看起来简单,但真要写对,还得动手试过才知道哪些地方会出事。我的建议是:核心逻辑用时间戳+定时器双保险,别偷懒;事件绑定记得保存引用;高频 DOM 操作交给 rAF。

这个方案不是最优的,但最简单,改完基本无大碍。个别极端情况可能还是会丢一帧,但不影响主流程。

以上是我个人对 throttle 限速的实战经验,有更优的实现方式欢迎评论区交流。这类工具函数的细节还有很多可以挖,后续还会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论