防抖与节流:写事件处理函数前先想清楚

3 分钟读完前端系列

每次面试别人我都会问这两个概念,每次自己写滚动监听之前也都会重新想一遍。与其说是两个知识点,不如说是一道选择题:这个事件,你到底想响应多少次?

问题是什么

浏览器里有些事件触发得非常勤快:输入框每敲一个键触发一次 input,滚动条每移动一像素都可能触发一次 scroll,鼠标在元素上每经过一个子节点触发一次 mousemove。

如果事件处理函数很重,比如每次都要发请求或者做大量计算,浏览器就会卡给你看。

解法有两个方向,取决于业务语义。

防抖:等你说完我再动

防抖(debounce)的语义是:停止触发 N 毫秒后,才真正执行。如果在这期间又触发了,就重新计时。

典型场景是搜索联想。用户想输入「防抖与节流」五个字,不应该每敲一个键就发一次请求:

ts
function debounce<T extends (...args: never[]) => void>(
  fn: T,
  wait = 300
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | undefined;

  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

input.addEventListener("input", debounce((event) => {
  fetchSuggestions(event.target.value);
}, 400));

用户连续输入时,请求永远不会发出;停顿超过 400 毫秒,才发最后那一次。五次请求合并成了一次。

节流:按固定节奏响应

节流(throttle)的语义是:不管触发多频繁,每 N 毫秒最多执行一次。

典型场景是滚动监听。页面滚动时想更新一个回到顶部的按钮显隐,用户的手不会停,但 UI 每 200 毫秒更新一次完全够用:

ts
function throttle<T extends (...args: never[]) => void>(
  fn: T,
  interval = 200
): (...args: Parameters<T>) => void {
  let last = 0;

  return (...args: Parameters<T>) => {
    const now = Date.now();
    if (now - last < interval) return;
    last = now;
    fn(...args);
  };
}

window.addEventListener("scroll", throttle(updateBackToTop, 200));

注意这是最简单的「 Leading edge」实现:先执行再计时。如果想要停止触发后再补一次,就得加上 trailing 逻辑,复杂度翻倍,多数场景不值得。

怎么选

一句话判断:关心最终状态用防抖,关心过程反馈用节流。

  • 搜索联想、表单校验、窗口 resize 后重算布局:防抖
  • 滚动加载、滚动吸顶、拖拽时的位置同步:节流

另外两个提醒。第一,addEventListener 的第三个参数传 passive: true,滚动场景能明显更顺滑。第二,组件卸载时记得移除监听并清掉定时器,不然防抖函数会在组件销毁后再执行一次。

就这些。下次写事件处理函数之前,先问一句:这个事件,我想响应多少次?

收录于 前端 系列