防抖(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))这里有两个容易被忽略的细节:
fn.apply(this, args):保留原函数的this和参数,否则在对象方法、事件回调里this会丢。- 闭包里的
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)
}
}immediate 为 true 时:首次调用立即执行,然后进入 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 也完全没问题——但理解背后这几十行代码,才知道参数该怎么配、坑在哪里。