返回首页

防抖与节流:从一个搜索框讲清 debounce 和 throttle

防抖和节流是前端最常被问、也最常写错的两个工具函数。这篇从搜索框、滚动监听这些真实场景出发,把两者的差别、几种实现变体,以及可取消、Promise 化等进阶写法一次讲清。

25 约 5 分钟 · 5876 字 前端

防抖(debounce)和节流(throttle)几乎是每个前端都会写、但又总记不太牢的两个工具函数。它们要解决的是同一类问题:某个事件触发得太频繁,而我们并不需要每次都响应

典型场景:

  • 搜索框输入,每敲一个字就发一次请求,接口被打爆
  • 监听 scroll / resize,回调一秒钟跑几十次,页面卡顿
  • 按钮重复点击,同一个表单提交了好几遍

这篇我们从场景出发,把两者讲清楚,再给出几个实战里真正会用到的变体。

先建立直觉

一句话区分:

  • 防抖:事件停止触发一段时间后,才执行一次。"你别说了,等你说完我再回应。"
  • 节流:在固定的时间间隔内,最多执行一次。"我每秒只回应你一次,说再多也一样。"

用搜索框举例:

  • 防抖:用户连续打字时不发请求,停下来 300ms 才发,适合搜索联想。
  • 节流:用户一直拖动滚动条时,每 200ms 才计算一次位置,适合滚动加载、滚动动画。

记住这个区分,后面的代码都只是它的实现细节。

防抖:等你停下来再执行

最基础的实现,核心就是"每次触发都把上一个定时器清掉,重新计时":

function debounce(fn, delay = 300) {
  let timer = null

  return function (...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, delay)
  }
}

用法:

const onSearch = debounce((keyword) => {
  fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
}, 300)

input.addEventListener('input', (e) => onSearch(e.target.value))

这里有两个容易被忽略的细节:

  1. fn.apply(this, args):保留原函数的 this 和参数,否则在对象方法、事件回调里 this 会丢。
  2. 闭包里的 timer:每个被 debounce 包装的函数,都有自己独立的定时器,互不干扰。

立即执行版(leading edge)

上面的版本是"延迟末尾才执行"(trailing)。有时我们希望第一次触发立刻执行,之后在静默期内忽略,比如点击按钮:第一下马上响应,连点不重复。

function debounce(fn, delay = 300, immediate = false) {
  let timer = null

  return function (...args) {
    if (timer) clearTimeout(timer)

    if (immediate && !timer) {
      fn.apply(this, args)
    }

    timer = setTimeout(() => {
      timer = null
      if (!immediate) fn.apply(this, args)
    }, delay)
  }
}

immediatetrue 时:首次调用立即执行,然后进入 delay 静默期;静默期结束后,下一次调用才会再次立即执行。

节流:固定频率执行

节流保证"无论触发多频繁,在 interval 内只执行一次"。常见有两种实现思路。

时间戳版(leading)

记录上次执行时间,够了间隔才执行。特点是第一次会立即执行,但最后一次可能不执行。

function throttle(fn, interval = 200) {
  let last = 0

  return function (...args) {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn.apply(this, args)
    }
  }
}

定时器版(trailing)

用定时器,特点是第一次会延迟 interval 执行,最后一次能执行到。

function throttle(fn, interval = 200) {
  let timer = null

  return function (...args) {
    if (timer) return
    timer = setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, interval)
  }
}

两者结合:首尾都不丢

实战里常常希望首次立即执行,且最后一次也别丢。把时间戳和定时器结合起来:

function throttle(fn, interval = 200) {
  let last = 0
  let timer = null

  return function (...args) {
    const now = Date.now()
    const remaining = interval - (now - last)

    if (remaining <= 0) {
      // 到了执行点,清掉可能存在的尾部定时器
      if (timer) {
        clearTimeout(timer)
        timer = null
      }
      last = now
      fn.apply(this, args)
    } else if (!timer) {
      // 还没到点,但安排一个尾部执行,保证最后一次不丢
      timer = setTimeout(() => {
        last = Date.now()
        timer = null
        fn.apply(this, args)
      }, remaining)
    }
  }
}

这个版本是大多数库(如 lodash)默认行为的简化版:既有 leading 又有 trailing。

防抖 vs 节流:到底怎么选

判断标准其实很简单,问自己一句:频繁触发期间,我需不需要中间的反馈?

  • 不需要中间反馈,只关心"最终结果" → 防抖。比如搜索联想,只在乎用户停下来时那个词。
  • 需要持续但不必每帧的反馈 → 节流。比如滚动进度条、拖拽时实时计算位置。

一张场景对照表:

场景 选择 原因
搜索框联想 防抖 只关心停止输入后的最终关键词
表单按钮防连点 防抖(immediate) 首次立即响应,后续静默
resize 重新计算布局 防抖 拖拽窗口过程中无需反复算,松手算一次
scroll 加载更多 节流 滚动中要持续判断是否到底部
拖拽元素跟随鼠标 节流 需要持续更新位置,但不必每个 mousemove 都更新
滚动动画 / 进度条 节流 需要平滑的周期性更新
频繁上报埋点 节流 控制上报频率

进阶:可取消与 Promise 化

工具函数光能用还不够,实战里经常需要"手动取消"和"拿到返回值"。

支持取消

给返回的函数挂一个 cancel 方法,常用于组件卸载时清理定时器,避免内存泄漏或对已销毁组件的回调。

function debounce(fn, delay = 300) {
  let timer = null

  function debounced(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, delay)
  }

  debounced.cancel = function () {
    if (timer) {
      clearTimeout(timer)
      timer = null
    }
  }

  return debounced
}

Promise 化:拿到防抖后的结果

搜索联想常常要 await 结果。可以让防抖函数返回 Promise,只 resolve 真正执行的那一次:

function debounceAsync(fn, delay = 300) {
  let timer = null
  let resolves = []

  return function (...args) {
    if (timer) clearTimeout(timer)

    return new Promise((resolve) => {
      resolves.push(resolve)
      timer = setTimeout(async () => {
        const result = await fn.apply(this, args)
        resolves.forEach((r) => r(result))
        resolves = []
        timer = null
      }, delay)
    })
  }
}
const search = debounceAsync((q) => fetch(`/api/search?q=${q}`).then((r) => r.json()), 300)

const list = await search('vue') // 期间被覆盖的调用会一起拿到最终结果

框架里的注意点

在 Vue / React 里用防抖节流,有一个高频错误:在每次渲染时重新创建包装函数,导致内部的 timer 闭包每次都是新的,防抖完全失效。

Vue 3 里在 setup 中创建一次即可,天然只执行一次:

<script setup>
const onInput = debounce((e) => {
  keyword.value = e.target.value
}, 300)

onUnmounted(() => onInput.cancel?.()) // 卸载时取消,避免对已销毁组件操作
</script>

React 里要用 useMemo / useRef 锁住同一个实例,不要直接在函数体里 debounce(...):

const onInput = useMemo(() => debounce(handleInput, 300), [])
useEffect(() => () => onInput.cancel?.(), [onInput])

常见坑

第一,this 和参数丢失。 一定要用 fn.apply(this, args) 透传,尤其在对象方法和事件回调里。

第二,每次渲染重建函数。 框架组件里务必把包装后的函数缓存住,否则闭包重置,防抖节流失效。

第三,忘记清理定时器。 组件卸载、路由切换时,残留的定时器可能回调到已销毁的上下文。给函数加 cancel 并在卸载时调用。

第四,把防抖当节流用。 用防抖做"滚动加载更多",用户匀速滚动时回调永远不触发(因为一直没停下来),导致加载不出来。这种持续性场景应该用节流。

第五,延迟值拍脑袋。 搜索联想 200–400ms 体验较好;滚动相关 16–100ms;太短失去意义,太长用户感觉卡顿。按场景测一下。

总结

防抖和节流的本质,是给"高频事件"和"低频响应"之间装一个缓冲:

  • 防抖:停下来才执行,关心"最终态",适合搜索、resize、防连点。
  • 节流:固定频率执行,关心"过程中的周期反馈",适合 scroll、拖拽、上报。

写的时候记牢三点:透传 this 和参数、在框架里缓存同一个实例、提供 cancel 以便清理。真到了生产项目,直接用 lodash 的 debounce / throttle 也完全没问题——但理解背后这几十行代码,才知道参数该怎么配、坑在哪里。