最后更新:2025-12-16

防抖(debounce)和节流(throttle)这俩概念,面试被问到的概率极高,但真正理解它们的人不多。不少人背了概念、写过代码,实际工作里却判断不清楚某个场景到底该用哪个。

我也是工作了一段时间之后才真正把这两个搞清楚。

先说清楚区别

防抖:触发事件后,等待 n 毫秒,如果期间没有再次触发就执行;如果又触发了,重新计时。

节流:不管你触发多少次,我在 n 毫秒内只执行一次,超过时间再触发才会再次执行。

一个类比:

  • 防抖像电梯关门——有人进来就重新等,等到真的没人进来了才关门
  • 节流像地铁——不管站台多少人,每隔固定时间发一班车

手写防抖

1
2
3
4
5
6
7
8
9
10
11
12
13
function debounce(fn, delay) {
let timer = null

return function (...args) {
// 每次触发都清掉之前的定时器
if (timer) clearTimeout(timer)

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

使用:

1
2
3
4
5
6
7
8
const handleSearch = debounce(function (keyword) {
// 发起搜索请求
fetchSearchResults(keyword)
}, 500)

searchInput.addEventListener('input', (e) => {
handleSearch(e.target.value)
})

用户输入”前端开发”,每敲一个字就触发一次 input 事件,但只有停下来 500ms 之后才真正发请求。体验好,也不浪费资源。

手写节流

节流有两种常见实现,时间戳版和定时器版,各有细微差别:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 时间戳版:第一次立即执行,停止触发后不再执行
function throttle(fn, interval) {
let lastTime = 0

return function (...args) {
const now = Date.now()

if (now - lastTime >= interval) {
fn.apply(this, args)
lastTime = now
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 定时器版:第一次延迟执行,停止触发后还会执行最后一次
function throttle(fn, interval) {
let timer = null

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

工作里大多数场景用时间戳版就够了,如果你需要”停止操作后还要触发最后一次”,就用定时器版,或者把两个合并。

工作里真实遇到过的场景

搜索框输入联想

这是防抖的标准场景,上面例子就是。用节流在这里不合适——节流会在用户还在输入的时候就触发请求,拿到的关键词是残缺的。

防抖等用户”停”了再请求,体验更准确。

滚动加载更多

1
2
3
4
5
6
7
8
9
10
const loadMore = throttle(function () {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement

// 距底部 200px 时加载
if (scrollHeight - scrollTop - clientHeight < 200) {
fetchNextPage()
}
}, 300)

window.addEventListener('scroll', loadMore)

这里用节流。滚动事件触发频率极高(每秒几十次),用防抖的话,用户一直滚动就永远不触发,体验反而差。节流保证每 300ms 最多判断一次,够用了。

窗口 resize

1
2
3
4
5
6
const handleResize = debounce(function () {
// 重新计算布局、更新图表等
chart.resize()
}, 300)

window.addEventListener('resize', handleResize)

resize 也是高频事件,但跟滚动不同——调整窗口大小是一个有明确”结束点”的操作,用防抖更合适,等用户停止拖动后再重新计算。

按钮防重复点击

1
2
3
4
5
6
7
8
9
10
const submitBtn = document.getElementById('submit')

submitBtn.addEventListener('click', debounce(async function () {
submitBtn.disabled = true
try {
await submitForm()
} finally {
submitBtn.disabled = false
}
}, 1000))

这是我项目里比较常见的写法。防止用户手速快狂点提交按钮。当然更标准的做法是请求期间把按钮禁用,两者可以配合。

用 lodash 还是自己写?

工作项目里如果已经引了 lodash,直接用 _.debounce_.throttle 就好,功能全、边界情况处理得好。

自己手写是为了理解原理,面试也会考。两个都掌握就行。

1
2
3
4
import { debounce, throttle } from 'lodash-es'

// 用法一样,第三个参数是 options,可以控制是否立即执行等
const handleInput = debounce(search, 500, { leading: false, trailing: true })

一个容易踩的坑

在 React 组件里用防抖,直接这么写会有问题:

1
2
3
4
5
6
7
8
// ❌ 每次 render 都会创建新的 debounce 函数,防抖失效
function SearchBox() {
const handleInput = debounce((val) => {
search(val)
}, 500)

return <input onChange={(e) => handleInput(e.target.value)} />
}

每次 render 都创建了新的 debounce 实例,计时器自然不连续,防抖就没用了。

正确做法是用 useCallback 包一下,或者用 useRef 存起来:

1
2
3
4
5
6
7
8
9
10
11
// ✅ useCallback 保证函数引用稳定
function SearchBox() {
const handleInput = useCallback(
debounce((val) => {
search(val)
}, 500),
[] // 注意依赖项
)

return <input onChange={(e) => handleInput(e.target.value)} />
}

这个坑我自己也踩过,排查了半天发现防抖没生效,检查代码才发现是这个问题。