防抖和节流这两个词在面试题里出现频率有多高在实际项目里就有多重要。做前端几年你会发现性能优化翻来覆去就那么几板斧而这两个函数绝对是最常用、也最容易被用错的。很多朋友能背出定义但一到真刀真枪写代码要么把两者混用要么写出来的函数有问题要么根本不清楚什么场景该用哪个。这篇文章我就把这两个函数的原理、手写实现、应用场景和踩坑经验一次性讲透保证你看完能直接用到项目里。先花一分钟说清楚这篇文章能帮你解决什么问题看完之后你能彻底搞懂防抖和节流的本质区别能手写一个健壮的、支持取消和立即执行版本的实现能根据实际需求准确判断该用哪个也能避开我踩过的那些坑。不管你是刚入行的前端新人还是写了几年但没仔细研究过源码的开发者这篇文章都值得你花十分钟读完。1. 防抖和节流的本质区别一句话就能讲明白1.1 为什么要限制函数执行频率浏览器里的用户交互事件触发频率高得吓人。鼠标移动、输入框打字、窗口缩放、滚动页面这些操作每次触发都可能执行几十上百次回调。举个例子用户在一个输入框里输入防抖你以为只触发了3次事件实际上每敲一个字母input事件都会连续触发多次总计可能触发几十次。如果回调里还要发请求、做复杂计算、操作DOM那性能问题就来了。最直接的后果就是页面掉帧、接口请求堆积、响应变慢。解决办法不是不监听事件而是控制回调的执行频率防抖和节流就是干这个事的。1.2 用电梯门的例子理解防抖防抖的核心思想是在事件被连续触发时只有当最后一次触发后等待了一段时间才执行目标函数。简单说就是只看最后一下。这个不太好理解的话你可以想象电梯门电梯开门后如果一直有人往里进门就一直保持打开状态直到最后一个乘客进入电梯开始倒计时关门。如果在倒计时期间又有新人来倒计时重新开始。防抖就是这样连续触发的事件会被合并成一次执行执行时机取决于最后一次触发后是否安静下来。所谓的debounce 500毫秒就是说事件停止触发500毫秒之后函数才执行。如果用户在输入框里连续打字中间没有停顿超过500毫秒那么等待期间一直不会发请求直到用户停下来500毫秒才发送一次请求。1.3 用技能冷却理解节流节流的核心思想是在一段时间内无论事件触发多少次函数最多只执行一次。这个和防抖完全不同它不关心你是不是连续触发只关心固定的时间间隔内有没有额度执行。想象成游戏里的技能冷却技能释放之后不管你怎么疯狂按键在冷却结束之前技能就是放不出来。只有等冷却时间到了才能释放下一次。节流就是这样一个机制函数在wait时间内只执行一次时间到了才能执行下一次。这里有一个关键区别防抖是最后一个人说了算只要一直触发就一直推迟执行节流是时间到了必须执行一次不管你怎么触发该执行的时候就会执行。1.4 两者的边界场景对比用最常见的滚动事件来对比。页面滚动时如果监听scroll事件做操作用防抖你滚动得越快回调越不会执行。等滚动完全停下来过了一段时间回调才执行一次。如果一个页面很长用户快速滑到底那在滚动过程中回调可能完全不执行只有停下来才执行一次。用节流无论你怎么滚动每隔固定时间比如200ms就会执行一次回调。滚动过程中会看到回调稳定地按固定频率触发。从这个例子能看出来防抖适合那些只需在操作结束后执行一次的场景节流适合需要按固定频率持续执行的场景。这个判断标准后面会反复用到把它记住往后做技术选型就清楚了。2. 手写一个防抖函数从最简版本到完整版本2.1 最简防抖定时器方案防抖实现的核心依赖是setTimeout。原理是每次事件触发时先清除上一次设置的定时器再重新设置一个新的定时器。这样连续触发只会保留最后一次的定时器等到延迟时间到了才执行目标函数。function debounce(fn, delay) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这就是最核心的防抖逻辑。但仔细看有一个问题setTimeout的回调函数里用了this这个this指向的是debounce返回的闭包函数里的this吗不是的因为fn.apply(this, args)这一行里的this在箭头函数里会被捕获箭头函数没有自己的this它会继承外层普通函数的this。所以关键在于返回的这个包装函数里的this是事件触发时绑定到元素上的那个this。如果你在addEventListener里使用了这个函数this应该指向绑定的元素。我这样写就是为了保证fn执行时的this上下文和事件绑定一致不然fn里的this会变成window在一些场景下会出问题。2.2 支持立即执行的版本有时候我们希望事件一开始触发就立即执行一次后续的连续触发不再执行直到停下来一段时间后才恢复立即执行的权限。这在表单提交、按钮防连点这些场景很实用。function debounce(fn, delay, immediate false) { let timer null; let isInvoked false; return function(...args) { const context this; if (immediate !isInvoked) { fn.apply(context, args); isInvoked true; return; } if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(context, args); isInvoked false; timer null; }, delay); }; }这个版本的含义是第一次触发时立即执行然后进入冷静期。冷静期内不管触发多少次都被忽略直到距离最后一次触发过了delay毫秒冷静期结束。下次触发时又可以立即执行了。2.3 支持取消操作实际项目中经常需要在组件销毁时取消尚未执行的防抖任务。如果用户在输入框里输入了内容还没到延迟时间就切换了路由旧页面里定时器到点执行会访问已卸载的DOM轻则报错重则内存泄漏。所以cancel接口是必备的。function debounce(fn, delay, immediate false) { let timer null; let isInvoked false; const debounced function(...args) { const context this; if (immediate !isInvoked) { fn.apply(context, args); isInvoked true; return; } if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(context, args); isInvoked false; timer null; }, delay); }; debounced.cancel function() { if (timer) { clearTimeout(timer); timer null; isInvoked false; } }; return debounced; }cancel方法里我做了两件事清除定时器重置状态。如果不清除定时器即使调了cancel等到延迟结束函数还是会执行那就谈不上取消了。2.4 返回Promise的版本和TypeScript版本如果是比较新的项目可能需要拿到防抖函数的返回值或者用TypeScript保证类型安全。这里给一个TypeScript版本同时支持返回值通过Promise获取function debounceT extends (...args: any[]) any( fn: T, delay: number, immediate false ): ((...args: ParametersT) void) { cancel: () void } { let timer: ReturnTypetypeof setTimeout | null null; let isInvoked false; const debounced (...args: ParametersT) { if (immediate !isInvoked) { fn.apply(this, args); isInvoked true; return; } if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); isInvoked false; timer null; }, delay); }; debounced.cancel () { if (timer) { clearTimeout(timer); timer null; isInvoked false; } }; return debounced; }注意TypeScript版本里this隐式是any实际使用时需要根据场景调整。但核心逻辑和不带类型的版本完全一致只是多了类型约束。对于团队协作的项目使用TypeScript版本能有效避免调用方误传参数。2.5 防抖的退化场景不得不提防抖有一个天生的问题如果事件一直不停触发函数永远不会执行。举个例子用户在输入框里连续输入每次都停顿不到delay时间那防抖持续推迟执行请求一直发不出去。极端情况下输入了10秒请求可能一次都没发。解决这个问题的思路是给防抖加一个最大等待时间。就是说即使事件一直没停也强制在某个时间点执行一次。有一些工具库实现了这个选项比如Lodash的maxWait。如果你需要这个能力可以参考这个思路自己实现逻辑上是在防抖的基础上叠加一个节流逻辑在时间达到上限时强制触发。不过说实话项目中90%的场景不需要这个特性我还没有见过必须靠maxWait才能解决的典型场景。真有这需求时重新审视一下用节流是不是更合适。3. 手写节流函数两种方案对比3.1 时间戳方案节流实现有两种主流方式。第一种是用时间戳记录上次执行的时间每次触发时计算时间差如果超过了wait就执行。function throttle(fn, wait) { let previous 0; return function(...args) { const context this; const now Date.now(); if (now - previous wait) { fn.apply(context, args); previous now; } }; }这个实现的优点是简单直观、实时性好。事件触发后如果距离上次执行时间超过wait立即执行。缺点也有——第一次触发时previous为0一定大于wait所以会立即执行但最后一次触发如果不满足条件就直接丢弃了。用户滚动了页面回调最后一次可能没触发如果回调里是保存状态这种操作可能会丢失最后的状态。3.2 定时器版本另一种实现方式是定时器方案。事件触发时设置一个定时器在wait毫秒后执行回调。如果定时器已存在就什么都不做。执行完后清除定时器。function throttle(fn, wait) { let timer null; return function(...args) { const context this; if (!timer) { timer setTimeout(() { fn.apply(context, args); timer null; }, wait); } }; }这个方案的特点是延迟执行第一次触发时要等wait毫秒后才执行。不会出现首次立即执行的情况。相比时间戳方案最后一个触发不会完全被丢弃因为只要在定时器开启后停止了触发定时器到点后就会执行一次。3.3 两种方案怎么选两种方案各有取舍关键看应用场景时间戳方案适合需要首帧立即响应的场景比如游戏中的交互、按钮反馈用户期待立即看到结果。定时器方案适合需要末尾状态不丢失的场景比如自动保存草稿最后一次修改也要入库。有些场景两个都想要既要首次立即执行也要末次能执行。Lodash的throttle其实是默认集成了这两个特性——它是通过leading和trailing选项控制的时间戳和定时器组合起来实现。如果你追求极致体验可以参考Lodash的实现思路结合两种方案才有完整的效果。3.4 合成版本首次立即执行加末尾兜底我写过一个合成版本同时解决首帧立即响应和末帧不丢状态的问题function throttle(fn, wait) { let previous 0; let timer null; return function(...args) { const context this; const now Date.now(); const remaining wait - (now - previous); if (remaining 0) { // 时间到了直接执行 if (timer) { clearTimeout(timer); timer null; } fn.apply(context, args); previous now; } else if (!timer) { // 时间未到设置定时器兜底执行最后一次 timer setTimeout(() { fn.apply(context, args); timer null; previous Date.now(); }, remaining); } }; }逻辑解释一下每次触发时计算距上次执行还有多长时间remaining。如果剩余时间不足或为0说明冷却期已过立即执行并更新时间戳。如果还有剩余时间说明还在冷却期内就设置一个定时器在剩余时间结束后执行一次。这个定时器保证了最后一次触发不会丢失同时首次触发能立即响应。实现这个版本时有一个细节需要注意设置定时器后要立即return不要在后续触发时反复重置定时器否则会破坏节流效果。我上面的代码用else if (!timer)做了判断只有当前不存在定时器时才创建。如果你发现自己的合成节流有奇怪的重复执行问题大概率是这一步写错了。4. 实际场景中到底该用防抖还是节流我帮你梳理好4.1 防抖的典型应用场景防抖最常见的应用是输入框实时搜索。用户在搜索框里打字我们希望停下来才请求因为用户打字的意图是等输入完整词后再搜索而不是每敲一个字母就去请求。用防抖300ms或者500ms用户停下约半秒后才会发请求。这样做不仅减少了请求量也避免了频繁更新下拉建议列表带来的闪烁。表单表单验证同理。用户输入邮箱你不需要每个字符都校验格式等用户停止输入后再校验体验更好也不会每敲一个键就弹一次错误提示。按钮防连点用防抖的immediate版本很合适。点一下立即提交然后不管怎么连点在冷却期结束前都不会再次提交。这比禁用按钮的方案体验好因为禁用按钮会导致用户无法再次点击如果遇到网络问题想重试就麻烦了。窗口resize事件也适合用防抖。用户拖动窗口调整大小时会连续触发几十次resize事件如果你在回调里做重布局或重绘性能很差。用防抖等用户停止调整后再执行用户感知不到延迟还能省下大量无效计算。不过要注意如果 resize 回调里做的是实时同步的任务用节流才是正确的看后面讲节流场景时我再细说。4.2 节流的典型应用场景节流常用于滚动加载。用户快速滚动页面时滚动监听触发非常频繁如果用防抖只有完全停止滚动才触发加载用户可能已经滑到很底部了还没加载新内容体验很糟糕。用节流保证每200ms检查一次距离底部的距离能保证滚动过程中也有稳定频率的加载检查不会等到彻底停下来才触发。页面滚动时的「回到顶部」按钮显示/隐藏也是用节流。用户滚动时每间隔一段事件判断一次滚动位置然后更新按钮的显示状态。用防抖会导致滚动停止后才判断按钮出现时机太迟。mousemove事件处理也适合节流比如实现自定义拖拽、Canvas绘图、元素跟随鼠标移动等场景。mousemove是高频触发事件如果用防抖会有严重的跟手延迟节流能保证回调以固定频率执行视觉上虽然有一点间隔但完全不影响跟手度。游戏中的按键监听、射击冷却也是节流的典型场景。按住攻击键每帧触发多次但技能只能按固定频率释放这就是节流的本质。4.3 一张表帮你快速选择场景推荐方案原因输入框实时搜索防抖等用户输入完整词后再请求避免请求风暴表单验证防抖避免每次按键都执行重逻辑校验按钮防连点防抖(immediate)首次立即执行冷却期内不再触发窗口resize重绘防抖等停止调整后再重算布局滚动加载更多节流滚动过程中也需要持续检查位置回到顶部按扭显隐节流滚动中需要按频率更新状态拖拽/鼠标跟随节流保证跟手度防止回调过于频繁自动保存节流既保证周期保存又不会过于频繁游戏技能冷却节流固定时间间隔释放一次这个表是根据我实际项目经验整理的。记住一个核心判断标准事件结束后需要执行一次——防抖事件过程中需要按频率执行——节流。套这个标准大部分场景都能快速做出选择。4.4 单独看一个高并发场景的细节高并发请求场景最容易犯错的地方是用了防抖想减少请求数结果发现请求一个都没发出去。前面提过防抖无限推迟执行就是这个原因。比如一个频控系统用户快速点击刷新按钮每次点击都触发防抖刷新只要点击间隔小于delay防抖就一直推迟用户会觉得点了没反应。这种情况下正确的方案是用防抖的immediate版本首次点击立即执行后续点击被合并或者直接用节流。说到这里顺便提一句做性能优化不能死记硬背方案要理解场景背后的真实诉求——用户期望立即响应就用节流或防抖immediate用户期望停止后统一处理就用普通防抖。5. 关键原理深入分析理解了才能写出正确的实现5.1 this指向问题怎么处理手写防抖节流时最容易出问题的就是this的丢失。当你在addEventListener里传入debounce返回的函数时这个包装函数在执行时会拿到正确的this。但如果你内部把fn直接调用比如timer setTimeout(fn, delay);那么fn里的this就变成window了因为在setTimeout回调中执行普通函数时this指向全局对象严格模式下是undefined。这显然不是我们要的。正确处理方式是用fn.apply(this, args)其中this是包装函数被调用时的上下文。如果包装函数是在element.addEventListener(click, debouncedFn)中触发this就指向element。这样目标函数fn就拿到了和原事件监听一样的this。5.2 参数传递的细节防抖和节流返回的是一个包装函数调用时传入的参数如何传递给原始函数这里也有陷阱。如果直接写timer setTimeout(fn(arg1, arg2), delay);那fn会立即执行不是延迟执行。正确做法是收集包装函数的参数在回调里传给fn。return function(...args) { // ... timer setTimeout(() { fn.apply(this, args); }, delay); };顺便提一个进阶细节事件对象event在延迟执行时大概率已经失效。尤其是在React中合成事件会在事件处理函数结束后被重置如果你在防抖函数里用到了event对象等延迟结束后访问e.target可能会抛错或拿到null。正确的做法是在包装函数开头就保存需要的event属性比如const target event.target; // 延迟后使用 target这个坑我踩过不少次写出来提醒一下。5.3 定时器与时间戳的边界行为手写实现时最让人困惑的是边界情况第一次执行、最后一次执行、连续触发时定时器如何交互。防抖的边界在于每次触发都清定时器新建定时器所以边界行为非常好的统一——最后一次触发后等delay执行。但immediate版本存在一个特殊情况第一次触发立即执行后如果第二次触发在delay之内且该触发被判定为进入冷静期那么后续第三次触发会怎样我上面的实现里冷静期内到达的触发会走清定时器、重建定时器的分支所以冷静期其实没有被锁定只是不会立即执行。你要注意这个行为是否符合你的预期。节流的时间戳方案边界在于上一次执行时间一旦设置后续如果在wait时间内的触发全部被忽略最后一次触发如果恰好比wait晚了一点又执行了。边界行为相对稳定。定时器方案的边界在于第一次触发设置定时器后后续所有触发在定时器存在期间都被忽略。定时器到点执行、清除、下一个触发重新设置定时器。边界行为是以触发间隔为准每隔wait执行一次但首次执行有wait延迟。为了统一首末行为我建议直接使用合成版本它把两种方案的优点合在一起边界行为也更好控制。5.4 为什么不能把防抖和节流搞混一个经典错误是滚动加载用了防抖结果用户快速滚动到页面底部时一直触发加载检查但因为每次滚动都在重置定时器加载操作永远不执行页面一直加载中。这个错误会导致线上事故——用户永远看不到新内容以为网站坏了。另一个经典错误是输入框实时搜索用了节流用户每次停下来不到wait就继续输入请求会按固定频率发出产生大量不必要的搜索请求和服务端压力。理论上没问题但体验会很差因为结果是按固定频率刷新的可能你输入完了结果还没刷新出来。搞混两者的本质是没理解**防抖关注安静、节流关注频率**这一核心差异。前面那个电梯门的例子我建议你牢牢记住防抖是电梯门等最后一个人节流是技能冷却时间到了才能放。6. 常见问题与排查技巧实录6.1 输入框防抖不生效每次按键都发请求排查思路先确认为什么防抖没起作用。常见原因有几类一是debounce返回的新函数没有正确绑定比如onInput{debounce(handleSearch, 300)}写在onInput上每次渲染都调用一次debounce生成一个全新的防抖函数状态根本不会保留。正确做法是放到一个变量里保证每次渲染用的是同一个函数或者用useMemo/useCallback包裹。二是immediate参数传错。如果你用了immediate: true又期望停止后执行那行为肯定不对。检查参数是否合理。三是定时器被意外清除。比如组件每次渲染都重新调用debounce返回到函数被替换timer也被重制为null相当于防抖从来没有生效。也就是第一条说的。6.2 节流第一次不执行要等很久才有反应如果你用的是纯定时器方案的节流第一次触发时会延迟wait才执行。如果wait设置得太大比如500ms用户会感觉到明显的延迟。解决方式是改用合成版本或时间戳版本让首次触发立即执行。还有一种情况是节流函数被bind到错误的this导致内部逻辑没走通白等了。排查时可以在节流函数里打印日志确认什么时候执行了、是不是从未执行。6.3 防抖函数在React中失效了这是React开发者最容易踩的坑。React的函数组件每次状态更新都会重新执行渲染如果你在渲染函数里直接写const handleChange debounce(fn, 300)每次渲染都会创建一个全新的防抖函数原防抖状态timer随旧函数被丢弃防抖自然失效。正确做法是用useRef或useMemo保持同一个防抖函数实例const debouncedSearch useMemo( () debounce(handleSearch, 300), [] // 依赖为空数组 );useCallback也可以但要注意把防抖函数本身用useRef保存避免依赖变化导致重新生成。还有一个很容易忽略的问题组件卸载时没有调用cancel。旧防抖函数持有定时器组件卸载后定时器到点会触发fn如果fn里引用了DOM元素或者setState在React 18以后的并发模式下会看到Cant perform a React state update on an unmounted component警告。正确的清理方式是useEffect(() { return () { debouncedSearch.cancel(); }; }, [debouncedSearch]);6.4 定时器回调里this指向不对这是手写实现时最容易出的问题。排查思路是在回调里打印this看它指向哪里。如果打印出来是window或undefined说明fn执行时的this不对。检查代码里是否用了箭头函数包裹fn.apply(this, args)箭头函数的this是词法作用域继承来的。如果你在setTimeout里写了fn(args)那this肯定会丢失改成fn.apply(this, args)即可。6.5 防抖函数传参带来的坑防抖函数如果要在回调里使用event对象一定要提前保存需要的属性。一个典型报错是Cannot read properties of null (reading value)原因就是e.target在延迟执行后变成了null。在Vue中这个现象不明显因为原生DOM事件对象在延迟后一般还能访问但属性可能已被清空在React中尤其明显。排查方法在包装函数里提前把需要的值取出来function handleInput(e) { const value e.target.value; debouncedSearch(value); // 只传值不传 event }6.6 防抖和节流到底哪个能解决请求过多问题这其实是根据现象选工具的问题。如果现象是请求发太频繁服务端压力大那要分情况用户在输入框里打字每字一搜——用防抖用户在疯狂点击按钮每次点击都触发下载——用节流或者防抖immediate页面滚动时后端请求不断——用节流。注意哈防抖和节流本质上都是前端减少请求次数的手段但它们的策略不同。防抖针对连续操作做合并节流针对持续操作做降频。判断标准还是回到那句事件结束后需要执行一次——防抖事件过程中需要按频率执行——节流。7. 工具库的防抖和节流源码里藏了不少好东西7.1 Lodash的debounce和throttle细节Lodash是前端最常用的工具库它的debounce和throttle实现非常完善值得研究。核心API是_.debounce(func, [wait0], [options]) _.throttle(func, [wait0], [options])options里可以配置leading是否在触发开始时就执行和trailing是否在触发结束后执行以及maxWait最大等待时间。默认leading: false, trailing: true。这就是为什么你直接用Lodash的debounce时事件触发后第一次不会立即执行而是等wait后才执行。Lodash的throttle其实是通过_.debounce加上maxWait: wait实现的。这个设计很精妙——它把节流理解为带最大等待时间的防抖。事件持续触发时防抖不断推迟执行但maxWait限制了最大等待时间超过这个时间强制执行一次。从效果上看throttle和防抖的区别就被maxWait抹平了。这套设计思路你理解了以后手写实现也有启发。7.2 要不要用Lodash几个判断标准现在前端项目里用Lodash还是有不少争议我个人的建议是项目已经引入Lodash就用它的实现没引入就别为了这一个函数引入整个库。你可以用lodash-es按需导入打包体积增加得有限import debounce from lodash-es/debounce; import throttle from lodash-es/throttle;这样只打包你用到的那部分代码体积问题不用太担心。如果不想用Lodash我上面的手写版本足够覆盖90%的场景。什么时候值得引入Lodash当你要用到leading、trailing、maxWait组合控制并且不想自己处理复杂边界情况时。Lodash的防抖里还包含了对cancel、flush、pending这些方法的完整实现它的鲁棒性是手写版本短期内比不了的。7.3 从源码里可以学到的设计思路阅读Lodash的源码你会发现它用了很多内部状态标志位比如timerId、lastArgs、lastThis、result、lastCallTime、lastInvokeTime。这些标志位管理着最后一次调用的参数、上下文、调用时间、执行时间。它还用了一个shouldInvoke函数判断是否应该执行把复杂的判断逻辑提取成独立函数。这个代码组织方式特别值得学习——防抖节流听着不难但想写得逻辑清晰可维护拆分函数是最快的路径。另外一个设计思路是使用leading和trailing的组合leading控制首次是否执行trailing控制末尾是否执行。Lodash默认leading: false所以你直接_.debounce(fn, 500)时的行为是停止后500ms执行。如果你设置为leading: true, trailing: false就变成了点击立即执行之后完全忽略。这四个参数相互组合能适应各种复杂场景理解这个组合逻辑后再写其他工具函数思路会清晰很多。8. 写一个在线演示页面方便你验证两种函数的行为差异8.1 简单的演示页面搭建我在本地跑过一个小demo就是为了直观验证防抖和节流的行为差异。用一个文本框监听input事件一个简单的计数器统计事件触发次数和执行次数再加一个50px高的滚动区域统计scroll事件次数前后对比就能看清两者的区别。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title防抖 vs 节流演示/title style body { font-family: system-ui, sans-serif; padding: 20px; } .box { border: 1px solid #ccc; padding: 16px; margin: 16px 0; border-radius: 8px; } .log { background: #f5f5f5; padding: 8px; font-family: monospace; height: 120px; overflow-y: auto; } /style /head body div classbox h2输入框/h2 input idinput placeholder在这里输入文字观察执行次数 / div触发次数span idtriggerCount0/span/div div防抖执行次数span iddebounceCount0/span/div div节流执行次数span idthrottleCount0/span/div /div div classbox h2滚动区域/h2 div idscrollArea styleheight: 100px; border: 1px solid #ccc; overflow: auto; div styleheight: 400px; background: linear-gradient(#fff, #eee);向下滚动/div /div div触发次数span idscrollTriggerCount0/span/div div防抖执行次数span idscrollDebounceCount0/span/div div节流执行次数span idscrollThrottleCount0/span/div /div div classbox h2日志/h2 div classlog idlog/div /div script function debounce(fn, delay) { let timer null; return function(...args) { const context this; if (timer) clearTimeout(timer); timer setTimeout(() fn.apply(context, args), delay); }; } function throttle(fn, wait) { let previous 0; return function(...args) { const context this; const now Date.now(); if (now - previous wait) { fn.apply(context, args); previous now; } }; } // 省略了DOM绑定细节核心演示已完整 /script /body /html在这个页面上你在输入框里快速输入防抖节流对比这几个字能看到触发次数远大于防抖执行次数节流执行次数则比较均匀。滚动区域的数据差异更明显——触发次数可能是上百次节流执行次数则是按照你设置的wait频率稳步增加防抖执行次数可能只有一次。8.2 通过演示验证边界行为写这个demo的过程中我发现了几个有意思的行为也提醒你注意input事件在中文输入法下触发频率高且配合防抖时如果输入法在组合输入阶段每帧触发一次input事件会导致防抖不断重置。这时候可以用compositionstart/compositionend来判断是否在组合输入组合结束后再执行防抖。滚动事件触发的频率和具体浏览器、操作系统有关。Mac的触控板滚动事件频率很高Windows鼠标滚轮慢一些。因此不要假设滚动事件触发频率是固定的节流的wait要根据实际手感调整。防抖的delay不要设得太大或太小。设太小比如50ms在慢电脑上体现不出优化效果设太大比如1000ms用户会感觉到明显的卡顿延迟。经验值是搜索场景300ms-500ms滚动场景100ms-200ms按钮防连点300ms左右根据不同场景做取舍。8.3 进一步扩展把防抖节流封装成Vue指令或者React Hook项目里如果多个组件都要用防抖节流封装成复用单元是更好的选择。Vue 3里可以写一个自定义指令import { debounce } from ./debounce; const vDebounce { mounted(el, binding) { const { fn, delay 300, immediate false } binding.value; el._debounceFn debounce(fn, delay, immediate); el.addEventListener(click, el._debounceFn); }, unmounted(el) { el.removeEventListener(click, el._debounceFn); el._debounceFn.cancel(); } };React里可以写一个useDebouncedCallbackimport { useRef, useEffect } from react; function useDebouncedCallback(callback, delay) { const argsRef useRef(); const timerRef useRef(); useEffect(() { return () { if (timerRef.current) { clearTimeout(timerRef.current); } }; }, []); const debouncedCallback useMemo(() { return function(...args) { argsRef.current args; if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current setTimeout(() { if (typeof callback function) { callback(...argsRef.current); } }, delay); }; }, [callback, delay]); return debouncedCallback; }注意上面React版本的useMemo依赖数组里有callback这意味着每次组件渲染时如果callback引用变化比如匿名箭头函数会产生新函数。如果需要稳定引用需要配合useRef修改。这里只是展示核心思路实际项目中按需调整。封装完成之后在多个组件里使用同一个防抖实例不仅代码更整洁也避免了每个组件各自创建防抖实例导致的状态管理混乱。更重要的是在组件卸载时统一调用cancel能杜绝内存泄漏隐患。9. 最后分享一些我踩过的坑和心得这些坑是我在实际项目中真实遇到的写出来省得你走弯路。第一个坑是防抖解决一切的思维误区。有一段时间我在项目里只要遇到高频事件就上防抖结果滚动加载因为防抖效果太差被用户吐槽。后来改成语义更合适的节流问题立刻消失。防抖和节流没有谁比谁高级它们是两种不同的策略选错方案等于白写。第二个坑是清理定时器的问题。在SPA应用里组件卸载后定时器还在走是常见的内存泄漏源头。Rreact项目里这个问题更隐蔽因为组件卸载后定时器执行setState不会有明显的报错提示只有React 18会打印警告你会误以为代码没问题。养成习惯凡是用了防抖和节流的地方组件卸载时必须调cancel。第三个坑是不要随手设一个很大的延迟。我看到过有人把防抖延迟设为2000ms理由是这样一定不会频繁请求。但实操下来用户体验特别差操作半小时都没反应用户以为系统坏了。延迟时间要结合实际场景测试不能瞎拍。一个折中的方法是用maxWait把防抖的无限等待限制在可接受的范围内但更推荐根据场景换用节流。第四个心得是手写一遍真的很有用但生产环境用Lodash也不丢人。自己手写能让你深入理解底层原理但Lodash的成熟度、边界处理能力不是随手手写版本能比的。如果是核心业务代码我建议直接用Lodash如果是学习项目或者代码量不大自己实现完全够用。第五个心得是防抖和节流不仅用于前端事件处理。把函数输入输出泛化之后它们也能用在接口请求封装的频率控制、WebSocket消息的批量处理、服务端接口的限流逻辑里。理解了本质换个环境照样能用。这篇文章到这里我已经把防抖和节流的原理、手写实现、场景选择、坑点排查都讲透了。这些都是我实际开发中积累的经验照着做你会发现性能问题其实没有想象中那么难解决。如果你在实践里遇到了我没提到的问题欢迎评论区交流我也很想看看你们在真实项目里是怎么用这两个函数的。