ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

防抖与节流:前端高频事件性能优化的核心利器

防抖与节流:前端高频事件性能优化的核心利器 1. 一个高频事件引发的性能灾难以及两个救火队员几个月前接手了一个后台管理系统里面有个搜索框。产品需求很简单用户在输入关键词的同时列表要实时刷新出搜索结果。开发同学也很实在直接在input的input事件里绑了一个接口请求。结果一上线就出事了——用户每敲一个字母请求就发出去一次联,联想,联想笔,联想笔记本……打字快的时候一秒钟能触发七八次后端直接被干趴下了浏览器也卡得不行。类似的现象还有窗口resize时重算布局、页面scroll时做懒加载、鼠标mousemove时绘制轨迹。这些事件的特点都是触发极其频繁但绝大多数触发我们根本不关心。真正的问题不是事件触发太多次而是每次触发都执行了不该执行的操作。这时候就需要两兄弟出手了防抖函数debounce和节流函数throttle。它们的核心作用都是限制函数的执行频率但思路完全相反。用一个不严谨但特别好记的类比防抖是电梯关门——电梯门开的时候只要还有人进来就重新计时直到没人进为止才关门出发。节流是公交车发车——不管站台上来了多少人车子固定每隔一段时间发一班不会因为没人来就提前走也不会因为人多就加开。这篇文章我会把这两个函数的原理、手写实现、真实业务选型以及各种边界情况一次性讲透。不管你是刚入行的前端新人还是写了好几年业务代码但一直停留在会用lodash阶段的老手这篇都值得花十分钟看完。2. 防抖函数的完整实现从零开始手写理解每一行代码2.1 基础版延迟执行的核心逻辑防抖的本质是**事件停止触发一段时间后才执行**如果在这段时间内再次触发就重新计时。代码非常短function debounce(func, wait) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { func.apply(this, args); }, wait); }; }这里有个核心知识点timer通过闭包保存在内存中每次调用返回的函数时都能访问到上一次的定时器。clearTimeout干不掉也没关系定时器到期后timer只是变成了一个已过期的编号下一次赋值会直接覆盖它。但如果不清理上一次的定时器仍然会在wait毫秒后执行——这就是防抖失效最常见的bug来源。这段代码的问题在于每次执行都会延迟到事件停止后wait毫秒才触发。但实际需求往往有两种场景延迟执行停止连续操作后等待片刻再执行比如输入搜索。立即执行第一次点击立即执行后续连续点击在等待期间被忽略比如按钮提交防连点。2.2 升级版立即执行的初次触发加上immediate参数让第一次触发立即执行之后wait时间内的重复触发全部无效function debounce(func, wait, immediate false) { let timer null; return function(...args) { const callNow immediate !timer; if (timer) clearTimeout(timer); timer setTimeout(() { timer null; if (!immediate) func.apply(this, args); }, wait); if (callNow) func.apply(this, args); }; }这个写法里藏着几个关键细节需要仔细理解第一行const callNow immediate !timer——只有当immediate为真且timer为空即当前没有等待中的定时器时才立即执行。第一次调用时timer是null所以callNow为true。定时器回调里设置timer null是关键步骤。这个赋值不能省。它的作用是wait时间结束后把定时器状态重置这样下一次事件触发时callNow又能变成true从而允许再次立即执行。如果没有这一行第一次立即执行之后的所有触发都会被clearTimeout掉返回的匿名函数timer永远不会变成nullcallNow永远是false函数彻底死掉。返回值问题要注意立即执行模式下func.apply(this, args)的返回值在callNow分支里是能拿到的但延迟执行分支的返回值永远是undefined。对绝大多数防抖场景发请求、改样式、写状态来说这不算问题。但如果你真的需要拿到返回值那说明这个场景根本不适合用标准防抖得用带结果缓存的变体后面进阶部分会讲。2.3 为什么必须保留this和event对象很多人手写防抖时容易忽略this和args的透传。看这个反例// 错误示范 function debounce(func, wait) { let timer null; return function() { if (timer) clearTimeout(timer); timer setTimeout(func, wait); // this丢失event参数也没传 }; }setTimeout(func, wait)执行时func内部的this会是全局对象严格模式下是undefined拿不到原来的调用上下文——比如某个Vue实例、某个DOM元素。而且事件对象event完全丢了。如果你在防抖函数里需要读event.target.value不带参数直接写func就会在运行时报错。所以标准写法是捕获外层this和arguments统一交给func.apply(this, args)处理。这一点在面试中也是高频考点面试官就是想看你有没有注意到这个细节。2.4 立即执行版本的实际测试效果用一个真实场景来验证给一个按钮绑定防抖后的提交函数wait设置为3秒。第0秒点击第一次立即执行提交同时启动一个3秒定时器。第1秒、第1.5秒、第2秒连续点击每次都clearTimeout掉旧定时器并重新计时所以不会触发提交。第3秒整定时器到期timer置空整个过程结束。第5秒再点击此时timer已经是nullcallNow为true再次立即执行。这个模式下函数永远在第一次触发和连续触发的第3秒后之间不会多执行。如果你把immediate设为false默认则第5秒点击后会等到第8秒才执行——两种模式的行为差异很大选型时一定要想清楚业务需要的是哪种。3. 节流函数的两种实现路线时间戳与定时器的取舍节流的核心是**固定时间间隔内最多执行一次**不管事件触发了多少次。实现思路有两条路时间戳记录法和定时器法。两者行为差异不大但细节上各有坑。3.1 时间戳实现立即执行停止后不再补function throttle(func, wait) { let previous 0; return function(...args) { const now Date.now(); if (now - previous wait) { func.apply(this, args); previous now; } }; }原理一句话记录上一次执行的时间戳每次触发时对比当前时间差值大于等于wait才执行。这个实现的特点是**头执行、尾不执行**——第一次触发立即执行停止触发后最后那一次未达标的触发会被丢掉。举例设置wait 1000ms在0ms、200ms、500ms、800ms、1200ms、2000ms触发事件那么执行点分别是0ms、1200ms、2000ms。800ms那次因为离上一次执行才800ms被丢掉。这种实现的优点是简单、可预测缺点是滚动停止那一刻的函数调用不会执行。比如做懒加载时用户快速滚动到页面底部然后停住最后一次到达底部的触发被丢了可能导致底部内容没加载出来必须再滚一下才触发。3.2 定时器实现停止后补一次执行function throttle(func, wait) { let timer null; return function(...args) { if (timer) return; timer setTimeout(() { func.apply(this, args); timer null; }, wait); }; }这个实现的特征是**尾执行、头不执行**——第一次触发要等wait毫秒后才执行停止触发后最后一次触发仍然会进入定时器执行。同一个例子wait 1000ms0ms、200ms、500ms、800ms、1200ms、2000ms触发执行点是1000ms由0ms那次触发创建的定时器、2000ms、3000ms。第一次执行被延后了但停止后有一次补执行。3.3 结合版既立即执行又保证尾部触发实际项目里最常用的是两者的结合第一次触发立即执行停止后如果有残余触发在等待结束时再补一次。这样首尾都不丢function throttle(func, wait) { let previous 0; let timer null; return function(...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); } }; }逻辑拆解remaining计算的是距离下一次可用执行还差多少毫秒。remaining 0说明间隔已满立即执行同时清掉可能挂着的定时器因为等下的定时器已经没必要了。remaining 0且当前没有定时器则启动一个定时器到点后执行并把previous更新为当前时间这样下一次事件触发时remaining是从新的基准开始计算。务实提醒手写结合版虽然在面试中很加分但实际项目中我建议直接用Lodash的_.throttle。这种边界问题定时器冲突、时间基准漂移别人已经踩过坑了没有必要在生产环境里冒险。4. 防抖还是节流看业务场景不看代码这是最常见的选择难题。很多人在面试里能背出定义但遇到真实业务就懵了因为两个函数都能限制频率看起来差不多。我的经验是判断这个操作是在等结束还是等节奏。4.1 场景对照表业务场景推荐方案理由搜索框实时请求接口防抖用户停下打字才是真正想搜索的状态连续打字过程中的每次请求都是浪费按钮提交防止连点防抖立即执行第一次点击就该生效wait时间内重复点击直接忽略窗口resize重算布局防抖窗口拖拽过程中一直在变尺寸希望等拖完停稳后再算一次即可滚动监听加载更多节流滚动是连续的不能用防抖防抖会导致一直滚就一直不加载固定间隔检查一次位置滚动时记录滚动位置节流需要定期保存当前位置防止刷新后丢失等停止再记录会丢失中间态游戏中的射击/技能CD节流固定间隔发子弹不可能等你停止操作才发表单输入校验非请求防抖校验逻辑即使执行也要等输入停顿不给连续输入过程添堵高频DOM拖拽时的位置同步节流希望拖拽过程中持续更新位置低频而不是停止拖动才更新4.2 两个容易选错的经典例子第一个无限滚动加载。用防抖是错的。用户快速往下滚事件连续触发用防抖的话只要滚动一直不停加载函数就永远不执行——直到用户完全停下来才开始加载。如果数据量大或网络慢用户会看到滚到底了但永远在转圈的糟糕体验。正确的做法是节流比如每200ms检查一次是否接近页面底部到了就发请求。第二个登录按钮防连点。这里必须用防抖的立即执行版本而不是普通防抖。普通防抖延迟执行意味着用户第一次点击后要等几百毫秒才真正提交视觉上按钮没反应用户容易再点一次反而触发更多请求。立即执行版则保证第一次点击瞬间就提交后续连续的点击全部被吞掉。4.3 埋一个参数传递的坑在事件监听里绑定防抖/节流函数时经常有人犯这个错误// 错误的监听方式每次渲染都会创建新的防抖函数 input.addEventListener(input, debounce(handleInput, 300));事件监听里直接执行debounce(handleInput, 300)问题在于debounce只调用一次返回的是同一个防抖函数这个写法本身没问题。真正容易出事的是在React的render或Vue的模板里每次渲染重新执行// React里这样写每次render都会生成新的防抖函数防抖完全失效 const handleSearch debounce(fetchData, 300); // 但如果是内联写法 Input onChange{() debounce(fetchData, 300)(e)} / // 每次渲染都新建防抖上下文等于没防抖解决方案是使用useRef或useMemo把防抖函数在整个组件生命周期内只创建一次或者在模块顶层定义为常量。这也是实战中十个人有五个会踩的点。5. 进阶优化取消、取消防抖、缓存结果与动态调整等待时间5.1 给防抖函数加cancel方法业务场景用户在搜索框里输入了内容停止输入300ms后发请求这个任务已经排进定时器了但用户在等待期间点了一下重置按钮此时应该把待执行的定时器取消避免发出上一次的无效请求。function debounce(func, wait) { let timer null; const debounced function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { timer null; func.apply(this, args); }, wait); }; debounced.cancel function() { if (timer) clearTimeout(timer); timer null; }; return debounced; }注意cancel里也要把timer置空否则如果直接clearTimeout而不置null回调函数要从timer里判断状态时会出错。另一个常见需求是flush——不等等待时间结束立刻执行并清掉定时器。在写测试用例时特别有用debounced.flush function(...args) { if (timer) { clearTimeout(timer); timer null; func.apply(this, args); } };5.2 等待时间动态调整自适应防抖很多场景下固定wait不够聪明。比如搜索框用户敲字有快有慢快的用户按200ms没问题慢的用户200ms可能连续触发。一个折中方案是动态等待时间如果上一次触发和当前触发间隔很短说明用户正在快速输入可以自动增加等待时间如果间隔较大说明输入节奏慢可以适当减少。function adaptiveDebounce(func, wait, maxWait) { let timer null; let lastCallTime 0; return function(...args) { const now Date.now(); const timeSinceLast now - lastCallTime; const dynamicWait Math.min(maxWait, Math.max(wait, timeSinceLast * 2)); clearTimeout(timer); timer setTimeout(() { func.apply(this, args); }, dynamicWait); lastCallTime now; }; }这个实现不能说严谨但作为思路示例够用。实际项目里我一般还是用maxWait参数来做兜底——即使防抖逻辑下一直有新任务进来也不允许执行被无限推迟maxWait是最大等待时间的底线。5.3 节流的leading和trailing配置Lodash的_.throttle本质上是对_.debounce的封装支持leading和trailing两个选项leading: true是否在首次触发时立即执行。trailing: true是否在等待时间结束后补一次执行。两个都设为true就是前面写的结合版两边都不丢。两个都设为false的节流没有任何意义事件会被吞干净所以lodash源码里强制两个不能同时为false。5.4 一项被很多人忽略的性能优化防抖/节流函数内部的requestAnimationFrame对于DOM相关的防抖/节流性能优化的上限其实不取决于调用频率而取决于浏览器的渲染帧率。现代浏览器每帧约16.7ms你在1帧里触发10次事件也只会有1次渲染效果。与其用setTimeout做节流不如考虑用requestAnimationFramerAF来处理function throttleRaf(func) { let running false; return function(...args) { if (running) return; running true; requestAnimationFrame(() { func.apply(this, args); running false; }); }; }requestAnimationFrame的优点是自动匹配屏幕刷新率过快的设备上不会浪费CPU。页面切到后台时rAF自动暂停减少无谓的计算。和浏览器的渲染管线对齐不会出现改了DOM但渲染还没开始的错位。缺点是无法做到精确的毫秒级控制足够用于resize、scroll、mousemove这类视觉类事件不适合需要固定间隔稳定执行的逻辑如轮询、打点上报。6. 面试与工程中的细节追问this指向、返回值、代码实现对比面试官问防抖节流很少只让你写个基本版就完事。我梳理了几个最高频的追问和应答要点。6.1 防抖函数中this的指向为什么要用apply透传因为返回的函数是独立存在的直接用func()调用函数内部的this会丢失。比如在Vue组件里绑定this.handleClickhandleClick内部用了this.someData防抖返回的新函数执行时this不再是组件实例代码直接崩。必须用闭包捕获外层this再func.apply(this, args)。6.2 防抖和节流在处理异步请求时的差异纯函数层面的区别不大但配合请求结果处理时要额外小心。防抖场景搜索框用户输入abc停止300ms发了请求A返回后渲染结果。但如果用户在请求A还没返回时就清了输入框此时请求A返回的结果不应该再渲染。处理方案有三种在函数里维护一个请求序号每次发起请求前自增回调里比对序号。使用AbortController取消上一次请求。防抖函数里加一个是否已取消的状态位取消时同步更新状态。function debounceWithToken(func, wait) { let timer null; let token 0; return function(...args) { clearTimeout(timer); const currentToken token; timer setTimeout(() { if (currentToken token) { func.apply(this, args); } }, wait); }; }token颈部的判断可以保证如果用户在等待期间又做了一次新操作旧的那一次执行任务就被作废了。这个做法在并发请求场景下比单纯防抖更安全。6.3 手写版本vs Lodash成熟实现什么时候该用哪个我的建议分三档场景选择理由面试/学习原理手写理解闭包、this、计时器的精髓业务代码能引库lodash / underscore经过大量边界测试支持cancel、flush、leading/trailing配置业务代码function片段/老项目手写简易版只要几十行代码不引依赖有一点很容易被误解认为lodash的_.debounce和手写版的核心逻辑完全一样。实际上lodash处理了很多边界情况比如**timer过期但还没执行时再次调用**、带maxWait的防抖等价于节流、错误处理等等。生产环境优先用成熟库不是不自信而是成熟的实现确实更可靠。6.4 常被忽略的同步与异步测试差异给防抖函数写单测时新手常犯的错误是直接用同步断言// 错误示范断言立即失败因为防抖是异步执行的 const fn debounce(() result 1, 300); fn(); expect(result).toBe(1); // 此时result还是undefined正确做法是用jest.useFakeTimers()模拟定时器配合jest.advanceTimersByTime(300)推进时间后断言或者用真实的setTimeoutdone回调等待300ms之后断言。很多看起来怪的bug其实都是测试时序问题。7. 变体和扩展思路合并请求、Promise化防抖、事件流中的管道应用7.1 合并请求防抖在接口层的另一种用法前端页面高频触发的操作除了DOM事件还有一类是相同请求的重复发送。比如用户疯狂点刷新列表按钮或者多个组件同时发起同一个数据接口的请求。这时可以把防抖用在请求层把wait时间内的多次相同请求合并成一次function mergeDebounce(fn, wait) { let timer null; let pendingArgs []; return function(...args) { pendingArgs args; clearTimeout(timer); timer setTimeout(() { fn(...pendingArgs); }, wait); }; }这个思路在做输入联想接口时特别好用——用户敲联想笔记本防抖后最终只发出一次请求但URL参数是最后一次的完整词。换言之前面所有中间状态联联想联想笔的请求都被合并掉了。7.2 Promise化防抖调用方需要知道请求什么时候完成如果防抖函数内部做了异步操作调用方想知道这次操作的结果标准防抖做不到因为返回undefined。解决方案是让防抖函数返回一个Promisefunction debouncePromise(func, wait) { let timer null; let resolveList []; return function(...args) { clearTimeout(timer); const resultPromise new Promise((resolve) { resolveList.push(resolve); }); timer setTimeout(() { const result func.apply(this, args); resolveList.forEach((resolve) resolve(result)); resolveList []; }, wait); return resultPromise; }; }这样调用者可以await debouncedFn(...)但要注意等待期间的多次调用都会被收集在定时器执行后一次性resolve——如果想要最后一次调用才返回Promise前面的调用立即reject/返回空需要更精细的状态机。这个版本适合日志上报、批量操作确认类场景。7.3 配合事件流框架使用如果你项目里用了RxJS这类响应式框架防抖和节流可以直接用操作符替代手写逻辑debounceTime(300)等价于防抖。throttleTime(300)等价于节流。auditTime(300)类似节流的尾部执行。sampleTime(300)固定间隔取样。在处理复杂事件流比如拖拽 键盘 动画的组合时操作符的链式调用的表达力远超手写函数。7.4 防抖/节流在服务端也能用不要以为这是前端专属概念。服务端处理高并发请求时也可以用类似思路做请求合并和频率限制。比如Node.js里多路请求需要批量写数据库用防抖收集短时间内到达的请求合并后一次性写库DB连接数能省一大半。不过服务端场景通常更建议用队列、限流中间件如令牌桶因为防抖天然引入延迟并不适合所有后端接口。防抖和节流工具函数本身也是通用的和语言无关。8. 我在真实项目中踩过的三个防抖/节流的坑以及绕开方法8.1 坑一React事件系统里防抖函数被重复创建这是React函数组件中最常见的错误。看这个代码function SearchBox() { const handleInput debounce((e) { fetchSuggestions(e.target.value); }, 300); return input onInput{handleInput} /; }每次render都会执行debounce(...)生成一个全新的防抖函数绑定到input上旧的监听器被移除。防抖的闭包状态完全丢失——等于每个新函数都是首次调用防抖完全失效。正确做法function SearchBox() { const handleInputRef useRef(); if (!handleInputRef.current) { handleInputRef.current debounce((e) { fetchSuggestions(e.target.value); }, 300); } const handleInput handleInputRef.current; return input onInput{handleInput} /; }或者更简洁地使用useMemo/useCallback并搭配useRef持久化。原理都一样保证整个生命周期内只创建一次防抖函数。8.2 坑二使用节流时把队尾的触发丢失了做无限滚动时我早期用的是3.1节的时间戳版节流结果遇到一个诡异问题快速滚到接近页面底部时触发加载函数的条件判断在remaining 0的分支里但只要滚动够快最后一次接近底部的触发可能因为离上一次执行不足wait毫秒而无法通过判断页面停在底部加载却永远不来。后来改用了3.3的首尾都执行版本问题消失。从此我的经验是凡是跟位置是否到达某个边界有关的节流一律用结合版确保队尾执行不丢。8.3 坑三wait时间设置得太短/太长完全没达到预期设置太短比如1ms防抖退化为普通函数调用节流退化为每次触发都执行没有起到限制效果。设置太长比如1000ms搜索框停止输入一秒后才出结果用户会觉得卡无限滚动可能要滚动停下来才加载按钮点击后等一秒才提交体验很糟。经验值参考不绝对搜索建议300~500ms按钮防连点300~800ms无限滚动200~300msresize重算300~500ms实时保存草稿800~1500ms。具体数值要根据业务体感和实际测量微调不要在线上拍脑袋改先在测试环境设几个档位对比。8.4 一个关于防抖是否真的只是延迟执行的澄清写这篇文章前再明确一点防抖不是把函数执行推后而是在连续触发中只保留最后一次。如果你希望每次触发都能执行只是降低频率那就该用节流。这就是电梯和公交车的本质区别。面试时把这个类比讲清楚会显得理解得透彻。9. 个人实操心得工作中怎么落地我最后再分享一条很实际的建议不要因为防抖节流听起来高级就什么都往上套。有些场景其实不需要它们。比如一个按钮的点击事件里只有一行console.log你给它套个防抖纯属画蛇添足比如一个mousemove事件里只改一个CSS变量浏览器本身optimizes你给它套个节流反而可能让动画不那么流畅。防抖和节流解决的是**高频事件触发了昂贵操作**的组合问题不是所有事件都要降频。如果想要简化写法可以封装一个通用的事件执行管理器账号接收事件类型、回调、策略debounce/throttle内部统一处理注册和清理避免在组件里散落一堆定时器class FrequentCallLimiter { constructor(strategy, fn, wait) { this.limitedFn strategy debounce ? debounce(fn, wait) : throttle(fn, wait); } execute(...args) { return this.limitedFn(...args); } cancel() { if (this.limitedFn.cancel) this.limitedFn.cancel(); } }我自己在工作中还有一个习惯每次写完防抖/节流相关的代码都会在浏览器Performance面板里跑一次真实的用户路径确认高频事件触发期间的函数调用次数确实降下来了。眼见为实总比我觉得应该没问题靠谱得多。这两个工具函数原理简单边界不少用好了能大幅提升前端性能和用户体验。希望这篇能帮你少走几个弯路。
返回列表