资讯动态

JavaScript防抖与节流:从原理到项目实战,一篇讲透高频事件优化

发布时间:2026/10/13 4:50:21 来源:尧图企业网站定制
搜索框刚敲完两个字联想请求已经发出去七八个窗口拖一下 resize回调函数像机关枪一样哒哒哒响个不停。这种问题做过前端的人大概率都遇到过频率一旦上来页面就开始卡顿后台接口也被无效请求打满。解决这个问题的核心手段其实就是两个看起来很像、用起来却完全不同的概念JavaScript 防抖和节流。这篇文章直接讲清楚三件事防抖和节流的本质区别是什么、手写实现分别怎么写、以及真实项目里到底怎么选怎么用。无论你是刚接触前端的新手还是写了一段时间业务代码想补基础的老手看完应该都能直接上手改代码。1. 防抖和节流的本质区别先说结论防抖的核心逻辑是“多次触发只认最后一次”节流的核心逻辑是“固定时间间隔内最多执行一次”。一句话概括就是——防抖重在合并节流重在限频。1.1 两个场景引出两种思路想象一下你公司楼下的大门是自动感应的每次有人经过就重新计时关门只要人不断经过门就永远不关。这就是防抖事件被连续触发时只有最后一次触发后的等待期结束函数才真正执行。典型场景是搜索框联想、窗口缩放结束后重新计算布局、表单输入校验。再想象一下地铁闸机无论多少人排队刷卡每个人必须等前一个人完全通过之后才能刷下一次。这就是节流不管事件触发了多少次每个时间窗口内最多只执行一次但窗口期到了该执行就得执行。典型场景是滚动加载、拖拽、鼠标移动、游戏循环里的按键检测。这两个例子的核心差异在于防抖把“连续触发的多件事”合并成“一件事”节流把“高频触发”压制成“低频触发”但不会丢掉时间窗口内的执行机会。1.2 一张表记住半衰期对比维度防抖Debounce节流Throttle执行策略等待停止后执行最后一次固定间隔内最多执行一次执行次数持续触发时可能只执行1次持续触发时按间隔执行多次适合场景输入搜索、resize结束、按钮防连点滚动加载、mousemove、拖拽类比等人齐了才开饭地铁闸机一个一个过是否需要清理定时器每次触发都需要只需要判断间隔是否到达防抖和节流在设计思想上根本不沾边只是实现起来都用到了定时器所以经常被混为一谈。实际面试和项目中只要你能答出“防抖取最后一次、节流按频率执行、场景不同手段不同”就已经拿到基础分了。2. 自己手写防抖从 10 行版本到参数化版本理解了原理代码实现就是水到渠成的事。我不太建议一上来就背工具库源码先把最简版本写通再一步步加上参数增强理解会更扎实。2.1 最简防抖实现function debounce(func, wait) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() { func.apply(this, args); timer null; }, wait); }; }这段代码虽然短但每一个细节都是不能漏的timer放在debounce函数内部利用闭包保存状态每次调用debounce返回的新函数都会生成独立的定时器队列。每次触发时先把上次的定时器清掉再重新设置新的定时器。只要wait时间内再次触发前面的定时器就作废。箭头函数内部的this沿用包装函数作用域配合func.apply(this, args)能把调用方的this正确地传回原函数。args使用剩余参数收集无论原函数需要几个参数都能原样透传。这个版本已经满足最基本的“输入结束之后执行”需求。但它有个明显的缺陷不支持第一次立即执行。比如搜索框场景用户敲下第一个字符时你希望立刻搜索一次而不是干等 300ms 才出结果。这时候就需要第二种版本。2.2 支持 leading 和 trailing 的完整版这个版本业界常用面试手写率也很高function debounce(func, wait, leading false) { let timer null; function debounced(...args) { const callNow leading !timer; if (timer) clearTimeout(timer); timer setTimeout(() { func.apply(this, args); timer null; }, wait); if (callNow) func.apply(this, args); } debounced.cancel function () { if (timer) clearTimeout(timer); timer null; }; debounced.flush function () { if (timer) { clearTimeout(timer); func.apply(this, arguments); timer null; } }; return debounced; }leading参数控制是否在第一次触发时立即执行。核心逻辑是第一次触发时timer还是nullcallNow为true于是调用func后面紧接着再设置定时器。在wait期间继续触发timer存在因此callNow一直是false直到定时器到点被置空下一次触发才会再次立即执行。cancel方法用于取消尚未执行的定时器flush方法用于强制执行当前等待中的调用。这两个方法非常实用搜索组件卸载时调用cancel避免内存泄漏需要在某个操作前把结果强制输出时调用flush。2.3 写防抖最容易踩的坑这个坑我见过很多人踩而且踩了之后很难发现把debounce(func, 500)写在每次触发都执行的函数内部。// 错误示范每次调用都创建一个新的防抖函数防抖失效 input.addEventListener(input, () { const fn debounce(search, 500); fn(input.value); });上面这种写法每次输入都会调一次debounce(search, 500)每次生成的timer都是全新的前面的定时器根本不会清理。正确的做法是初始化时就创建防抖函数const fn debounce(search, 500); input.addEventListener(input, (e) fn(e.target.value));提示防抖函数的本质是“用闭包保存同一个 timer”如果你每次都创建一个新闭包等于重新开了一个定时器队列防抖自然就失效了。3. 手写节流两种经典实现和最终合并版本节流的实现方式比较多常见的有时间戳版本、定时器版本以及我推荐在实际项目中使用的合并版本。理解了这三种你对节流的理解基本就到顶了。3.1 时间戳版本function throttle(func, wait) { let previous 0; return function (...args) { const now Date.now(); if (now - previous wait) { previous now; func.apply(this, args); } }; }原理很简单每次调用时用当前时间戳减去上次执行的时间戳如果超过设定的wait间隔就执行并更新时间戳。这个版本的特点是“立即响应并丢弃窗口内的触发”适合滚动加载这类不需要处理结尾状态的场景。它的问题也很明显如果事件在窗口尾部触发了一次但还没等到下一次窗口开启就停止了那么最后一次触发永远不会执行。比如滚动停止时距离上次执行已经过了 400ms但下一次执行要等到 600ms 后停止滚动后这个“最后一次”的状态就被丢弃了。3.2 定时器版本function throttle(func, wait) { let timer null; return function (...args) { if (!timer) { timer setTimeout(() { timer null; func.apply(this, args); }, wait); } }; }定时器版本改用“是否已排队”的状态来控制执行。第一次触发时设置定时器在定时器生效期间不管触发多少次都不处理直到定时器到点执行函数并清空状态。它的优势是会捕获“尾部”触发也就是在窗口结束时执行一次但代价是首次响应会延迟wait毫秒。也就是说时间戳版本首响应快但丢尾部定时器版本尾部不丢但首响应慢。实际项目里很多时候两个都不能接受。3.3 合并版本首尾兼顾的节流思路是时间戳负责判断什么时候立即执行定时器负责兜底处理窗口尾部的触发。function throttle(func, wait) { let previous 0; let timer null; function throttled(...args) { const now Date.now(); const remaining wait - (now - previous); if (remaining 0) { if (timer) { clearTimeout(timer); timer null; } previous now; func.apply(this, args); } else if (!timer) { timer setTimeout(() { previous Date.now(); timer null; func.apply(this, args); }, remaining); } } throttled.cancel function () { if (timer) clearTimeout(timer); timer null; previous 0; }; return throttled; }执行逻辑分为几段remaining 0表示距离上次执行已经超过窗口期立即执行再同步清掉可能存在的兜底定时器。remaining 0且timer不存在说明还不到执行时间于是设置一个定时器等剩余时间走完再执行。remaining 0且timer存在说明这个窗口内已经安排过兜底执行直接跳过。这样既保证窗口开始时能及时响应又保证窗口尾部的事件不会丢失还保证一个窗口内最多只有一次定时器执行。唯一要注意的边界是remaining可能因为Date.now()精度问题变成负数或0所以用了 0判断。3.4 另一条优化路径requestAnimationFrame节流在某些浏览器高频事件里还有一个变体就是用requestAnimationFrame替代setTimeout。它把执行频率和屏幕刷新率绑定通常就是 60fps。滚动、动画这类场景下rAF比固定16ms的定时器更自然不会出现“定时器比帧快”导致的空转实际体验 bug 视场景而定function throttleRaf(func) { let ticking false; return function (...args) { if (ticking) return; ticking true; requestAnimationFrame(() { func.apply(this, args); ticking false; }); }; }但requestAnimationFrame也有限制页面切换到后台标签页时会被浏览器暂停数据上报这类场景就不能依赖它。真讲究的话滚动跟随动画可以两个配合rAF负责渲染帧内的位置同步节流负责控制接口请求频率各有各的用处。4. 真实项目中的应用场景选择和参数调优代码实现只是第一步真正考功夫的是“什么场景用防抖什么场景用节流参数怎么定”。4.1 搜索框联想防抖为主、参数拆半搜索时用户每敲一个字都去请求接口是典型且不必要的高频请求必须防抖。但连续输入时如果 500ms 才开始第一次请求体验会很拖沓。这时候leading: true就派上了用场const searchFetch debounce((keyword) { api.search(keyword); }, 400, true);第一个字符到达时立即请求后续快速输入过程中等待停顿 400ms 再请求既保证了响应速度又控制了请求数量。wait的值用 300ms500ms 都合适具体看接口速度和用户输入节奏。接口慢的wait拉长一些接口快的wait短一些。4.2 窗口 resize、滚动加载节流更顺手resize和scroll本身就是“高频且持续触发”的事件用防抖会导致“一直拖不释放就一直不执行”用户拖到一半想看计算结果都看不到。用节流则能让回调每 100ms200ms 执行一次持续反馈但不卡顿。const onScroll throttle(() { // 判断滚动到接近底部时加载更多 }, 200);如果还想在滚动停止后额外做一次收尾工作比如记录最终滚动位置可以在节流的基础上再套一层防抖const handleScrollEnd debounce(() { // 滚动结束后的处理 }, 300); window.addEventListener(scroll, () { onScroll(); handleScrollEnd(); });这个组合方案也是面试里常被追问的核心想考察你是不是真的理解两者的互补关系。4.3 表单按钮防重复提交防抖 禁用双保险按钮连续点击导致重复提交是后台管理系统里的经典 bug。防抖能做到但更稳的做法是配合一个disabled状态let isSubmitting false; button.addEventListener(click, () { if (isSubmitting) return; isSubmitting true; submitForm().finally(() { isSubmitting false; }); });防抖解决的是“手抖连点”问题disabled解决的是“异步未返回再点”问题。两者不冲突可以一起上。纯防抖处理按钮有一个小坑如果防抖时间设为 500ms用户点了之后没反应就再点一下每次点击都重新计时请求迟迟发不出去体验反而更差。4.4 确定 wait 参数别拍脑袋输入联想300ms500ms可以用leading补偿首字延迟。按钮防连点200ms400ms配合按钮禁用。滚动加载100ms200ms间隔太短压不住频率太长感觉滚动不跟手。resize 重新计算布局可以考虑防抖 150ms300ms或者节流 100ms200ms取决于是否需要实时反馈。注意没有统一标准值最终以实际机型上的体感为准。测试时要重点看低端机型上的表现有些问题在高配手机上根本复现不出来。5. 框架里的防抖节流React/Vue 实践补充业务代码和原生 Demo 有一个重大区别框架有生命周期组件可能被卸载闭包里的引用可能过期。处理不好轻则防抖无效重则内存泄漏。5.1 React Hook 中的防抖函数函数组件每次 render 都是一个新的函数环境如果直接在组件内部封装防抖函数每次渲染都会重新创建定时器队列造成防抖失效。标准解法是用useRef保存防抖函数让它在整个生命周期内保持同一个引用function useDebouncedCallback(callback, delay) { const callbackRef useRef(callback); const debouncedRef useRef(); useEffect(() { callbackRef.current callback; }, [callback]); useEffect(() { debouncedRef.current debounce((...args) { callbackRef.current(...args); }, delay); const current debouncedRef.current; return () current.cancel(); }, [delay]); return debouncedRef.current; }这段代码里callbackRef负责保存最新版本的callback避免防抖函数闭包里访问到旧 props 或 state。debouncedRef只依赖delaydelay不变就不会重建防抖函数。组件卸载时通过useEffect的清理函数调用cancel未执行的定时器被释放。5.2 React StrictMode 下的双重调用React 开发模式下的StrictMode会让 effect 在挂载时执行两次如果你的清理函数忘记清除定时器可能出现防抖函数被创建两次、旧定时器残留的情况。解决思路很简单一定要在 effect 的清理函数中调用cancel不要只在组件卸载时清理。双重挂载在正常开发模式下无法避免但你只需要记住“谁创建谁清理”这个原则就不会出问题。5.3 Vue 场景下的写法Vue 里如果用的是 Options API可以在created钩子里创建防抖函数并挂到this上然后在组件卸载前清理created() { this.searchFetch debounce((keyword) { this.fetchSuggestions(keyword); }, 300); }, beforeUnmount() { this.searchFetch.cancel(); }如果用 Composition API思路和 React 几乎一模一样用ref存防抖函数在onBeforeUnmount清理。框架虽不同闭包和定时器清理的思路完全通用。6. 常见问题排查一份速查表这些年做项目把大多数防抖节流相关的问题归类一下基本就这几类。症状可能原因排查方向防抖完全失效每次触发都立即执行每次调用都重新创建防抖函数检查防抖函数的创建位置是否在初始化时创建防抖函数内部读不到最新 state闭包捕获了旧值用 ref 或 refCallback 保存最新值组件卸载后控制台报 setState 警告定时器回调在卸载后仍执行在清理函数中调用 cancel节流首响应很慢用了纯定时器版节流改用时间戳版或合并版节流丢掉了最后一次触发用了纯时间戳版节流合并版或追加尾部防抖定时器执行次数比预期多清理逻辑里没把 timer 置 null执行完func后记得timer null严格模式下 behavior 异常effect 被重复调用旧定时器残留effect 内部主动 cancel不要依赖组件卸载提示排查这类问题最好的方法不是看代码猜而是在防抖/节流函数入口和func调用处加几行console.log把调用次数和时间戳打出来。一眼就能看出到底有没有生效。7. 一点实战心得最后分享几个我在实际项目中积累的心得。第一不要为了用而用。有些场景事件频率本身很低比如 change 事件加不加防抖效果没差别还白白增加代码复杂度。防抖和节流只解决“高频事件导致的不必要消耗”问题低频事件不需要。第二工具函数要允许“取消”和“立即执行”。真实业务里这两件事几乎一定用得上。输入框清空时取消排队请求、页面切走时清理定时器、某个操作需要立刻执行当前等待的结果都离不开cancel和flush这两个能力。第三组合用法比单一用法更常见。滚动中用节流控制接口频率停止后用防抖做收尾处理搜索中leading立即响应trailing保证停顿后仍能请求。组合没有固定公式核心是明确“哪些状态必须被处理哪些状态可以丢弃”。按我个人的习惯这类高频优化代码我一般不用第三方库写一份 30 行左右、带cancel和flush的工具函数放项目公共模块里谁需要谁引反而比到处安装依赖和封装组件更可控。你把原理吃透之后就算面试官当场让你手写也只是一分钟的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑