防抖与节流:把“高频事件”变成可控的调用
在前端开发里,你会经常遇到“事件触发太频繁”的场景:
- 输入框
input:用户每打一个字都触发搜索 - 页面滚动
scroll:每滚动 1px 都触发回调 - 窗口缩放
resize:拖动过程中不断触发 - 鼠标移动
mousemove:几乎每一帧都在触发
如果你在这些回调里做了请求、复杂计算、DOM 读写、上报,页面就很容易卡顿、抖动,甚至把接口打爆。
解决这类问题最常用的两把“工具刀”就是:防抖(debounce)与节流(throttle)。
1. 先用 30 秒记住区别
| 名称 | 一句话理解 | 典型场景 | 你关心的“结果” |
|---|---|---|---|
| 防抖(debounce) | 等你停下来一段时间再执行 | 搜索联想、窗口缩放结束后计算布局 | 更关心“最终那一次” |
| 节流(throttle) | 你再怎么狂点,我也按固定频率执行 | 滚动加载、拖拽过程实时反馈、滚动监听吸顶 | 更关心“过程中的连续反馈” |
你可以把它们想成两种“限频策略”:
- 防抖:把多次触发合并成一次(只要你一直触发,就一直不执行)
- 节流:把多次触发稀释成少量执行(每隔一段时间执行一次)
2. 防抖(Debounce):等你“安静”下来
2.1 核心机制
防抖的本质就是:定一个延迟定时器,每次触发都重置它;只有最后一次触发后,延迟时间真正走完才执行。
用图理解(尾触发 trailing debounce,最常用):
2.2 两种常见形态:尾触发 vs 头触发
- 尾触发(trailing):停止触发一段时间后再执行(默认、最常用)
- 搜索输入:用户停下来再请求
- resize:拖完再重新计算布局
- 头触发(leading):第一次触发立刻执行,之后在等待时间内不再执行
- 一些“第一次要立刻响应”的场景,比如第一次点击立刻出反馈,但禁止短时间内重复提交
防抖:停下来才做事;
节流:过程中也做事,但别太频繁。
3. 节流(Throttle):按固定频率执行
3.1 核心机制
节流的本质是:在一个时间窗口内,只允许执行一次。
用图理解(以“每 200ms 最多执行一次”为例):
3.2 常见“放行策略”
你会看到节流常用两个选项:
leading:是否在窗口开始时立即执行一次trailing:是否在窗口结束时补执行最后一次
不同库/实现的默认值不同(例如很多实现默认 leading: true, trailing: true),面试也爱问这点:
同样叫 throttle,默认行为可能不一样。
4. 怎么选:一张决策图
5. 手写实现:从“能用”到“好用”
下面给出两套实现:简版(易背)和工程版(带 cancel/flush + leading/trailing)。面试、工作都够用。
5.1 防抖:简版(尾触发)
function debounce<T extends (...args: any[]) => void>(fn: T, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined
return function (this: unknown, ...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, wait)
}
}
5.2 节流:简版(时间戳版,偏 leading)
function throttle<T extends (...args: any[]) => void>(fn: T, wait: number) {
let lastTime = 0
return function (this: unknown, ...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime >= wait) {
lastTime = now
fn.apply(this, args)
}
}
}
- 不支持
cancel/flush(组件卸载时不方便清理) leading/trailing行为固定(很多业务需要可配置)
6. 工程版:支持 leading / trailing / cancel / flush
6.1 防抖(工程版)
type DebounceOptions = {
leading?: boolean
trailing?: boolean
}
type Debounced<T extends (...args: any[]) => any> = ((
...args: Parameters<T>
) => void) & {
cancel: () => void
flush: () => ReturnType<T> | undefined
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait: number,
options: DebounceOptions = {},
): Debounced<T> {
const {leading = false, trailing = true} = options
let timer: ReturnType<typeof setTimeout> | undefined
let lastArgs: Parameters<T> | undefined
let lastThis: unknown
let lastResult: ReturnType<T> | undefined
const invoke = () => {
if (!lastArgs) return
lastResult = fn.apply(lastThis, lastArgs)
lastArgs = undefined
}
const debounced = function (this: unknown, ...args: Parameters<T>) {
lastArgs = args
lastThis = this
const shouldCallNow = leading && !timer
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
if (trailing) invoke()
else lastArgs = undefined
}, wait)
if (shouldCallNow) invoke()
} as Debounced<T>
debounced.cancel = () => {
if (timer) clearTimeout(timer)
timer = undefined
lastArgs = undefined
}
debounced.flush = () => {
if (!timer) return lastResult
clearTimeout(timer)
timer = undefined
if (trailing) invoke()
return lastResult
}
return debounced
}
- 搜索输入:通常用
leading: false, trailing: true(默认) - 防止重复提交:可以考虑
leading: true, trailing: false
6.2 节流(工程版)
type ThrottleOptions = {
leading?: boolean
trailing?: boolean
}
type Throttled<T extends (...args: any[]) => any> = ((
...args: Parameters<T>
) => void) & {
cancel: () => void
flush: () => ReturnType<T> | undefined
}
export function throttle<T extends (...args: any[]) => any>(
fn: T,
wait: number,
options: ThrottleOptions = {},
): Throttled<T> {
const {leading = true, trailing = true} = options
let timer: ReturnType<typeof setTimeout> | undefined
let lastInvokeTime = 0
let lastArgs: Parameters<T> | undefined
let lastThis: unknown
let lastResult: ReturnType<T> | undefined
const invoke = (time: number) => {
lastInvokeTime = time
if (!lastArgs) return
lastResult = fn.apply(lastThis, lastArgs)
lastArgs = undefined
}
const remainingWait = (time: number) => wait - (time - lastInvokeTime)
const throttled = function (this: unknown, ...args: Parameters<T>) {
const now = Date.now()
lastArgs = args
lastThis = this
if (!leading && lastInvokeTime === 0) {
lastInvokeTime = now
}
const remain = remainingWait(now)
const shouldInvoke = remain <= 0 || remain > wait
if (shouldInvoke) {
if (timer) {
clearTimeout(timer)
timer = undefined
}
invoke(now)
return
}
if (!timer && trailing) {
timer = setTimeout(() => {
timer = undefined
invoke(Date.now())
}, remain)
}
} as Throttled<T>
throttled.cancel = () => {
if (timer) clearTimeout(timer)
timer = undefined
lastArgs = undefined
lastInvokeTime = 0
}
throttled.flush = () => {
if (!timer) return lastResult
clearTimeout(timer)
timer = undefined
invoke(Date.now())
return lastResult
}
return throttled
}
7. 实战示例:写到项目里怎么用
7.1 搜索联想(防抖)
const search = async (keyword: string) => {
// 真实项目里:这里通常是 fetch/axios
console.log('请求搜索:', keyword)
}
const onInput = debounce((keyword: string) => {
if (!keyword.trim()) return
void search(keyword)
}, 300)
7.2 滚动监听(节流)
const onScroll = throttle(() => {
const y = window.scrollY
// 例如:吸顶、懒加载、曝光上报
console.log('scrollY:', y)
}, 200)
window.addEventListener('scroll', onScroll, {passive: true})
7.3 “按帧节流”:滚动/动画更丝滑(rAF)
当你要在滚动中做 DOM 读写(例如计算位置、更新样式),用 requestAnimationFrame 做节流通常更贴合渲染节奏:
function throttleByRaf<T extends (...args: any[]) => void>(fn: T) {
let locked = false
return function (this: unknown, ...args: Parameters<T>) {
if (locked) return
locked = true
requestAnimationFrame(() => {
locked = false
fn.apply(this, args)
})
}
}
想把 rAF 的执行时机讲清楚,可以搭配阅读:requestAnimationFrame 详解。
这里的 “按帧节流” 不是严格意义的 wait = 16ms,它的频率会受设备刷新率、页面是否在后台、主线程是否繁忙等因素影响,但体验通常更自然。
7.4 React/Vue 里最容易踩的坑:卸载后还在执行
防抖/节流常常内部用了 setTimeout,组件卸载后如果不清理,可能出现:
- 卸载后仍发请求
- 卸载后还在
setState(React 会 warning)
做法是:在卸载时 cancel(以 React 为例):
const onChange = useMemo(() => debounce((v: string) => {
console.log('search:', v)
}, 300), [])
useEffect(() => () => onChange.cancel(), [onChange])
8. 常见坑与排雷清单
- 一定要保留
this和参数:用fn.apply(this, args),别直接fn(...args)就完事(尤其在类方法、事件处理里)。 - 事件监听要能移除:
addEventListener/removeEventListener必须是同一个函数引用,别在每次 render 都 new 一个 debounce。 leading+trailing组合要想清楚:- debounce:
leading: true, trailing: true可能导致“开头一次 + 结束一次” - throttle:
leading: false, trailing: false会导致永远不执行(这通常不是你想要的)
- debounce:
- 接口请求要考虑并发与过期:防抖只能减少触发次数,但不能保证“最后一次响应就是最新”。需要配合取消请求或对响应做版本校验。
- 滚动事件请加
{passive: true}:避免浏览器因为可能preventDefault而阻塞滚动(移动端更明显)。
9. 面试高频问答
Q1:防抖和节流的本质区别是什么?
- 防抖:把多次触发合并成一次,等停止触发一段时间后才执行
- 节流:限制执行频率,在时间窗口内最多执行一次
Q2:搜索输入为什么更适合防抖?
因为用户输入过程中,“中间态”大多没意义,你更关心用户停下来后的最终关键词。防抖能显著减少请求次数,提升体验和稳定性。
Q3:滚动监听为什么更适合节流?
因为滚动过程中需要持续反馈(吸顶、懒加载、上报曝光),但不需要每个像素都执行。节流能让反馈“连续但不过载”。
Q4:leading 和 trailing 分别表示什么?
leading:窗口开始时是否立刻执行一次trailing:窗口结束时是否补一次“最后的那次触发”
Q5:节流有哪些实现方式?各自特点?
- 时间戳版:偏
leading,实现简单,但不容易做trailing - 定时器版:偏
trailing,容易补最后一次,但第一次通常要等一等 - 时间戳 + 定时器混合:更灵活(工程常用)
Q6:防抖/节流为什么要提供 cancel?
为了在组件卸载、路由切换、任务失效时,主动取消未执行的回调,避免:
- 过期请求
- 内存泄漏
- 卸载后更新状态
Q7:防抖能解决“请求乱序”吗?
不能。防抖只能减少触发次数,但网络响应可能乱序。需要:
- 取消前一个请求(AbortController)
- 或者给请求加序号/版本,只接收最新响应
Q8:什么时候更推荐用 requestAnimationFrame 节流?
当你在高频事件中要做 DOM 读写/动画更新时,rAF 能把执行点对齐到渲染帧,减少抖动与掉帧,滚动体验更好。
Q9:能不能只用 lodash 的 debounce/throttle?
可以,实际工程里非常常见。但面试通常仍希望你能说明原理,并知道它们的关键选项(leading/trailing、cancel/flush)和常见坑(函数引用、卸载清理、请求乱序)。
10. 小结
- 防抖:停下来再做(合并多次触发)
- 节流:按频率做(稀释执行次数)
- 真正的工程落地,关键在:
leading/trailing策略、cancel/flush、以及在框架里保证函数引用稳定与卸载清理