
我第一次在力扣上看到2627这道题时第一反应是官方怎么把前端工具函数搬上来了后来仔细读题才发现这题一点都不简单它把闭包、定时器、this绑定、参数透传四个JS高频考点一次性串起来了写起来就几行代码但能把这几个点讲清楚的人并不多。函数防抖Debounce说白了就是给你一个函数fn和毫秒数t返回一个新的函数。这个新函数被调用后不会立刻执行fn而是等t毫秒如果在这段等待时间里又被调用了就把上一个计时器清掉重新计时。这就是防抖最核心的语义也是我在项目里处理搜索输入、窗口resize、按钮重复提交时最常用的工具。这篇文章就围绕这道题把防抖的原理、实现、边界情况和实际项目里的进阶用法一次讲透不管是准备面试还是纯想补一补JS基本功都值得看完。1. 题目解析与防抖的真实应用场景1.1 题目到底在问什么先把题目本身掰开揉碎。官方给了一个函数签名var debounce function(fn, t) { return function(...args) { // 你的实现 }; };入参有两个fn是被包装的原始函数t是延迟的毫秒数。返回的是一个新函数这个新函数的行为是从最后一次被调用开始计算延迟 t 毫秒后执行 fn。如果在等待窗口内再次调用则取消上一次的等待重新开始计时。用一个官方示例来理解输入 fn (...args) console.log(...args) t 50 calls [{t: 50, inputs: [1]}, {t: 75, inputs: [2]}] 输出 [{t: 125, inputs: [2]}]调用序列是这样的第 50ms 调了一次dlog(1)此时启动一个 50ms 的计时器原本应该在 100ms 执行fn(1)。但第 75ms 又调了dlog(2)此刻上一次的计时器被清掉重新挂一个新的 50ms 计时器于是fn(2)实际在第 125ms 才执行。前一个调用被后一个调用“覆盖”了这就是防抖和普通延迟执行最本质的区别只要有新调用进来旧计时器就作废。我刷这道题的时候顺手梳理了一下这类工具函数在力扣里的判定方式不是看内部状态而是通过一个预置的时间轴调用来断言“哪些调用真正触发了执行、在什么时刻触发的”。所以写题时不需要返回值只需要把计时逻辑做正确就行。1.2 为什么官方会把“函数防抖”单独出一道题刚开始我也觉得奇怪一个工具函数而已值当单独出一题但仔细写完之后发现这道题触及的是前端最底层的几个能力第一是闭包。你返回的函数要能够访问并修改外层作用域里的timer变量靠的就是闭包机制数据存在返回函数的词法环境里而不是挂在全局或对象属性上。第二是定时器管理。setTimeout返回一个 idclearTimeout可以取消对应的回调这两个 API 是防抖的全部底层逻辑看似简单但很多人会把 id 搞丢、搞混或者忘记清除。第三是this 绑定。原函数fn在被延迟执行时必须拿到正确的this。这需要用fn.apply(this, args)来显式传递很多新手会直接写fn(...args)结果this在回调执行时丢失甚至会报错。第四是参数透传。fn可能接收任意数量的参数作为包装函数你不能丢掉任何一个所以要用剩余参数...args收集再展开传递。这四点几乎是每个前端岗位面试都会问的东西。力扣把这题放进 JavaScript 专项题库里不是为了让你背答案而是想检验你是不是真的理解这些基础概念而不是只会用 lodash。尤其是热题100里算法题刷得多的人偶尔做做这种工具题反而能发现自己在 JS 语言特性上有不少盲区。1.3 防抖 vs 节流先搞清楚这对“双胞胎”提到防抖几乎一定会带出节流因为两者都是用来控制函数触发频率的但语义差别很大我见过太多面试者在这上面翻车。防抖debounce在连续触发的事件中只有最后一次触发的等待期结束后函数才执行。中间所有调用都会被取消。节流throttle在连续触发的事件中固定间隔时间内只执行一次函数不管你是不是狂点它都按节奏来。用等电梯来类比防抖就像电梯门快关的时候又有人按了开门键门重新打开等人全部进来、不再有人按键之后过几秒才缓缓关上节流则是电梯每隔固定时间停靠一层不管有没有人节奏是固定的。两者的典型应用场景也不同场景推荐方案原因搜索框输入联想防抖用户停顿下来才发请求避免每次击键都请求窗口 resize 后重绘防抖等拉伸动作结束再计算一次按钮防重复提交防抖快速连点时只认最后一次滚动事件监听节流需要在滚动过程中持续更新状态但不能太频繁游戏中的射击/技能释放节流控制技能冷却时间保证一定频率力扣 2627 只考了防抖但实际面试时很可能紧接着会问“节流怎么写”或“防抖和节流的区别”建议把两个版本都手写一遍这样面试官追问时你心里有底。2. 核心思路拆解setTimeout clearTimeout 构建防抖模型2.1 一秒理解防抖的“重置计时”机制学防抖最忌讳直接背代码理解了模型可以不用记代码也能现场推出来。模型就一句话每次调用时先把旧闹钟取消再设定一个新闹钟到点才响铃。展开细说第一次调用时timer是空的所以clearTimeout(timer)什么也不做然后创建一个setTimeout把返回的 id 存入timer。在等待期间第二次调用来了clearTimeout(timer)把上次的 id 对应的计时器取消旧的闹钟失效这时再创建一个新的setTimeout并覆盖timer。如此反复只有最后一次调用创建的计时器能“活”到 t 毫秒之后到点执行fn。这里有一个关键设计计时器 id 必须保存在闭包变量里。如果放在局部作用域内每次调用都重新声明就没法在后续调用中清除之前的计时器了。这也是为什么防抖函数普遍长这样function debounce(fn, t) { let timer; return function(...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), t); }; }核心逻辑三步走清空旧计时器 - 创建新计时器 - 存下新 id。就这么简单但越是简单的东西越要抠细节尤其在第 2.2 节提到的几个边界问题上。2.2 必须处理的三个边界参数透传、this 指向、返回值第一个边界参数透传。fn可能是(...inputs) console.log(inputs)也可能是一个接收三个参数的业务函数。返回的新函数必须原封不动地把参数传给fn。现代 JS 里最干净的方式是return function(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, t); };这里用剩余参数...args收集所有实参保存在返回函数自己的作用域中等到setTimeout回调执行时再展开传给fn参数数量和顺序一点不少。第二个边界this 指向。这是最容易被忽略的点。如果返回的函数是用普通function声明的那么它的this由调用时的上下文决定。比如const obj { name: 组件A, handle: debounce(function() { console.log(this.name); }, 100) }; obj.handle(); // 这里 this 是 obj在setTimeout回调里如果直接写fn(...args)回调执行时的this已经变成undefined严格模式或window非严格模式this.name自然就取不到了。所以需要用fn.apply(this, args)把延迟执行那一刻的this保存并传回给fn。我在刷题时就踩过这个坑第一次用箭头函数实现返回函数结果this被箭头函数从定义位置捕获调用方怎么改都改不回来。后来规范写法是外层用普通function内部定时器回调用箭头函数为了让this穿透到外层返回函数上。第三个边界返回值。这题不要求返回fn的结果因为防抖本身是异步行为debounce(fn, t)返回的函数被调用后fn还没执行返回值只能是undefined。如果面试官问“防抖函数需不需要把 fn 的返回值返回给调用方”你可以明确回答标准的防抖设计不返回因为它是“最后一次执行”调用方无法同步拿到结果。实际项目里如果需要结果通常会改用回调函数或者 Promise 通知。2.3 关于“立即执行版”防抖的讨论标准防抖是延迟执行也就是最后一次调用后等 t 毫秒才执行。但有些业务场景希望第一次点击时立刻执行一次后续快速连点不再触发直到停顿后恢复这就是 leading 版本防抖或者叫 immediate 版本。力扣 2627 明确不要求做这个扩展面试手写时也建议先答标准版再补充“如果需要立即执行可以怎么改”。实现思路是利用时间戳判断function debounceImmediate(fn, t) { let timer null; let lastCallTime 0; return function(...args) { const now Date.now(); const needInvoke now - lastCallTime t || !timer; if (needInvoke) { fn.apply(this, args); } lastCallTime now; clearTimeout(timer); timer setTimeout(() { timer null; }, t); }; }这里的逻辑是如果距离上次调用已经超过 t 毫秒说明休息时间足够长允许立即执行否则说明仍在快速连点期间跳过本次执行并且重置静默计时器。理解了标准防抖之后这个 variant 只需要二十分钟就能自己推出来。刷题时先把标准版吃透扩展版本看着自然就能写了。3. 完整题解实现与逐行讲解3.1 标准答案代码与注释下面是力扣 2627 的标准实现我加上了逐行注释直接跑就能通过/** * param {Function} fn 需要防抖的原函数 * param {number} t 延迟毫秒数 * return {Function} 防抖后的新函数 */ var debounce function(fn, t) { // 1. 在闭包中保存计时器 id这是实现防抖的核心缓存 let timer null; // 2. 返回一个新函数普通 function 而非箭头函数为了正确接收 this return function(...args) { // 3. 如果存在上一次未完成的计时器先取消它 // 这是“重新计时”的关键让旧调用彻底作废 clearTimeout(timer); // 4. 创建新的计时器延迟 t 毫秒后执行 fn timer setTimeout(() { // 5. apply 传递 this 和参数保证 fn 在正确的上下文中运行 fn.apply(this, args); }, t); }; };第 1 步变量声明时我写let timer null不写var也是一个意思但let更符合现代编码规范。第 4 步的回调用了箭头函数目的是让定时器回调里的this沿用外层返回函数调用时的上下文这样后面fn.apply(this, args)才能拿到正确的调用者。如果你在箭头函数里又把fn换成直接fn(...args)this就是 undefined实际项目里经常会触发“Cannot read properties of undefined”这种让人摸不着头脑的报错。这段代码在 LeetCode 里填进答题区提交就能通过。注意不用处理fn的返回值也不用考虑定时器是否已经被清空后再置 null题目的判定只看执行结果。3.2 手动验证一组可运行的测试用例提交前我习惯在本地把逻辑跑一遍确认行为符合防抖语义。你可以复制下面代码到浏览器控制台、Node.js 或任意在线编辑器中执行const startTime performance.now(); function log(...msgs) { console.log([${Math.round(performance.now() - startTime)}ms], ...msgs); } const dlog debounce(log, 100); dlog(A); // A 被记录但会延迟 setTimeout(() dlog(B), 50); // 新调用挤掉 A setTimeout(() dlog(C), 100); // 再挤掉 B setTimeout(() dlog(D), 150); // 最后一击D 会真正执行预期输出[250ms] D原因0ms 时调用 A - 计划 100ms 执行50ms 时调用 B - 清除 A计划 150ms 执行100ms 时调用 C - 清除 B计划 200ms 执行150ms 时调用 D - 清除 C计划 250ms 执行。最终只有 D 执行且时间点在 250ms。这个测试反映了一个容易被忽略的事实前三次调用都没能执行原函数它们只是各自启动了一个注定被取消的计时器。所以防抖不是把三次调用合并成一次而是“前三次都白调”只有最后一次真正生效。这也意味着高频触发场景下防抖天然会丢弃一部分请求这正是我们想要的效果。3.3 lodash.debounce 与本题的对比看到这题很多用过 lodash 的人会想到_.debounce(fn, wait, options)两者是一个东西吗核心机制一样但 lodash 的版本功能更完整能力力扣 2627lodash.debounce延迟执行trailing支持支持立即执行leading不支持可选配置最大等待时间maxWait不支持可选配置取消防抖cancel不支持支持立即触发flush不支持支持返回值无返回定时结果实际项目里lodash.debounce最常用到的配置是leading和maxWait。leading: true用于“第一次点击立刻响应但防止后续连点”maxWait则用来处理“用户一直不松手也不能无限等待”的场景比如连续滚动页面时滚动事件一直触发标准的 trailing 防抖会让回调永远不执行加上maxWait: 200就能保证每 200ms 至少执行一次。但在面试或者刷题语境下手写一个基础防抖就够了因为它考察的是语言底层能力而不是 API 熟练度。如果你能在此基础上再主动说出 cancel 怎么实现、leading 怎么实现面试官对你的评价会明显上一个台阶。所以我的建议是先把力扣 2627 的基础版闭着眼睛默写出来再自己扩展一两个版本这就比背 lodash 源码有意义得多。4. 常见问题排查与进阶延伸4.1 最容易踩的三个坑第一坑忘记 clearTimeout。很多人第一次写防抖时只写了setTimeout结果调用几次就执行几次完全没起到防抖作用。要记住防抖的灵魂不是“延迟”而是“重新计时”而重新计时的前提就是先把旧计时器清掉。少一行clearTimeout(timer)结果就差十万八千里。第二坑this 绑定丢失。这个在 2.2 里提过但值得单独再强调一次。fn.apply(this, args)写成fn(...args)或者在返回函数里用了箭头函数都会导致回调执行时this错乱。我在项目中就遇到过一个基于类的组件里用防抖包装了this.updateData结果运行后报this is undefined排查了半天才发现是箭头函数捕获了错误上下文。第三坑把防抖做成节流。有些人对“延迟执行”理解不到位以为防抖是“每隔 t 毫秒执行一次”这就成了节流。区分方式很简单防抖看的是不触发之后的一段时间节流看的是触发过程中的固定节奏。写题时如果输出结果和预期时间轴对不上优先检查是不是把setTimeout写成了setInterval或者忘了重新计时。第四坑没有清理定时器导致内存泄漏。在组件卸载、页面关闭时如果定时器还没执行完回调会照样被触发。实际项目中函数执行完后应该把timer置为 null并且提供 cancel 方法在清理时机调用否则就是一个隐藏的内存泄漏点。力扣题不需要考虑这个但写出 cancel 会是明显的加分项。4.2 从一道题到实际项目封装一个带 cancel 的防抖力扣 2627 的返回值只有一个函数但生产环境里的防抖通常需要支持取消。比如 React 组件搜索框useEffect(() { const debouncedSearch debounceWithCancel((keyword) { fetchSearchResult(keyword); }, 300); debouncedSearch(inputValue); return () { debouncedSearch.cancel(); // 组件卸载时取消尚未执行的定时器 }; }, [inputValue]);如果这里没有 cancel组件卸载后定时器回调触发setState就会出现经典的“对已卸载组件执行状态更新”warning严重的会内存泄漏。带 cancel 的完整实现function debounceWithCancel(fn, t 0) { let timer null; function debounced(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; // 执行完毕释放引用 }, t); } // 取消方法清掉计时器并释放 id debounced.cancel function() { clearTimeout(timer); timer null; }; return debounced; }在 Vue 里也类似beforeUnmount钩子中调用cancel。这是我在实际项目里最常用的版本search 防抖、表单校验防抖、按钮提交防抖都能直接复用。刷完力扣 2627 之后建议第一时间自己封装一个带 cancel 的版本这才是从“会做题”到“会写代码”的分界线。4.3 这类题在力扣里的地位与刷题建议力扣的 JavaScript 专项题单里2627 身边全是类似的“小而美”工具函数题比如 2620 计数器、2621 睡眠函数、2623 记忆化函数、2637 有时间的 Promise 限制。它们放在一起构成了一个比较完整的前端 JS 基座闭包、异步、作用域、原型链、柯里化都有覆盖很适合在刷热题100的间隙穿插着做。我以前刷题喜欢盯着算法题和数据结构总觉得考工具函数没意思但真正面试之后才发现这些基础题反而是最容易暴露问题的地方。算法题你不会就是不会大家一眼能看出来工具函数题你觉得自己会但一深问 this、闭包、取消逻辑含糊的人特别多。把 2627 吃透同时把节流、leading 版本、cancel 版本、maxWait 逻辑都练习一遍面试时再遇到同类问题基本就不会慌。个人经验是刷这类题不要开着编辑器自动补全尽量在空白文件里纯手写。写完之后把定时器相关的执行顺序讲给旁边的人听或者自己在注释里解释一遍。能讲清楚“为什么这一步要先 clearTimeout 再 setTimeout”才算真的掌握了防抖而不是背下了这段代码。另外力扣这类题的官方判定方式有时候比较严格比如它只看最后哪些调用实际执行了对中间过程的 setTimeout id 不关心所以你不用刻意清理最后的 id保持闭包内变量正确即可。真正要留意的是返回函数的用途它是被多次调用的每次调用之间必须共享同一个 timer而不是重新创建。这个共享关系靠的就是闭包理解这一点防抖就通了。