防抖和节流:别只记用法,先分清它们各自在挡什么

防抖和节流看起来像两个很熟的工具,真要解释清楚“为什么这里该用防抖、那里该用节流、按钮防重复点算不算同一类问题”,很多人其实只记住了用法,没真正记住判断标准。

搜索联想就是最典型的例子。用户连着输入时,请求会高频触发,返回顺序也可能乱掉,最后展示的甚至不是用户停下来时那次输入对应的结果。这个场景里为什么防抖有效,本质上不是“少发点请求”这么简单,而是你要的结果只和“最后一次稳定输入”有关。

一旦把这个判断标准说清楚,很多相近又不同的场景就能分开:滚动监听为什么更像节流,提交按钮为什么更像状态锁,防抖和节流的 leadingtrailing、取消和解绑又分别在补什么边界。下面就顺着这条线往下拆。

第一问:为什么加了防抖就好了

防抖的意思是:高频触发时,等用户停下来一段时间后再执行。

最常见场景就是团队里一个实习生负责的搜索框。用户输入:

1v
2vu
3vue

如果每次输入都请求接口,就会发三次请求。防抖后,用户停下比如 300ms,才发最后一次。

实现:

1function debounce(fn, wait) {
2  var timer = null
3
4  return function() {
5    var context = this
6    var args = arguments
7
8    clearTimeout(timer)
9
10    timer = setTimeout(function() {
11      fn.apply(context, args)
12    }, wait)
13  }
14}

使用:

1input.addEventListener(
2  'input',
3  debounce(function(event) {
4    search(event.target.value)
5  }, 300)
6)

想直观确认「连续触发只执行最后一次」,可以贴这段到控制台,它不依赖 DOM,输出是确定的——我就是当着他的面跑的这段:

1var calls = []
2var fn = debounce(function (x) { calls.push(x) }, 100)
3fn(1); fn(2); fn(3)            // 100ms 内连调三次
4console.log(calls)             // => []        此刻一次都还没执行
5setTimeout(function () {
6  console.log(calls)           // => [3]       只执行了最后一次,参数是 3
7}, 200)

关键就是中间的 fn(1)fn(2) 都被后一次的 clearTimeout 取消了,只有 fn(3) 的定时器活到了最后。所以乱序问题被连根拔掉:请求只剩最后那一发,没有「先发后至」的覆盖。

讲到这里都顺利。翻车发生在他第二天改完给我看代码的时候——写的是这样:

1// 错误!每次 input 都 new 一个新防抖函数,timer 永远是新的
2input.addEventListener('input', function(event) {
3  debounce(function() { search(event.target.value) }, 300)()
4})

**debounce 一定要在外面调用一次、拿到那个返回的函数再绑定。**上面这种写法,每次事件触发都重新生成一个全新的、timer 还是 null 的防抖函数,等于每次都立即执行,根本没防住。正确做法是先 var debouncedSearch = debounce(search, 300),再把 debouncedSearch 绑上去,让闭包里的 timer 在多次触发间共享。这个错误特别隐蔽,因为代码不报错、功能也「能用」,只是防抖完全没生效——那版提测前要不是我看了一眼,QA 都未必测得出来。

我没资格笑他。查资料那晚我在我们自己的代码库里搜了一圈 debounce,挖出一个我更早埋下的 Vue 变体坑:图省事在 methods 里写 handleInput: debounce(function () {...}, 300)。单个组件没问题,但 methods 上的函数是挂在组件选项对象上的,同一个组件的多个实例会共享同一个防抖闭包——列表页每行一个搜索框时,A 行打字会把 B 行的定时器清掉。正确姿势是在 created 里为每个实例单独造一个:

1created() {
2  // 每个组件实例各自持有一份防抖函数和它的 timer
3  this.debouncedSearch = debounce(this.search, 300)
4}

再说说防抖的「延迟感」。300ms 是我的默认值,搜索场景体感刚好;接口慢、想更省可以放到 500ms,但超过 500ms 用户会觉得「我打完字了它怎么还不动」。另外有时候产品希望「第一次输入立刻搜一次,后面再防抖」,那就要支持 leading 模式(首次立即执行),lodash 的 debounce{ leading: true, trailing: true } 配置就能做到。我给他的建议是:能不手写就别手写,项目里直接 import debounce from 'lodash/debounce',自己写的那版留着讲原理用——lodash 把后面要说的那堆边界全替你想好了。

第二问:为什么滚动那里用的是节流

节流的意思是:高频触发时,每隔一段时间最多执行一次。

常见场景是滚动监听:

1window.addEventListener('scroll', throttle(handleScroll, 200))

实现,先看时间戳版:

1function throttle(fn, wait) {
2  var lastTime = 0
3
4  return function() {
5    var now = Date.now()
6    var context = this
7    var args = arguments
8
9    if (now - lastTime >= wait) {
10      lastTime = now
11      fn.apply(context, args)
12    }
13  }
14}

滚动时不需要每次事件都计算,固定频率执行就够了。

节流的「固定频率」也能跑出来看。下面每 10ms 疯狂触发一次、持续约 1 秒,但节流间隔是 100ms,所以放行次数大约是 1000 / 100 ≈ 10 次(首次立即那一下让它落在 10~11 次附近,受调度抖动影响,所以是约数不是定值):

1var count = 0
2var t = throttle(function () { count++ }, 100)
3var iv = setInterval(t, 10)        // 每 10ms 触发一次
4setTimeout(function () {
5  clearInterval(iv)
6  console.log(count)               // => 约 10 次(10±1,不会是 100 次)
7}, 1000)

对比一下:不加节流的话 setInterval(..., 10) 一秒会触发约 100 次。节流把它压到了十分之一,这就是「固定频率放行」最直观的证据。

讲到这儿小谭追了一句让我卡壳的:「那用户停止滚动之后,最后一次会执行吗?」我当场支吾了。回去查完才把话说圆:这正是两种实现的分水岭。时间戳版本是 leading 的——第一次触发立刻执行,但「最后一次」可能丢。比如用户滚到底停住,最后那次落在节流间隔之内,回调就不执行了——做「滚动到底加载更多」时,这会导致差一点点没触发到底判断。所以另一种实现是用定时器(trailing 版),保证停下后还会补一次:

1function throttle(fn, wait) {
2  var timer = null
3  var context, args
4  return function() {
5    context = this
6    args = arguments
7    if (timer) return
8    timer = setTimeout(function() {
9      fn.apply(context, args)
10      timer = null
11    }, wait)
12  }
13}

时间戳版「首次立即、末次可能丢」,定时器版「首次有延迟、末次会补」。讲究的实现(比如 lodash 的 throttle)会把两者合起来,首尾都顾上。实际项目我按场景选:滚动加载更看重「到底了一定要触发」,我倾向定时器版或者干脆 lodash;实时改变某个数值显示这种,首次立即响应体感更好,用时间戳版。

还有个配套细节:滚动监听记得给 addEventListener{ passive: true },告诉浏览器你不会 preventDefault,滚动能走合成线程,配合节流体验更顺。这个我在上个月弹窗那篇里细讲过,不重复。

补一个我讲给小谭的延伸思路:对「跟着滚动/拖拽实时重绘」这类纯视觉更新,比起用毫秒数节流,更贴合屏幕的是 requestAnimationFrame——它在每次重绘前回调,频率自动对齐显示器刷新率。在常见的 60Hz 屏上,相邻两帧的时间差约 1000 / 60 ≈ 16.7ms,可以在浏览器控制台用 performance.now() 量出来:

1var prev = null, n = 0
2function loop(now) {
3  if (prev !== null) console.log((now - prev).toFixed(1)) // 多数行约 16.7
4  prev = now
5  if (++n < 5) requestAnimationFrame(loop)
6}
7requestAnimationFrame(loop)
8// 典型输出(60Hz 屏):16.7  16.6  16.7  16.8 …  会有小幅抖动

所以「节流到 16ms」和「用 rAF」效果接近,但 rAF 更省——页面不可见(切到后台标签页)时它会自动暂停,不像 setTimeout 还在空转。

回到第二问的正面回答:怎么选

「为什么搜索用防抖、滚动用节流」,我重新组织过的答案是这样的。

防抖适合「只关心最后一次」的场景:

  • 搜索输入
  • 窗口 resize 后重新布局
  • 表单字段异步校验

节流适合「持续触发,但固定频率响应」的场景:

  • 滚动加载
  • 拖拽移动
  • 鼠标移动
  • 页面滚动进度

如果用户连续输入,搜索只要最后结果,选防抖。如果用户持续滚动,页面需要定期判断是否到底部,选节流——防抖在这里是灾难:用户一直匀速滚,「停下来」永远不发生,加载更多一次都不会触发。

再举几个我自己实际拿不准、最后这么定的例子。窗口 resize 重画图表(echarts)我用防抖,因为拖拽改窗口大小的过程中间态没意义,只要松手后的最终尺寸重画一次。但「拖拽元素跟随鼠标」必须用节流不能用防抖——防抖会让元素停在原地、等你松手才瞬移过去,体感像卡顿。表单字段失焦校验我两个都不用,直接 blur 触发就行,没必要硬套。

一句经验,也是我最后写在给小谭的笔记末尾的:分不清的时候问自己「中间过程的那些次到底要不要响应」。要,且想控制频率,节流;不要,只认最后一锤,防抖。

第三问:提交按钮加防抖行不行——我答错的那道

小谭的第三问「提交按钮防重复点击是不是也加防抖就行」,我当场说了「差不多」。查完资料我专门找他更正:不行,这道我答错了。

按钮提交更适合加 loading 锁,而不是单纯防抖:

1async function submit() {
2  if (submitting) return
3
4  submitting = true
5
6  try {
7    await request('/api/save')
8  } finally {
9    submitting = false
10  }
11}

给「提交」按钮加 300ms 防抖看着像解决了,但只要接口超过 300ms(很常见),用户在等待期间再点一次,防抖间隔早过了,照样发第二个请求。结果就是慢网络下重复下单——在电商公司,这四个字的分量不用我多说。防抖管的是「短时间高频触发」,而提交按钮的问题是「一次请求还没回来又点了」,这俩根本不是一回事。

正确的姿势就是上面那段:一个 submitting 标志位,请求期间锁住按钮(UI 上同步置灰、转圈),finally 里再解锁。但前端这层只是体验优化,真正的兜底必须在后端做幂等——前端拦不住手快、拦不住网络重试、更拦不住有人直接调接口。我们订单模块的做法是提交时前端生成一个唯一的 requestId 带上,后端用它去重,同一个 id 只认第一次。前后端各守一道,才算稳。这一段我是拉着后端同事一起给小谭讲的,他补了一句很精辟的:「前端的锁是给用户看的,后端的幂等才是给钱看的。」

他没问、但我自己心虚的三处边界

三个为什么答完,我顺手把手写实现的几处边界也补进了笔记——这些是我自己以前踩过、这次查资料才彻底想明白的。

边界一:this 和参数不要丢

手写 debounce/throttle 时要保留 this 和参数:

1fn.apply(context, args)

否则用在对象方法里会出问题。这不是抠细节,我去年吃过一次亏:把防抖用在 Vue 组件方法上,没保留 this,回调里 this.keyword 直接 undefined 报错,因为 setTimeout 里普通函数的 this 指向了 window(严格模式下是 undefined)。用 var context = this 把外层 this 存下来、再 fn.apply(context, args) 传回去,就对了。同理 arguments 也要透传,不然事件对象 event 就丢了,回调里 event.target.value 拿不到。

环境支持的话,用箭头函数能省掉存 this 这步,因为箭头函数不绑定自己的 this

1function debounce(fn, wait) {
2  var timer = null
3  return function(...args) {
4    clearTimeout(timer)
5    timer = setTimeout(() => fn.apply(this, args), wait)
6  }
7}

注意外层那个 return function 不能也写成箭头函数——它必须是普通函数才能接住调用时的 this(事件触发时的 DOM 元素或组件实例),只有里面 setTimeout 的回调用箭头函数去「借」外层的 this。这层嵌套关系我画在白板上给小谭过了一遍,他反问的「那外层为什么不能也用箭头」恰好就是理解闭包和 this 的分界线——能把这个讲明白,两个知识点就都通了。

边界二:取消防抖

有些场景需要取消未执行的任务:

1function debounce(fn, wait) {
2  var timer = null
3
4  function debounced() {
5    var context = this
6    var args = arguments
7
8    clearTimeout(timer)
9
10    timer = setTimeout(function() {
11      fn.apply(context, args)
12    }, wait)
13  }
14
15  debounced.cancel = function() {
16    clearTimeout(timer)
17    timer = null
18  }
19
20  return debounced
21}

组件销毁时可以取消,避免页面离开后还执行回调。这个坑很真实:用户在搜索页打了字、还没到 300ms 就点返回离开了,组件已经销毁,定时器到点还是触发了回调,回调里 this.$set 一个已经不存在的组件,控制台一片报错,严重的还内存泄漏。我的习惯是在 Vue 的 beforeDestroy 里调一下 cancel

1beforeDestroy() {
2  this.debouncedSearch.cancel()
3}

lodash 的 debounce/throttle 自带 .cancel().flush()(立即执行挂起的那次),所以又是一个我推荐直接用 lodash 的理由——这些边界它都替你想好了。自己手写时记得把 cancel 补上,尤其是单页应用,组件频繁创建销毁,漏掉清理迟早出问题。

边界三:别忘了解绑事件

跟取消配套的还有一件事:addEventListener 绑上去的防抖/节流函数,组件销毁时要 removeEventListener 解绑。而 removeEventListener 要求传入的是同一个函数引用,所以必须把 debounce(...) 返回的那个函数存起来,绑和解绑用同一个变量:

1// 绑定时存引用
2this.onScroll = throttle(this.handleScroll, 200)
3window.addEventListener('scroll', this.onScroll)
4
5// 销毁时用同一个引用解绑
6window.removeEventListener('scroll', this.onScroll)

我见过有人 removeEventListener('scroll', throttle(this.handleScroll, 200)),又 new 了一个新函数去解绑,自然解不掉,滚动监听一直挂在 window 上累积,切几次页面后卡得明显。这类「监听器泄漏」在长生命周期的单页应用里特别容易被忽略。

讲完之后

上周五我把重新整理的这份笔记发给小谭,顺便承认了两件事:提交按钮那道我答错了,methods 里那个共享闭包的坑是我自己去年埋的。他表情有点意外,大概没想到带他的人会认账。

带人三个星期,最大的收获反而是我自己的:**概念一句话、手写是入场券、选型见真章、边界定成色。**防抖是等停下来再执行,节流是固定频率执行;搜索用防抖,滚动用节流,提交用 loading 锁加后端幂等;this 和参数要透传,组件销毁要 cancel 加解绑。这些拆开都不难,难的是每一条背后都得站着一次真实的教训——以前这些教训是我自己踩出来的,现在多了一种获得方式:被一个实习生连问三个为什么,然后老老实实回到书桌前。

小谭那个搜索联想最后用的是 lodash 的 debounce(search, 300)created 里建、beforeDestroy 里 cancel,一次过了 code review。他在提交说明里写了句「防抖:等停下来;节流:管频率」。行,这半个月没白带。