跳到主要内容

防抖与节流:把“高频事件”变成可控的调用

在前端开发里,你会经常遇到“事件触发太频繁”的场景:

  • 输入框 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. 常见坑与排雷清单

  1. 一定要保留 this 和参数:用 fn.apply(this, args),别直接 fn(...args) 就完事(尤其在类方法、事件处理里)。
  2. 事件监听要能移除addEventListener/removeEventListener 必须是同一个函数引用,别在每次 render 都 new 一个 debounce。
  3. leading + trailing 组合要想清楚
    • debounce:leading: true, trailing: true 可能导致“开头一次 + 结束一次”
    • throttle:leading: false, trailing: false 会导致永远不执行(这通常不是你想要的)
  4. 接口请求要考虑并发与过期:防抖只能减少触发次数,但不能保证“最后一次响应就是最新”。需要配合取消请求或对响应做版本校验。
  5. 滚动事件请加 {passive: true}:避免浏览器因为可能 preventDefault 而阻塞滚动(移动端更明显)。

9. 面试高频问答

Q1:防抖和节流的本质区别是什么?

  • 防抖:把多次触发合并成一次,等停止触发一段时间后才执行
  • 节流:限制执行频率,在时间窗口内最多执行一次

Q2:搜索输入为什么更适合防抖?

因为用户输入过程中,“中间态”大多没意义,你更关心用户停下来后的最终关键词。防抖能显著减少请求次数,提升体验和稳定性。

Q3:滚动监听为什么更适合节流?

因为滚动过程中需要持续反馈(吸顶、懒加载、上报曝光),但不需要每个像素都执行。节流能让反馈“连续但不过载”。

Q4:leadingtrailing 分别表示什么?

  • leading:窗口开始时是否立刻执行一次
  • trailing:窗口结束时是否补一次“最后的那次触发”

Q5:节流有哪些实现方式?各自特点?

  • 时间戳版:偏 leading,实现简单,但不容易做 trailing
  • 定时器版:偏 trailing,容易补最后一次,但第一次通常要等一等
  • 时间戳 + 定时器混合:更灵活(工程常用)

Q6:防抖/节流为什么要提供 cancel

为了在组件卸载、路由切换、任务失效时,主动取消未执行的回调,避免:

  • 过期请求
  • 内存泄漏
  • 卸载后更新状态

Q7:防抖能解决“请求乱序”吗?

不能。防抖只能减少触发次数,但网络响应可能乱序。需要:

  • 取消前一个请求(AbortController)
  • 或者给请求加序号/版本,只接收最新响应

Q8:什么时候更推荐用 requestAnimationFrame 节流?

当你在高频事件中要做 DOM 读写/动画更新时,rAF 能把执行点对齐到渲染帧,减少抖动与掉帧,滚动体验更好。

Q9:能不能只用 lodash 的 debounce/throttle

可以,实际工程里非常常见。但面试通常仍希望你能说明原理,并知道它们的关键选项(leading/trailingcancel/flush)和常见坑(函数引用、卸载清理、请求乱序)。


10. 小结

  • 防抖:停下来再做(合并多次触发)
  • 节流:按频率做(稀释执行次数)
  • 真正的工程落地,关键在:leading/trailing 策略、cancel/flush、以及在框架里保证函数引用稳定与卸载清理