防抖和节流:别只记用法,先分清它们各自在挡什么
防抖和节流看起来像两个很熟的工具,真要解释清楚“为什么这里该用防抖、那里该用节流、按钮防重复点算不算同一类问题”,很多人其实只记住了用法,没真正记住判断标准。
搜索联想就是最典型的例子。用户连着输入时,请求会高频触发,返回顺序也可能乱掉,最后展示的甚至不是用户停下来时那次输入对应的结果。这个场景里为什么防抖有效,本质上不是“少发点请求”这么简单,而是你要的结果只和“最后一次稳定输入”有关。
一旦把这个判断标准说清楚,很多相近又不同的场景就能分开:滚动监听为什么更像节流,提交按钮为什么更像状态锁,防抖和节流的 leading、trailing、取消和解绑又分别在补什么边界。下面就顺着这条线往下拆。
第一问:为什么加了防抖就好了
防抖的意思是:高频触发时,等用户停下来一段时间后再执行。
最常见场景就是团队里一个实习生负责的搜索框。用户输入:
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。他在提交说明里写了句「防抖:等停下来;节流:管频率」。行,这半个月没白带。