
力扣2627这道题我在刷题的时候刷到过也在面试中被问过。题目名字叫函数防抖看起来简单代码就几十行但真要把它讲清楚几乎会把JavaScript里最关键的几个概念全串起来闭包、this指向、异步时序、事件循环。这篇博文就从力扣2627这道题本身出发把防抖的题目考点、实现原理、边界情况、工程化落地一次讲透。1. 题目拆解力扣2627 到底在考什么1.1 题目原意还原力扣2627的题干很短请你实现一个函数debounce它接收两个参数一个是原函数fn一个是延迟时间t毫秒返回一个新的函数。这个新函数被调用后如果在t毫秒内又被调用了那么上一次的调用就被作废计时重新开始只有距离最后一次调用超过t毫秒fn才会真正执行一次。题目给的测试场景一般是这样的假设t 50ms分别在0ms、20ms、40ms连续调用返回的新函数那么fn最终只在90ms时执行一次并且收到的参数是最后一次调用时传的参数。这段描述翻来覆去就是一句话连续触发的密集调用只保留最后一次。听起来很简单但力扣把这题单独拎出来不是因为防抖难写而是因为它考的东西太密了——你至少得懂闭包、懂 setTimeout 的机制、懂 this 的传递、懂参数展开。这四个点漏一个代码就是错的或者在某些场景下就是错的。1.2 高频事件带来的真实痛感防抖不是一句空洞的“性能优化”。我见过太多前端项目因为一个搜索框把后端接口打崩的案例。用户每敲一个字符input事件就触发一次如果每次都发一个请求几秒钟内可能就是几十个请求涌出去后端扛不住前端也处理不过来响应。类似的场景还有拖拽窗口时resize事件疯狂触发、滚动页面时scroll事件每秒触发几十次、连续点击按钮时多次提交表单。这些事件本身很微弱但回调里如果放了重计算、重排、重绘、网络请求那每一帧的触发都是在烧性能。防抖想解决的问题就是在人体动作的密集和机器处理的成本之间加一个缓冲带等动作稳定下来再动手干活。这就像电梯关门——只要还有人往电梯门里走电梯就绝不会关直到没人进来的那一刻才开始关门。防抖的核心逻辑就是这个电梯等着关门的场景。1.3 防抖与节流先把这两个兄弟分清很多同学刷题时会把防抖debounce和节流throttle混在一起面试的时候更是一紧张就说不清。其实一句话就能区分防抖是“停下来才执行”节流是“每隔一段时间最多执行一次”。举个例子你在搜索框里输入“防抖”两个字。如果用的是防抖那么必须等你停止输入了比如停手 300ms请求才发出去。如果用的是节流那么输入过程中每 300ms 至少发一次请求不管你有没有停。实际场景中选哪个取决于业务需求搜索联想、表单校验用防抖因为需要用户输入完再处理。滚动监听、窗口缩放、鼠标移动更适合节流因为需要持续给出反馈不能一直等到停。按钮防重复提交既可以用立即执行的防抖也可以用节流关键看“首次点击”要不要立刻执行。面试中这两个经常成对出现所以建议一起准备好。力扣上也有对应的节流题刷完防抖题以后顺手把那道节流题也刷了前后对比着理解效果完全不一样。2. 防抖的核心机制定时器背后的三件事2.1 定时器重置的逻辑闭环防抖的所有秘密都在一个变量上timer。这个变量通过闭包被缓存下来每次调用返回的新函数时都做同样两步操作清掉上一次的timer。重新设置一个timer让它t毫秒后再去执行fn。这就是所谓的“重置计时”。第一次调用timer是空的所以设置一个新定时器第二次调用到来时上一次的定时器还没走完clearTimeout直接把它废掉再开一个新的第三次调用再来继续废掉、继续开。只要调用间隔小于tfn就永远不会执行只有最后一次调用结束之后安静地超过t毫秒定时器弹出来fn才真正跑一次。生活化类比还是那个电梯电梯门要关了有人冲进去门又重新打开计时重新走。人一个个进来门始终关不上没人进来之后电梯才慢慢关门。2.2 为什么必须处理 this 和 arguments力扣的示例代码里fn通过fn(...args)也能跑通大部分简单用例但在真实应用中这是不够的。因为原函数fn在被调用时this的值不能丢。看这个场景const obj { name: app, process() { console.log(this.name); } }; const debouncedProcess debounce(obj.process, 200); debouncedProcess(); // 期望输出 app如果你写出的是fn(...args)这样直接调用那fn内部拿到的this是undefined严格模式或者windowthis.name自然也是undefined。原因在于obj.process被当作参数取出来的时候它和obj的绑定关系已经断了你再把它当一个普通函数调用this当然恢复默认值。正确做法是让防抖返回的新函数里面的this传递给最终的调用var debounce function(fn, t) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, t); }; };这里的关键点有两个第一返回的函数不要用箭头函数因为箭头函数没有自己的this它只会傻乎乎地继承外层作用域那样你拿不到调用方那个动态的this。现阶段用普通函数声明this就是调用它的人。第二setTimeout的回调用了箭头函数。这是为了让回调内部的this继承来自外层普通函数的this然后再通过fn.apply(this, args)把它传给fn。如果这里写成function()那this又会被函数自己绑定成undefined或window整个链又断了。arguments的处理相对简单直接在返回函数上用剩余参数...args收集所有调用参数然后在setTimeout回调里原样传给fn。这里有一个小坑如果在定时器里直接读外层函数的arguments因为setTimeout回调是异步的实际执行时数据可能已经被后面几次调用覆盖了。所以先把args抓成局部变量再使用也是一种更稳妥的写法。2.3 关于返回值很多人没想明白的一点注意上面的标准实现里debounced()这个新函数的返回值默认是undefined。这很容易让人困惑为什么我调用了新函数却拿不到fn的返回值原因在于防抖的核心是“延迟执行”。第一次调用debounced()的时候fn还在t毫秒后的将来等着执行你立刻就想要fn的返回值这在时间顺序上是不可能的。所以力扣原题里其实不要求你处理返回值只要能保证在正确的时间执行fn即可。但真实业务中有两次地方需要在意返回值一是希望“立即执行”的版本immediate第一次触发时就同步调用fn返回值可以同步拿到二是事件回调本身并不关注返回值所以用undefined也无关紧要。如果你需要拿到异步执行后的结果一般要考虑用回调、Promise 或者直接改成节流逻辑而不是跟防抖函数的返回值较劲。3. 手写实现从标准答案到一套能上线的 debounce3.1 先把力扣题的标准解法写出来力扣2627最精简的通过版本我先完整写一遍再逐行拆var debounce function(fn, t) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, t); }; };逐行拆开看let timer null;闭包里的关键状态缓存。每次调用返回的新函数都能拿到这个变量。return function(...args)返回一个新函数剩余参数args收集所有实参。clearTimeout(timer);如果之前的定时器还没执行直接废掉。setTimeout(..., t)开启新定时器t毫秒后执行回调。fn.apply(this, args)确保原函数的this和参数都正确。为什么用apply而不是直接fn(...args)因为apply允许你在调用时显式指定this。在这段代码里setTimeout回调里的this通过箭头函数继承自返回的普通函数而返回的普通函数在被调用时this等于它的调用者。这样一个完整的this传递链就建立了。3.2 进阶版支持立即执行与同步返回值力扣题只需要实现基础版但如果你面试时抛出“能不能让我第一次点击就立即执行”这个问题基础版是不够的。这里给出一个常见进阶实现function debounce(fn, delay, immediate false) { let timer null; let result; let lastThis; let lastArgs; function debounced(...args) { lastThis this; lastArgs args; if (timer) { clearTimeout(timer); } if (immediate) { const callNow !timer; timer setTimeout(() { timer null; }, delay); if (callNow) { result fn.apply(lastThis, lastArgs); } } else { timer setTimeout(() { timer null; result fn.apply(lastThis, lastArgs); }, delay); } return result; } return debounced; }immediate 模式的核心是callNow !timer。首次调用时timer是nullcallNow为true立刻执行fn。紧接着设置一个定时器但这个定时器的作用不再是“延迟执行”而是“加锁”——在delay毫秒内timer始终不为空之后的每次调用都只是把timer清掉重新加锁callNow永远为falsefn不会再次执行。直到锁到期timer被置为null下一次调用才会再次触发立即执行。这个模式非常适合“首次点击必须立刻响应”的场景比如提交按钮用户点了一下立刻发请求同时在 2 秒内无论怎么狂点都不会触发第二次请求。3.3 工程版补上 cancel 和 flush 接口基础版和进阶版在手写面试题里够用了但放到真实项目里还不够。一个完整的可维护debounce函数至少还应该暴露两个方法function debounce(fn, delay, immediate false) { let timer null; let result; let lastThis; let lastArgs; function debounced(...args) { lastThis this; lastArgs args; if (timer) { clearTimeout(timer); } if (immediate) { const callNow !timer; timer setTimeout(() { timer null; }, delay); if (callNow) { result fn.apply(lastThis, lastArgs); } } else { timer setTimeout(() { timer null; result fn.apply(lastThis, lastArgs); }, delay); } return result; } debounced.cancel function() { if (timer) { clearTimeout(timer); } timer null; }; debounced.flush function() { if (timer) { clearTimeout(timer); result fn.apply(lastThis, lastArgs); timer null; } }; return debounced; }cancel用来取消还没执行的定时器。典型场景是组件卸载useEffect里注册了防抖监听卸载时如果不取消定时器到了时间照样会执行回调可能引发“在已卸载组件上调用 setState”的警告甚至内存泄漏。flush用来立刻执行不等定时器走完。典型场景是用户输入到一半时点击了“搜索”按钮这时候需要把输入框里最后的内容立即拿来请求而不是傻等防抖剩余时间。这两个方法其实很多第三方库都内置了比如lodash.debounce就自带.cancel()和.flush()。这也是手写实现和工程实现之间的分水岭基础版本解决“能用”工程版本解决“好用”和“安全”。4. 边界条件与踩坑实录你以为的防抖并不完整4.1 容易被忽略的三种边界场景第一组件或页面销毁时定时器还在跑。这是真实项目里最隐蔽的问题之一。你在mounted里注册了防抖用户还没来得及触发页面就销毁了。如果防抖函数没有cancel能力这个定时器依然会在未来某个时刻执行。一旦回调里访问了已销毁的 Vue 组件实例或 React 组件状态轻则警告重则报错。解决思路所有用过防抖的地方尽量在销毁钩子里调用.cancel()。如果你没写 cancel 接口就在组件销毁前手动clearTimeout把保存定时器的变量清掉。第二immediate 模式下会出现“漏执行”。默认防抖是“最后一次调用生效”用户连续触发 10 次最后只执行 1 次。但如果业务要求“第一次就要执行”并且“后续连续触发不再重复执行”用 immediate 模式是对的。可有一种场景容易踩坑用户在delay时间内点了一次过了delay又点了一次此时timer已经变成null下一次点击会再次立即执行。看起来没问题但如果你预期的是“用户在 2 秒内最多提交一次”那么delay结束后你又放开锁了第 2.1 秒的点击照样会提交。如果需要严格的“间隔节流”防抖换节流可能更合适。第三同一个debounce实例被多个调用方共用。如果组件里创建一个debouncedFn然后同时用在新窗口的resize和某个按钮的click上两次触发共用一个timer会导致互相取消。最典型的表现是你点击按钮但因为窗口刚刚resize过点击事件被“抵消”了。解决办法很简单一个需要防抖的场景对应一个独立的 debounce 实例不要复用。4.2 常见错误实现对照我整理了几个最常见的错误写法每一条都是我在 code review 中看到过、或者在面试者代码里见到过的错误写法问题后果每次调用都重新let timer null闭包没形成防抖完全失效每个调用都会执行setTimeout回调直接写fn(...args)this 丢失原函数内部拿不到正确 this用return fn(...args)想拿返回值把原函数立即执行了防抖失效而且返回值也拿不到箭头函数声明返回函数this 被固定继承外层对象方法调用时 this 错误只clearTimeout不清空 timer 变量timer 状态残留后续判断!timer时得到错误结果前两个错误最致命写出来几乎就是零分。第三个则是个经典误解我在 3.3 里已经解释过为什么拿不到立刻的返回值这里就不重复了。4.3 面试官最爱追问的三个变体如果面试已经进入“你手写一下防抖”环节那面试官多半会在你的基础上追加三连问第一个追问怎么取消防抖答案就是在返回函数上挂一个cancel方法内部做clearTimeout并把timer置空。实际项目中也确实需要所以我会直接背出完整的工程版写法。第二个追问怎么让第一次点击立即执行答案就是用 immediate 参数配合callNow !timer这个判断。这也是我在 3.2 里写的那个版本。第三个追问防抖和节流的区别什么时候用哪个基础回答是“防抖停止后执行节流固定频率执行”然后一定要带具体业务场景。比如“搜索联想用防抖因为不需要每个字符都请求滚动加载更多用节流因为需要滚动过程中定期触发加载按钮提交用防抖加 immediate因为第一次点击必须立刻反应”。能说到按钮这个场景面试官基本就知道你实战过而不是只会背概念。还有一个细节容易被追问setTimeout的延迟时间并不精确实际会有几毫秒到十几毫秒的偏差而且浏览器对嵌套定时器存在最少 4ms 的钳制。如果业务对时间精度要求很高防抖的t只能保证“近似”等待不能当作严格计时器来用。5. 从刷题到落地防抖在各个业务场景的实战5.1 搜索联想请求400ms 的玄学搜索框是防抖最经典的应用地。我通常设置 300 到 400 毫秒。太短了用户稍微停顿一下就会发请求太长了联想反馈明显迟钝用户会以为功能坏了。简单实现const searchInput document.getElementById(search); const debouncedFetch debounce(async (keyword) { const data await fetchSuggestions(keyword); renderSuggestions(data); }, 400); searchInput.addEventListener(input, (e) { debouncedFetch(e.target.value); });这段代码能跑但有一个非常隐蔽的并发问题如果用户先输入“abc”发出请求 A停了一下改成“abcd”发出请求 B。如果请求 A 比请求 B 后返回前端就会用旧数据覆盖新数据表现就是搜索联想内容错乱。解决办法是在请求前对比当前输入值是不是还是最新的或者用请求序号let requestId 0; const debouncedFetch debounce(async (keyword) { const currentId requestId; const data await fetchSuggestions(keyword); if (currentId requestId) { renderSuggestions(data); } }, 400);使用请求序号的思路很简单发新请求的时候旧请求的currentId已经不等于全局的requestId了所以它的结果直接被丢弃。这个方法在很多需要“最后一次结果优先”的场景都适用。5.2 按钮防重复提交immediate 版才是正解很多人第一次接触防抖会直接把提交按钮的click事件交给基础版防抖submitBtn.addEventListener(click, debounce(submitOrder, 2000));这样写有一个问题用户点击按钮后submitOrder并不会立刻执行而是等 2 秒后执行。也就是说用户点了一下页面没有任何反应2 秒后才开始提交。如果用户不知道这个机制他可能会再点一下然后你这两次点击又被合并成一次。最后体验是按钮像“无效”用户气得连点了七八次。正确做法是使用 immediate 版本并且可以配合返回一个“正在提交”的标志让按钮立刻变成加载态submitBtn.addEventListener(click, debounce(submitOrder, 2000, true));这样第一次点击立刻执行submitOrder同时开启一个 2 秒的锁锁内重复点击全被忽略锁到期后用户才能再次提交。如果后端接口很慢锁到期了但请求还没返回可能出现重复提交所以还需要后端做幂等校验这是前端防抖解决不了的最后一道防线。5.3 组件库与 lodash.debounce 的工程化参考日常业务开发里我不建议每次都手写防抖直接用软件包里的成熟方案会更稳。lodash.debounce几乎成了业界事实标准它的能力已经覆盖了我前面写的基础版、immediate 版、cancel、flush 全部功能还额外支持maxWait可以在连续触发中强制每隔一段时间执行一次这在节流与防抖之间提供了一种“鱼和熊掌兼得”的折中方案。maxWait的思路很有启发防抖虽然能保证停止后执行但如果你一直刷它就一直不执行某些场景下用户会感到“卡死”。给它设置一个maxWait比如 3000ms那么无论你怎么刷新3 秒内至少执行一次。这样既保留了防抖的“安静后执行”又保证了“不能无限期拖”。用lodash.debounce的常见写法import debounce from lodash/debounce; const saveToServer debounce(async (data) { await api.save(data); }, 500, { leading: true, trailing: true, maxWait: 2000 });leading: true对应 immediatetrailing: true表示停止后也执行一次maxWait兜底。看到熟悉了吧这就是力扣 2627 基础版的工程化升级形态。在 Vue 组件里我还会用.cancel()处理组件卸载onBeforeUnmount(() { saveToServer.cancel(); });React 里对应的是useEffect的清理函数。养成这个习惯之后基本没再见过“组件卸载后定时器回调还在跑”的报错。补充一个现代框架里的注意点React 18 的 StrictMode 在开发环境下会故意重复挂载组件如果你的防抖实例创建在组件外层它不会受影响如果创建在组件内部且依赖 useEffect 初始化要注意清理逻辑会不会被重复注册。把实例提升到组件外部、或在 effect 里先cancel再创建就能规避。最后说一点我个人的体会力扣 2627 这道题刷过的人很多但真正理解透的人并不多。它表面上是让你写一个防抖函数内里考验的是对闭包、this、异步时序这三块地基的理解。你在刷题、面试、写业务时遇到的场景可能各不相同但只要把“闭包缓存 timer、clearTimeout 重置、apply 保 this、剩余参数传递”这四件事刻进肌肉记忆不管题目怎么变你都能从容应对。这也是我在实际项目中写搜索框、按钮提交、滚动监听都离不开的一套心法。