资讯详情

资讯详情

手写防抖函数:从原理到高阶封装的完整指南

老读者都知道前端面试里“手写防抖函数”已经快成必考题了。不管你是面初级、中级还是高级这道题的出现频率高得离谱几乎每个面试官都会在编码环节让你写一遍。更气人的是很多面试者背好了三段式代码结果面试官一追问“为什么用闭包”“this指向会不会丢”“事件对象怎么透传”当场就卡住了。这篇不是让你死记硬背而是把防抖函数从原理到代码、从最简单版本到生产级版本逐层拆开揉碎。全文要么是面试血泪教训要么是实际项目中踩过的坑尽量把这道题背后牵扯到的闭包、定时器、this指向、参数透传、Promise这些知识点一次讲透。看完之后你不光能默写防抖还能把每种写法的取舍讲得明明白白。1. 防抖函数到底在解决什么问题1.1 从一个让人崩溃的场景说起想象一个搜索框用户每敲一个字符前端就发一次请求。用户在键盘上正常打“防抖函数”这四个字可能触发了七八次请求。这些请求里前面六七个都是无用请求它们要么被后一个请求覆盖要么白占服务器资源。更麻烦的是如果接口返回顺序不一致先发的请求后返回还会把后发的正确结果覆盖掉页面出现“搜索内容错乱”的灵异现象。这个问题的根源不是代码逻辑写错了而是事件触发的频率远超实际处理能力。类似的情况还有浏览器窗口的resize事件拖动窗口时一秒钟能触发几十次如果每次都去做重排和重绘页面会卡得像幻灯片。还有按钮点击提交、滚动监听、输入校验都属于高频事件。防抖函数就是解决这类问题的通用方案。它的核心思路很简单事件被触发后不是马上执行而是等待一段延迟时间如果在延迟时间内又被触发就重新计时。只有用户停下来不再触发延迟时间走完才真正执行一次。生活化一点说防抖就像坐电梯关门——只要还有人往电梯口走门就不关等所有人都进电梯了门才慢慢关上。1.2 防抖和节流这两个概念别搞混面试环节经常出现一个配套追问“防抖和节流有什么区别各适合什么场景”这个问题答不清楚前面代码写得再对也会在综合评估上扣分。防抖强调“只执行最后一次”。连续触发的过程中每次触发都重置计时器最后一次触发后等待delay毫秒执行。节流强调“固定间隔执行一次”。不管事件触发多频繁在单位时间内最多执行一次像给水管装了一个限流阀每秒只能流过一定量的水。以滚动监听为例如果希望用户滚动停下来之后再去计算位置用防抖如果希望滚动过程中每秒计算一次保证统计数据的实时性用节流。以搜索框为例输入防抖是标配因为用户击键间隙本身就给了天然的“停止”信号但如果是上报用户行为日志需要持续采集那就得用节流不能等用户停下来再上报。两者在代码实现上也有明显差异。防抖的核心是每次触发都会clearTimeout并重新setTimeout节流的核心是记录上次执行时间只有达到间隔才执行。很多博主喜欢把两者对比如下对比维度防抖debounce节流throttle执行时机最后一次触发后延迟执行固定频率执行一次核心行为重置定时器判断时间间隔适合场景输入搜索、按钮防重复提交滚动统计、拖拽计算、动画帧控制极端情况一直触发就一次都不执行一直触发也会定期执行面试时这个表能说出来就已经超过了绝大多数只会默写代码的候选人。1.3 哪些场景必须用防抖而不是别的方案我统计过自己实际项目里用到防抖的地方高频场景至少有五个搜索框输入自动补全。这个最典型用户输入关键词后停顿几百毫秒再去请求接口既减少请求量也避免搜索联想结果乱跳。按钮提交防重复。支付按钮、保存按钮、登录按钮用户手抖或者网络慢的时候会连点多次。防抖延迟可以设为200到300毫秒如果用户在短时间内连续点击只有最后一次触发生效避免重复提交表单。窗口resize后重新计算布局。resize整个过程会触发几十次回调等缩放结束后再重新计算宽高和位置只需要一次。滚动到底部后分页加载。用户快速滚动时可能瞬间跨越多个分页阈值用防抖合并成一次加载请求防止同时发出多个分页请求导致列表错乱。移动端触摸事件处理。touchmove、touchend这类事件触发频率比桌面端mouse事件还夸张很多绘制类逻辑都需要防抖处理。记住这些场景面试时答“在哪些业务中你会用防抖”直接给具体案例比干巴巴解释概念有说服力得多。2. 手写防抖前的三个前置知识点2.1 为什么非用闭包不可很多初学者写防抖会写出这样的 bug 版本function debounce(fn, delay) { let timer null; return function () { if (timer) clearTimeout(timer); timer setTimeout(fn, delay); }; }这个版本看着好像没问题但面试官让你解释“为什么返回的函数能记住之前的 timer”很多人就说不清了。原因就是闭包。在JavaScript中函数内部可以访问其外部作用域中的变量即使外部函数已经执行完毕内部函数依然保留对外部变量timer的引用。这就是为什么每次调用返回的匿名函数时它都能看到上一次设置的timer从而执行clearTimeout。如果不用闭包把timer定义在返回函数内部会发生什么每次调用都会重新声明一个timer它永远是nullclearTimeout永远不会执行之前设置的定时器不会被清除整个防抖就名存实亡了。调试工具里可以验证这一点在函数外声明一个全局变量timer也能实现防抖但那会污染全局作用域。闭包方案把timer封装在函数内部外部访问不到多个防抖实例之间互不干扰这才是工程上正确的做法。2.2 定时器执行时机容易想错setTimeout的delay参数并不是“delay毫秒后一定执行”而是“delay毫秒后把回调放入任务队列”。如果主线程被同步任务阻塞回调实际执行时间会晚于delay。这也是为什么极端情况下防抖函数会有延迟“超时”的现象。放在防抖里想用户在900ms时触发了最后一次输入delay设为1000ms理论上1900ms时执行。但如果主线程正在做大量同步计算比如渲染一个复杂组件定时器回调就可能被推迟到2000ms甚至更晚。这不是防抖本身写错了而是JavaScript单线程事件循环的固有特性。面试如果想加分可以主动说出这个知识点定时器的等待时间不是绝对精确的防抖的delay只是“至少等待这么久”实际触发时间受主线程任务队列影响。这说明你对JavaScript运行机制有真实理解而不是只会背代码。2.3 this指向和事件对象为什么必须透传新手版本直接调用fn丢失了两个关键信息。第一个是this指向。如果原函数是一个对象的方法希望通过this访问该对象的属性直接调用fn会使this变为undefined或全局对象取决于是否处于严格模式这会导致运行时错误。第二个是event对象。在事件监听回调里event对象是判断用户操作的关键数据比如键盘按键码、鼠标坐标、input的value。如果调用时没有把arguments透传给原函数回调里拿不到event很多逻辑就会崩。所以防抖内部必须这样处理function debounce(fn, delay) { let timer null; return function (...args) { const context this; const later () { timer null; fn.apply(context, args); }; if (timer) clearTimeout(timer); timer setTimeout(later, delay); }; }用fn.apply(context, args)确保this指向当前调用上下文同时把rest参数完整传给原函数。为什么要用apply而不是直接调用因为直接调用fn(args)只能传一个数组参数应用展开后的多个参数需要apply或spread operator。这里用apply是最稳妥的写法兼容性好语义清晰。关于箭头函数版本有些面试者会写成timer setTimeout(() fn.apply(this, args), delay)这样其实也可以因为箭头函数会捕获外层函数的this。但要注意这里的this是debounce返回的函数被调用时的this。两种写法都可以关键是你得明白箭头函数没有自己的this它用的是词法作用域的this。3. 从零开始手写防抖函数3.1 第一版先写出能用的骨架面试官让你手写防抖第一步不需要追求完美先把核心逻辑写对。最基本的骨架是返回一个函数每次调用时清除旧的定时器重新设置一个新的定时器延迟delay毫秒后执行原函数。function debounce(fn, delay) { let timer null; return function () { if (timer) clearTimeout(timer); timer setTimeout(() { fn(); }, delay); }; }这段代码虽然能跑但问题也很明显this指向没处理、参数没透传、返回值永远为undefined。如果面试官只是让你“写个大概”这勉强能过但大多数面试官会追问“还有没有问题”此时你要能自己说出这三个缺陷并主动修复。给自己留个答题节奏先给骨架版本再逐步演进。这比一上来就背完整版更能体现思路也更符合“手写”这个形式的考察目标。优化版本如下function debounce(fn, delay) { let timer null; return function (...args) { const context this; if (timer) clearTimeout(timer); timer setTimeout(() { timer null; fn.apply(context, args); }, delay); }; }这个版本已经能处理多参数和this绑定足以应对大多数面试。注意执行完定时器回调后要timer null原因有两个一是防止定时器ID堆积造成内存泄漏二是为immediate版本做铺垫——只有timer为null才代表“冷却期已结束”。3.2 第二版加immediate支持立即执行防抖还有变体首次触发时立即执行一次后续连续触发只重置定时器。这在按钮点击场景下很有用用户第一次点击就立刻响应后面疯狂连点也不会重复触发。实现思路是保存一个“是否在冷却期”的标志。在我的实现里就用timer是否为null来判断因为冷却期开始时设定定时器冷却期结束时timer被置为nullfunction debounce(fn, delay, immediate false) { let timer null; return function (...args) { const context this; const callNow immediate !timer; if (timer) clearTimeout(timer); timer setTimeout(() { timer null; if (!immediate) { fn.apply(context, args); } }, delay); if (callNow) { fn.apply(context, args); } }; }这段代码的逻辑要仔细品味。第一次触发时timer为nullcallNow为true于是立即执行fn。随后设置定时器在delay毫秒内再次触发因为timer有值callNow为false不会再次立即执行。直到delay走完timer被置null下一次触发才能再次立即执行。这里有个细节容易写错如果immediate为true定时器回调里就不再执行fn它只负责把timer置null真正执行fn的动作发生在调用时机的callNow判断里。很多背代码的人在这个地方容易混淆面试官一眼就能看出来。3.3 第三版加上cancel和flush业务场景中常常需要在防抖等待期间“反悔”。比如用户连续输入时防抖正在等待800ms后发送请求这时点击了重置按钮需要清空输入框并取消即将发出的请求。没有cancel方法这个请求就一定会发出。Lodash的debounce提供了cancel和flush两个方法。cancel用于取消尚未执行的定时器flush是立即执行当前等待中的回调。实现很简单在返回的函数对象上挂方法function debounce(fn, delay, immediate false) { let timer null; const debounced function (...args) { const context this; const callNow immediate !timer; if (timer) clearTimeout(timer); timer setTimeout(() { timer null; if (!immediate) { fn.apply(context, args); } }, delay); if (callNow) { fn.apply(context, args); } }; debounced.cancel function () { if (timer) clearTimeout(timer); timer null; }; debounced.flush function () { if (timer) { clearTimeout(timer); timer null; fn.apply(this, arguments); } }; return debounced; }注意cancel里定时器ID虽然被clearTimeout清理了但timer值还保留着旧ID必须一并置null否则下一次判断“是否冷却期结束”时会出现状态错乱。flush方法要判断timer是否有值有值才执行——没有等待中的调用时flush不应该触发执行。在React等组件化框架的组合式开发里cancel常用于组件卸载的清理工作。比如useEffect的清理函数里调用debounced.cancel()能避免组件已经卸载但回调仍然执行导致的报错。3.4 第四版支持返回值Promise基础版防抖的返回值为undefined。如果原函数需要返回请求结果或计算结果debounce包装后的函数就要支持返回值。最实用的方案是让debounced返回一个Promise用定时器回调去resolvefunction debounce(fn, delay, immediate false) { let timer null; const debounced function (...args) { const context this; return new Promise((resolve, reject) { const callNow immediate !timer; const later () { timer null; if (!immediate) { try { resolve(fn.apply(context, args)); } catch (error) { reject(error); } } else { resolve(); } }; if (timer) clearTimeout(timer); timer setTimeout(later, delay); if (callNow) { try { resolve(fn.apply(context, args)); } catch (error) { reject(error); } } }); }; debounced.cancel function () { if (timer) clearTimeout(timer); timer null; }; return debounced; }这个版本已经接近生产可用的程度。需要注意几个边界情况cancel后尚未resolve的Promise会一直处于pending状态如果需要可以在cancel时reject所有等待中的Promiseimmediate模式下只有第一次触发的Promise能拿到结果等待期间触发的Promise会等到定时器走完resolve一个undefined。完整的防抖函数需要维护一个调用队列复杂性会指数级上升。不过面试写出这个版本已经属于加分操作了。4. 防抖在真实面试中的追问方式与应对4.1 面试官常见的五连问根据我接触过的面试反馈手写防抖之后的追问虽然形式多样但核心基本固定在五个问题上。第一个问题为什么有时需要传入{ leading: true }或者immediatetrue答案是某些业务需要首次触发立即响应例如打开弹窗、页面按钮的首次点击如果等delay毫秒才响应用户会感觉迟钝。第二个问题防抖的定时器为什么要清掉再重新设置因为不清除的话多次触发会叠加多个定时器最终导致原函数被执行多次防抖就失效了。第三个问题缓存函数里this指向谁包装函数返回给外部后调用时机决定this。一般事件监听回调中this是触发事件的DOM元素如果用apply手动绑定就能把这个this传给原函数。第四个问题如果用户一直触发防抖函数会发生什么如果一直是防抖模式原函数可能永远不执行这会让业务出现“卡死”现象所以要配合maxWait或者节流来实现最大等待时间。第五个问题防抖和节流能互相转换吗本质上它们都是对事件触发频率的控制底层都依赖时间或定时器所以可以通过参数组合在同一函数中实现两种模式Lodash里debounce和throttle本身就是共用一个核心实现。这五个问题答下来你对防抖的理解已经足够深入。怕就怕只背了代码面对追问说不出“为什么”。4.2 手写过程中最容易扣分的细节手写题最忌讳的是一上来就写完整版但更扣分的是写完完全不会解释。几个最常见的扣分点timer没有置null。定时器回调执行完毕后timer还保留着旧定时器ID会导致“冷却期是否结束”判断出错。这个细节在基础版不致命但一进入immediate版就一定会出bug。没有透传event对象。用户能背出arguments这个词但在具体代码里经常忘了写...args和fn.apply(context, args)导致回调里event是undefined。this指向问题。直接用fn()而不是fn.apply(context, args)当原函数用到了this就变成运行时错误。这点在非严格模式下表现不明显因为this会变成window但如果原函数检测到this不对直接抛异常demo就崩了。没有处理immediate模式下第一次触发后冷却期内的重复触发。有些实现会在immediatetrue时每次都立即执行这就完全错了。判定的关键在于callNow immediate !timer一旦冷却期内timer有值就不能再次立即执行。4.3 现场写码的答题节奏建议面试写这道题不要闷头狂写然后一句不解释。更好的节奏是三步走。第一步先确认需求。问面试官“需要处理this指向吗需要参数透传吗需不需要立即执行需不需要取消方法”这种提问本身就会给面试官留下严谨的印象。第二步写出基础版本并说清楚每一行的作用。不用追求一版到位先写出上面3.1的基础版然后主动补充“这里还有一个this指向问题我修复一下”再补充“参数也要透传”一步步演进。这个过程比直接默写完整版更能展示编码思维。第三步写完后自己指出边界情况。你可以说“这个版本在immediate模式下首次触发不能返回正确结果实际生产一般会结合Lodash”。能主动暴露问题并给出解决方案比假装完美更有价值。面试官考察的是你解决问题的能力不是考背诵能力。哪怕最终代码不是最优解只要思路清晰、追问能答上这道题就已经过了。5. 实际项目中的防抖踩坑记录5.1 定时器没清除导致的内存泄漏问题某个后台管理系统里一个长列表页面绑定了全局键盘事件监听每次按键都触发防抖后的搜索。当时用了一个封装组件把防抖函数直接写在组件内部组件销毁时忘了调用cancel。表面上看没什么异常但用户来回切换页面时旧组件监听的事件并没有被正常解绑。防抖的定时器还在等待闭包引用了旧组件的状态导致内存泄漏页面切换几十次之后明显卡顿。排查性能问题时用浏览器的内存分析面板才定位到一批“幽灵定时器”堆积在后台。正确做法是组件卸载时必须调用防抖返回的cancel方法。React里useEffect(() { return () { debouncedSearch.cancel(); }; }, []);这个坑非常隐蔽因为防抖的定时器不报错也不会让功能直接失效只能通过内存增长的间接证据才能发现。生产环境中凡是创建了防抖函数的地方都要问自己一句它的生命周期跟谁绑定什么时候销毁5.2 事件对象被“抖掉”的经典坑我在一个输入框校验功能里写了防抖把change事件回调套进去却发现event对象拿不到输入值。当时代码写的是input.addEventListener(input, debounce(handleInput, 300)); function handleInput(event) { console.log(event.target.value); // undefined 或者报错 }排查后发现是debounce实现里只调用了fn()没有把arguments透传。input事件触发时event对象是第一个参数被args收集后如果没通过apply传给原函数原函数自然拿不到。当时排查了很久因为浏览器控制台不会直接报“event is undefined”只是输出奇怪的undefined。这个案例给我一个教训手写工具函数时参数透传是刚需不是锦上添花。防抖的最简版本只能用于演示真实业务必须处理arguments。5.3 组件卸载后回调还在执行React场景下防抖后的接口请求如果发出时组件已经卸载回调里调用setState会触发“Cant perform a React state update on an unmounted component”警告。更麻烦的是如果接口返回后更新了旧的DOM引用可能导致页面出现短暂错乱。解决方式是在防抖回调里增加一个“是否已卸载”的判断标志或者在组件卸载时用cancel阻止尚未发出的回调。但已经发出的请求没法取消只能通过AbortController来中止。进阶方案是防抖函数内部维护请求标识每次调用前把上一次请求abort掉确保只有最后一次请求有机会完成function debounceAsync(fn, delay) { let timer null; let abortController null; return function (...args) { if (timer) clearTimeout(timer); if (abortController) abortController.abort(); const controller new AbortController(); abortController controller; timer setTimeout(() { fn.apply(this, [...args, controller.signal]); }, delay); }; }这个写法在搜索场景下非常有实用价值它从根源上解决了“旧请求返回覆盖新请求结果”的竞态问题。5.4 搜索请求竞态问题竞态问题是我在实战中遇到的最难排查的问题之一。搜索框输入汉字防抖设置为500ms用户先输入“防抖”又输入“防抖函”两次输入都触发了请求。理论上防抖会让第二次输入取消第一次的请求但如果请求一次发出后无法取消防抖只能保证“最后一次输入才发出请求”却无法保证“第一次请求不会比第二次请求晚返回”。网络环境差时先发的请求可能因为链路阻塞而比后发的请求晚回来最终页面展示的是旧请求的结果搜索词和结果不匹配。用户看到搜索结果和输入不一致第一反应是系统有bug。这个问题的解法就是上面示例中的每个新请求都abort掉上一个请求。浏览器原生fetch支持AbortControlleraxios支持signal参数实现成本并不高。生产环境的搜索框防抖必须结合请求取消否则即使防抖本身写得再好竞态问题依然存在。6. 进阶TypeScript版本与更优雅的封装6.1 用TypeScript约束泛型手写防抖的时候如果能直接写出TypeScript版本会让面试官对你的工程能力刮目相看。泛型不仅让代码更安全还能暴露你对类型边界的理解。function debounceA extends any[], R( fn: (...args: A) R, delay: number, immediate false ): ((...args: A) void) { cancel: () void; flush: () void } { let timer: ReturnTypetypeof setTimeout | null null; const debounced (...args: A): void { const context this; if (timer) clearTimeout(timer); if (immediate !timer) { fn.apply(context, args); } timer setTimeout(() { timer null; if (!immediate) { fn.apply(context, args); } }, delay); }; debounced.cancel () { if (timer) clearTimeout(timer); timer null; }; debounced.flush () { if (timer) { clearTimeout(timer); timer null; fn.apply(this, arguments); } }; return debounced; }泛型A约束了原函数的参数类型R用来标注返回值类型。虽然在这个void返回版本里R没直接用上但类型推导能确保传入的fn参数数量与调用端一致。如果发现this类型丢失可以用ThisParameterType工具类型进一步约束不过大多数场景不必要。6.2 从手写防抖延伸出的学习路径防抖只是一个切入点面试官真正想通过它了解的是你的JavaScript基础是否扎实。顺着这条线下面几个专题值得系统学习事件循环机制定时器、微任务、宏任务的执行顺序为什么防抖能控制执行时机。闭包与柯里化闭包的内存管理闭包陷阱以及防抖之外的函数缓存、柯里化实现。this绑定规则默认绑定、隐式绑定、显式绑定、new绑定各自的优先级以及箭头函数与普通函数的this差异。设计模式中的装饰器模式防抖本质上是一个高阶函数对原函数进行包装增强理解装饰器思想后能写出更多类似工具函数。异步并发控制防抖之后还有节流、串行队列、并发限制、Promise并发池这些都是现代前端工程化离不开的能力。把这条路径走完你手写的就不止是防抖而是一整套前端基础能力体系。这也是“每日一面”这类练习真正的价值所在。6.3 面试之外的工程实践建议最后聊点落地经验。平时写业务代码优先使用成熟工具库。比如Lodash的debounce内置了maxWait、leading、trailing、cancel、flush等完整功能处理边界情况比手写版本更健壮。手写防抖的主要场景是面试和学习自己项目里用如果团队没有工具库依赖那么至少抄一个功能完整的版本而不要用那个不能透传参数的基础版。封装自定义hook也是个实践方向。React体系中可以写一个useDebouncefunction useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] useState(value); useEffect(() { const timer setTimeout(() { setDebouncedValue(value); }, delay); return () clearTimeout(timer); }, [value, delay]); return debouncedValue; }这种hook处理的是值的防抖而不是函数防抖应对搜索框输入值的延迟同步非常好用。配合防抖函数一个负责“值层面的防抖”一个负责“调用层面的防抖”两者互相配合搜索类功能的性能问题基本能解决掉一大半。我自己面试时讲过这段经历先说clearTimeout重置定时器再讲闭包保活timer再演示arguments透传最后聊请求取消与竞态处理。面试官全程点头从这道题发散出去聊了半小时JavaScript异步编程。这才是一道手写题真正该有的展开姿势。希望这篇把防抖的前前后后讲透的文章也能帮你把这道经典题彻底拿下。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →