拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

手写函数防抖全解析:从基础到边界完全指南

手写函数防抖全解析:从基础到边界完全指南

先问一个问题:如果现在让你在白板上手写一个函数防抖,你能在五分钟内交出边界完整的代码吗?这不是在为难你,“防抖”几乎是前端面试题里出场率最高的手写题之一,同时也是实际项目里最常用的性能优化手段。它本身不复杂,但很多人写出来的版本要么丢 this、要么忘掉参数、要么清理不干净定时器,真正能写全要点的人其实不多。

这篇文章我想从“题解”的角度把防抖彻底拆开:先讲清楚它到底在解决什么,再一层层把闭包、this、立即执行、取消、最大等待时间这些边界补完,最后聊几个我在框架和排查过程中经常踩的坑。无论你是准备面试的开发者,还是想把手写代码能力补扎实的工程新人,或者只是想知道 lodash.debounce 内部到底做了什么,这篇都能帮上忙。

1. 从需求到实现:防抖究竟在解决什么问题

1.1 防抖的使用场景与核心痛点

先还原一个很常见的交互:搜索框的联想功能。用户按下“函数防抖”这四个字,是分若干次按下键盘的,假设一次完整输入需要 500 毫秒。如果你在 input 的 change 或 keyup 事件上直接绑定请求函数,那每一次按键都会发一个接口请求,最后可能产生四五个请求。更麻烦的是,这些请求是异步返回的,慢请求可能先发出但后返回,快请求后发出却先回来,页面上展示的数据顺序就会错乱。

这就是防抖要解决的核心痛点:高频连续触发的事件,我们只希望它在“暂停处理”之后执行一次。所谓“暂停处理”,通常是等待一定时间间隔内没有再次触发,才去执行真正的回调。这样网络请求数量大幅下降,状态覆盖的竞态问题也就顺带消失了。

除了搜索框,防抖适用的场景还有很多。

  • 窗口 resize:拖动窗口会连续触发 resize 事件,如果每次都执行重排计算,页面会非常卡顿。加一层防抖,等用户停止拖拽后再计算布局,体感最舒适。
  • 滚动事件:页面滚动时监听 scroll 做懒加载、导航栏高亮或回到顶部按钮的显隐,不加防抖的话每一帧都触发一次,主线程会被打满。
  • 按钮连续点击:用户手滑或网络慢时重复点击提交按钮,如果不做处理,后台会收到多条重复数据。防抖可以保证最后一次点击间隔结束才真正提交。

这些场景的共同点是什么?事件的触发频率远超我们实际需要处理的频率,而且我们真正关心的往往是“连续动作结束后的最终状态”,而不是中间过程的每一次变化。

1.2 防抖与节流的边界梳理

很多人会把防抖和节流混为一谈,因为两者都是限制函数执行频率的手段,但它们的取舍和技术细节完全不同。我习惯用两个生活化的类比来区分。

  • 防抖像电梯关门:电梯门准备关闭时,如果又有人进来,门会重新打开,再等一段时间没人进来才真正关门。
  • 节流像地铁发车:不管站台上人来人往,到点就关门出发,保证固定时间间隔内有且仅有一班车。

用技术语言描述:防抖是“连续触发时只执行最后一次”,节流是“连续触发时保证固定间隔内至少执行一次”。区别很关键:在事件持续触发不停止的情况下,防抖回调可能永远不执行,而节流会按节奏执行。

下面这张对比表,面试和工程选型时可以直接参考。

维度函数防抖函数节流
核心策略重置等待计时器,只执行最后一次固定时间间隔内只执行一次
适用场景搜索联想、resize、按钮提交滚动监听、拖拽、战斗连击
是否保证最小执行频率不保证,事件一直触发就一直不执行保证,到点必然执行一次
典型实现方式每次触发 clearTimeout + setTimeout时间戳对比或锁标志位
事件持续触发时回调状态永远处于待执行状态周期性执行

理解了这个本质差异,后面写实现才不会走偏。如果你拿到的面试题是“防抖”,那就老老实实处理 timer 重置;如果题目是“节流”,就不要用同样的思路去写,否则很容易被追问到哑口无言。

2. 手写防抖:从零开始实现的完整思考过程

2.1 最简版本:参数透传与定时器管理

第一步,写一个能应付大多数场景的基础版本。核心思路是:每次调用时清掉上一次的定时器,然后新建一个定时器,定时器到期后才执行真正的函数。

function debounce(fn, delay = 500) { let timer = null; return function (...args) { if (timer) { clearTimeout(timer); } timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; }

为何这样写?因为我们需要一个“外膜”一样的东西把原函数包起来。这个外壳函数每次被调用时,都能通过闭包访问到同一个 timer 变量,所以能做到“清掉上一次的定时器并重新计时”。如果 timer 定义在 debounce 外层的全局,多个实例就会共享同一个 timer,互相干扰;如果定义在外壳函数内部,每次调用都会重新初始化,无法实现“取消上一次”的效果。闭包在这里不是炫技,而是最自然的解法。

同时要注意,setTimeout 的回调中要使用箭头函数。箭头函数没有自己的 this,它会在定义时捕获外层外壳函数的 this。这样 fn.apply(this, args) 里的 this 就是实际调用外壳函数时的上下文。对于事件绑定的场景,这个 this 就是当前 DOM 元素或组件实例,数据才能正确传递。

这个版本已经可以投入工程使用:请求搜索、resize、按钮提交都能覆盖。但在面试官眼里,它只能算及格分,因为还缺 this 的显式保证、立即执行、取消机制等边界能力。

2.2 进阶版本:保证 this 指向与事件对象

很多初学者会把最简版本写成下面这样,这是一个经典错误:

function debounce(fn, delay) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(function () { fn(...args); // this 丢了 }, delay); }; }

问题在于,setTimeout 回调里的普通函数在运行时,this 指向全局对象(浏览器里是 window,严格模式下是 undefined)。此时调用 fn(...args),fn 内部的 this 就变成了 window/undefined。假如原函数是一个对象方法,比如 obj.save(),经过防抖包装后,this 就再也拿不到 obj 了。

正确做法是保留调用时外壳函数的 this,并把它透传给原函数。这也是为什么 2.1 版本里要用箭头函数的关键原因。如果你不想用箭头函数,也可以显式保存上下文:

function debounce(fn, delay = 500) { let timer = null; return function (...args) { const context = this; if (timer) clearTimeout(timer); timer = setTimeout(function () { fn.apply(context, args); timer = null; }, delay); }; }

这个版本和箭头函数版本在功能上完全等价,面试时你写哪种都行,只要讲清楚原理。

关于 event 对象还有一个细节:事件处理函数通常会接收一个 event 参数。当我们用 ...args 接住所有参数,并通过 apply 透传时,event 对象会自动传递过去,不需要额外处理。但如果你把原函数写成没有参数的 function(),那就拿不到 event 了。所以建议外壳函数统一收集参数,再原样转发。

2.3 立即执行版:前缘触发的实现与取舍

基础版防抖有个体验问题:用户第一次点击“提交”按钮时,需要等 delay 时间过后才执行,体感上像是按钮没反应。如果用户只点击一次,请求也会被延迟 500ms 甚至更久,体验反而变差。

这时需要支持“立即执行”模式,也叫前缘触发:第一次调用立即执行,后续快速调用则被抑制,直到一段时间没有再次调用后,下一次调用再重新立即执行。

function debounce(fn, delay = 500, immediate = false) { let timer = null; return function (...args) { const callNow = immediate && !timer; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; if (!immediate) { fn.apply(this, args); } }, delay); if (callNow) { fn.apply(this, args); } }; }

这个实现怎么理解?关键在“timer 是否为 null 代表了是否处于冷却期”。

  • 第一次调用时,timer 为 null,callNow 为 true,立即执行原函数。
  • 随后设置定时器,timer 被填充,后续调用即使清掉了旧定时器又新建新定时器,timer 始终存在,callNow 一直为 false,所以不会执行。
  • 直到最后一次设置的定时器到达 delay 后,把 timer 置 null,冷却结束。下一次调用时 callNow 又变 true,再次立即执行。

这个模式非常适合按钮防连点:第一次点击立刻发请求,后面的连续点击全部被忽略,直到冷却期结束才能再次提交。

不过要注意,immediate 模式也会带来新的边界问题:如果你希望“最后一次连续操作也能执行”,单纯 immediate 模式做不到,因为延迟到期时只会把 timer 置空,不会再补执行。实际工程中,很多需求是“第一次立即执行 + 最后一次也执行”,这就引出了更完善的 maxWait 机制,下一节会详细说。

3. 关于取消与延迟执行的边界细节

3.1 提供 cancel 方法的意义与实现

防抖的内部定时器是依托于“定时任务”存在的。如果用户在定时器运行期间离开了当前页面、关闭了组件、或者取消了某个操作,那么这个定时器仍然会到期并执行回调。回调里如果访问了已经销毁的 DOM 节点或调用了组件的 setState,轻则报错,重则造成内存泄漏或状态错乱。

所以一个成熟的防抖实现需要提供取消能力。通常做法是给返回的外壳函数挂一个 cancel 属性:

function debounce(fn, delay = 500, immediate = false) { let timer = null; const debounced = function (...args) { const callNow = immediate && !timer; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; if (!immediate) { fn.apply(this, args); } }, delay); if (callNow) { fn.apply(this, args); } }; debounced.cancel = function () { if (timer) clearTimeout(timer); timer = null; }; return debounced; }

cancel 方法内部要做两件事:清掉定时器,并把 timer 置回 null。为什么要置 null?如果不置 null,immediate 模式下 cancel 之后的第一次调用会因为 timer 仍存在而认为还在冷却期,从而跳过立即执行。同样,后续重新开始防抖时,旧 timer 的句柄残留也会造成误判。

工程上的典型用法是在 Vue 组件卸载钩子或 React useEffect cleanup 里调用 cancel。不要小看这一步,我在实际 code review 里见过很多“页面关了还在发请求”的线上问题,根源几乎都是防抖定时器没有清理。

3.2 保证最后一次调用一定被执行(maxWait)

回到前面提到的痛点:如果用户持续滚动页面,事件一直连续触发,防抖会让回调一直不执行,页面可能一直不渲染新内容。这是防抖最容易被吐槽的地方。

解决方案是引入一个“最大等待时间” maxWait,含义是:从第一次调用开始,最多等待 maxWait 毫秒,就必须让回调执行一次。这样即使事件持续高频触发,回调也不会被无限拖延。

我们可以给 2.3 版本再叠加一个简化版的 maxWait 实现:

function debounce(fn, delay = 500, maxWait = 0) { let timer = null; let lastInvoke = 0; return function (...args) { const now = Date.now(); if (maxWait > 0 && now - lastInvoke >= maxWait) { if (timer) clearTimeout(timer); timer = null; lastInvoke = now; fn.apply(this, args); return; } if (timer) clearTimeout(timer); timer = setTimeout(() => { lastInvoke = Date.now(); timer = null; fn.apply(this, args); }, delay); }; }

简单解释一下:lastInvoke 记录上一次真实执行的时间点。每次调用时先检查当前时间与上一次执行时间的间隔是否已经达到 maxWait,如果达到了,就立刻执行一次,并且重置计时。否则继续走常规的 delay 重置逻辑。

这个简化版覆盖了“持续滚动时定期执行”的需求,但不是完整实现。生产环境中 lodash.debounce 的 maxWait 逻辑会更严谨,会同时考虑 immediate、trailing 等选项的组合,我建议面试时提到这个思路就行,不必把所有组合都手写全。

3.3 返回值与异步处理经验

防抖后的函数有一个天然问题:原函数的返回值很难直接透传给调用方。原因很简单,调用外壳函数时,真正的原函数要么还没执行(延后模式),要么在定时器回调里异步执行。外壳函数如果直接写 return fn.apply(...),return 的结果几乎总是 undefined。

那我需要返回值怎么办?这里要分情况。

如果只是同步场景,比如防抖一个“计算字符串长度”的函数,返回值对你意义不大,等定时器执行后再读取结果也来得及,但对外壳函数的调用方来说,是拿不到本次执行结果的。

如果涉及异步请求,比如防抖一个返回 Promise 的搜索函数,更合理的做法是让外壳函数返回一个 Promise,并且把真正回调的结果通过 resolve 暴露出来。一个常见的思路是“promise 缓存”:

function debouncePromise(fn, delay = 500) { let timer = null; let pendingResolve = null; return function (...args) { return new Promise((resolve, reject) => { pendingResolve = resolve; if (timer) clearTimeout(timer); timer = setTimeout(() => { Promise.resolve(fn.apply(this, args)).then(resolve, reject); pendingResolve = null; timer = null; }, delay); }); }; }

但这里有个问题:每次调用都会创建新的 Promise,前一个 Promise 可能永远不被 resolve。所以在实际工程里,我更推荐“调用方不要依赖防抖函数的返回值,而是把请求结果存入某个状态容器”的做法,比如 Redux、Vuex,或者一个组件级 ref。这就是为什么在框架里使用防抖时,我们主要关注如何组织状态,而不是如何透传返回值。

4. 高频场景的重建与性能验证

4.1 结合场景:搜索框、窗口 resize、按钮防连点

防抖的逻辑是一套,但不同场景下的 delay 参数和是否 immediate 是有讲究的。我习惯按下面的经验值来配置。

搜索框联想的防抖,一般取 200 到 400 毫秒。太短的话,网络请求仍然不少;太长的话,用户输入停顿后联想结果迟迟不出现,体验变差。我自己常用 300ms,配合请求竞态处理。

const input = document.getElementById('search-input'); const handleInput = debounce((event) => { fetchSearch(event.target.value); }, 300); input.addEventListener('input', handleInput);

窗口 resize 的防抖,一般取 150 到 200 毫秒。resize 触发频率极高,但用户通常希望停止拖拽后再看到最终布局,所以可以选择 trailing 模式。如果使用前面 2.1 的基础版,正好就是等停止后才执行。

按钮防连点使用 immediate 模式,delay 可以根据按钮的类型设置 1000ms 到 3000ms。immediate 让第一次点击立即生效,后续快速重复点击无效。要注意的是,如果默认使用了 2.3 的版本,第一次点击立即提交后,delay 内再次点击不会有反应,delay 结束后才能再次点击。如果需求是“只能点一次,直到页面跳转”,那还是得在业务层用状态锁配合。

下面给一个完整可跑的按钮防连点示例(immediate 模式 + cancel 清理):

const submitBtn = document.getElementById('submit-btn'); const submit = debounce(() => { console.log('提交请求'); }, 1500, true); submitBtn.addEventListener('click', submit);

注意,这个 submit 返回的是 debounced 外壳函数,不是原函数 submit。事件监听器里拿到的是外壳函数对象,cancel 方法就在它上面。

4.2 防抖在 React/Vue 框架中的形态

在 React 里使用防抖,最常见的一个误区是每次渲染都重新创建防抖函数。比如直接在组件顶层写:

const handleSearch = debounce((keyword) => { fetch(keyword); }, 300);

这个 handleSearch 在每次渲染时都是一个新函数,内部闭包捕获的 timer 自然也是新的。第一次调用设置定时器,还没到 300ms,组件因为输入状态更新触发了二次渲染,handleSearch 被重新创建,旧的防抖闭包被销毁,定时器也随之丢失。最终表现是:防抖失效,甚至每次输入都会立即发起多个请求。

正确的姿势是用 useRef 或 useMemo 把防抖函数实例稳定下来:

const handleSearchRef = useRef(debounce((keyword) => { fetch(keyword); }, 300));

或者用 useMemo,并注意依赖数组要稳定:

const debouncedSearch = useMemo( () => debounce((keyword) => { fetch(keyword); }, 300), [] );

然后事件里调用 debouncedSearch(value) 即可。但 useMemo 版本有一个隐患:原函数如果依赖组件的最新 state,闭包会捕获旧值。解决方法是把原函数也存进 ref,让防抖外壳每次调用时都从 ref 里读取最新函数:

const searchFnRef = useRef((keyword) => { fetch(keyword); }); searchFnRef.current = (keyword) => { fetch(keyword, latestState); }; const debouncedSearch = useMemo( () => debounce((keyword) => searchFnRef.current(keyword), 300), [] );

在 Vue 3 的 setup 中,防抖函数可以直接声明在 setup 里,因为 setup 只执行一次,但组合式函数中使用时要注意组件的卸载清理:

const debouncedSearch = debounce((keyword) => { searchApi(keyword); }, 300); onUnmounted(() => { debouncedSearch.cancel?.(); });

这套在 React 和 Vue 里的使用模式,几乎是框架项目中对防抖理解的试金石。能答清楚“为什么不能直接写在 render 里”,往往比手写防抖更能打动面试官。

4.3 手写在一道前端面试题解中的要点

从面试官视角,一道“手写函数防抖”的题解通常会有以下评分阶梯。

  • 第一层:能用 setTimeout 和 clearTimeout 实现“延迟执行”。很多人到这里就停了,其实只是及格。
  • 第二层:正确处理参数透传和 this 指向。能主动写出 fn.apply(this, args) 并解释箭头函数的作用,可以过关。
  • 第三层:考虑 immediate 前缘触发。能在写之前反问面试官“需不需要第一次立即执行”,是加分的表现。
  • 第四层:提供 cancel 方法并正确置空 timer。这体现对工程场景的理解。
  • 第五层:能顺手聊到 maxWait、Promise 返回、防抖与节流的区别、框架中的注意事项。基本就是 offer 级别。

我把这五层做成一个评分对照表,方便自测。

层级考察点常见写法评价
1定时器重置clearTimeout + setTimeout及格
2this 与参数fn.apply(this, args)良好
3前缘触发immediate 参数优秀
4取消能力debounced.cancel加分
5边界场景maxWait、框架集成突出

面试时不要一上来就写最复杂版本。我建议按“基础版 -> trad+lead 版 -> cancel 版 -> 追问扩展”的顺序逐步展开,一边写一边讲思路。这样既展示了代码能力,也展示了工程思考深度。

5. 常见问题与排查技巧实录

5.1 坑点:防抖后函数丢失 this

这个坑太常见了,尤其在做 React 组件事件绑定时。你写了一个类组件方法:

handleClick = () => { this.setState({ submitted: true }); }; onClick={() => debounce(this.handleClick, 500)()}

这段代码表面上看没问题,防抖函数内部也用了 apply 透传 this。问题出在外壳函数是被一个普通箭头函数包了一层,调用时 this 已经正确绑定到了组件实例,所以多数情况下不会出错。但换个写法就踩坑了:

onClick={debounce(this.handleClick, 500)}

此时 React 事件系统会调用这个外壳函数,并在调用时把这个外壳函数当作普通事件处理器。如果原组件方法使用的是 function 关键字定义(而不是箭头函数),那外壳函数内部的 this 是 undefined(严格模式)或 window。即便 debounce 内部做了 apply,this 仍然不是组件实例。

所以正确的做法很明确:要么组件方法本身用箭头函数定义并把 this 绑定到实例,要么在 debounce 包装前后都显式 bind,要么干脆用 useRef 方案建立稳定的原型方法。不要指望防抖一层的 apply 能救回 this 丢失的问题,要确保调用时机上的 this 来源正确。

5.2 坑点:定时器未清理导致的内存泄漏

防抖本质上是创建了定时器,定时器回调持有了原函数和闭包变量。如果这个定时器在组件卸载后仍然存在,它就会让原函数所在的整个作用域链无法被回收,造成内存泄漏。很多线上页面卡顿、路由切换后仍然有日志在打印,就是这类问题的典型表现。

具体到 React 函数组件,推荐的清理方式是把 cancel 放进 useEffect cleanup:

useEffect(() => { return () => { debouncedSearch.cancel?.(); }; }, []);

在 Vue 3 中则是 onUnmounted 钩子里 cancel。在原生事件里,除了调用 cancel,还要记得移除事件监听器:

window.addEventListener('resize', onResize); window.removeEventListener('resize', onResize); onResize.cancel?.();

这里特别强调一下 React 18 的 StrictMode。在开发模式下,useEffect 的 setup 和 cleanup 会被额外执行一次,有些人会发现防抖函数的 cancel 在第一次 cleanup 时就把定时器清掉了,导致后续调用不生效。这个其实很正常,只要你的 reconnect 逻辑合理,生产环境下不会受到影响。排查时不要怀疑是自己代码写错了,先确认是不是 StrictMode 的预期行为。

5.3 面试追问应对:为什么要用闭包保存 timer

手写防抖之后,面试官几乎一定会追问:“为什么 timer 要定义在闭包里?”

答案从三个层面拆。

第一层,变量共享。外壳函数在多次调用之间需要共享同一个 timer。定义在闭包外层(即 debounce 的词法作用域)可以保证所有外壳函数调用读取和修改的都是同一个 timer。如果定义在全局,多个 debounce 实例会互相污染;如果定义在外壳函数内部,则每次调用都会创建新的 timer,无法做到清除上一次。

第二层,变量隔离。闭包使得每个 debounce 调用产生的 timer 只属于当前实例。双实例互不影响,这是函数式工具的基本要求。

第三层,内存语义。闭包中的 timer 随着返回的外壳函数一起存活。只要外壳函数还被引用,定时器就能正常工作。这也是为什么需要在合适的时机调用 cancel,把这条引用链主动切断。

追问还会继续:“连续调用防抖函数时,内存里最多有几个 timer?”答案是最多一个。因为每次调用都会先 clearTimeout,再新建。但这里有个细节:如果只清定时器而不置 null,timer 变量会残留一个已经无效的整数 ID,在 immediate 模式下会造成冷却期误判。所以代码里清完 timer 后顺手置 null,是值得养成的习惯。

还有一个边角知识:浏览器环境中 setTimeout 返回值是一个正整数 ID,Node.js 环境中返回一个 Timeout 对象。这个差异不影响 clearTimeout 的调用方式,但如果你在跨端代码里用 typeof timer 做判断,就得留个心眼。

写在最后的一点体会

手写防抖这件事,难的不是 setTimeout 和 clearTimeout 这两个 API,而是你能不能把一个“简单的延迟执行”完整地工程化。每一层边界补全,背后都对应着一个真实场景:this 丢了对应事件绑定,cancel 对应组件卸载,immediate 对应提交按钮,maxWait 对应滚动监听的假死。我在给团队做代码评审时,最怕看到的就是有人把一套防抖从老项目里复制到新项目,完全不理解 timer 为什么在闭包里、什么时候需要 cancel。真的建议每个写前端的人都亲手推演一遍这个过程,哪怕不背代码,只要把“为什么”想明白了,面试和工程排查都能轻松很多。

最后留一个实战作业:用你自己实现的防抖,给一个搜索框做联想请求,并加上 300ms 防抖、连续输入不请求、停止输入后再请求、组件卸载自动取消。等你把这个作业完整跑通,防抖这道题就真的通了。

返回列表