☰
从零手写函数防抖:闭包、this指向与定时器完全指南
2026/9/30 3:11:56 网站建设 项目流程

函数防抖这道题,你在前端面试里碰到的概率,可能比“闭包是什么”还要高。LeetCode 2627(题名 Debounce)直接把它做成了正式题目:给定一个函数fn和一个毫秒数t,实现一个新函数,新函数在t毫秒内被反复调用时,只让最后一次调用在t毫秒后真正执行。看起来只有几行,却把闭包、this 指向、定时器异步时序全考了一遍。

这篇文章就围绕这个“题解”展开。我会从最朴素的实现开始,一步步补上 this 透传、参数透传、立即执行、取消等能力,再解释为什么这套写法长这样,最后聊几个我在真实项目里踩过的坑。如果你正在准备前端面试,或者只是想把防抖彻底弄明白,照着往下写就行,不用死记模板。


1. 这道题到底在考什么

1.1 题面拆解与隐藏考点

LeetCode 2627 的原题描述很简洁:传入一个函数fn和一个毫秒数t,要求返回一个 debounced(防抖后的)版本。所谓防抖后的版本,就是当这个返回函数在t毫秒内被多次调用时,之前排队的执行都会被取消,只有最后一次调用在t毫秒后执行;如果在等待期间又来了新调用,计时重新开始。

先别急着写代码,把题面拆开看,里面其实藏了四个考点:

  • 闭包:timer变量必须放在返回函数的外部作用域里,让多次调用共享同一个定时器状态。很多人第一步就错在这里,把 timer 写成了全局变量,或者写成了函数内的局部变量。
  • 定时器管理:每次调用都要先clearTimeout掉上一次的定时器,再重新setTimeout。这个“先清后建”的动作就是防抖的核心,也是面试官最关注的逻辑点。
  • this 透传:返回函数被谁调用,原函数里的 this 就应该指向谁。如果直接在定时器回调里写fn(),this 就全丢了。
  • 参数透传:调用返回函数时传进来的参数,要原样传给原函数。用...args收集再透传,这是最干净的做法。

这四个点单独挑出来都不难,但组合在一起,刚好能筛掉那些“背过答案但没有真正理解”的人。面试官出这道题,本质上不是考你会不会写那几行代码,而是考你平时写代码的时候,有没有认真想过函数调用边界上的这些细节。

1.2 防抖到底解决了什么问题

在讲具体实现之前,先回答一个更重要的问题:为什么需要防抖?

真实的前端页面里,有大量高频事件:搜索框每敲一个字符就触发一次输入事件、窗口 resize 时连续触发、滚动时每秒可能触发几十次。如果每次触发都去执行接口查询、复杂计算或者 DOM 操作,性能会迅速劣化。搜索场景尤其典型——用户输入“函数防抖”,中间可能触发十几次查询,但真正有意义的查询只有用户停下来之后的那一次。

防抖的策略就是:不管触发多少次,只认最后一次,并且要求最后一次触发后等待一小段安静时间,才真正执行。这个过程用生活类比来理解很直观:你坐电梯时反复按关门键,如果你一直按,电梯门就一直等,直到你停下来几秒后才关门。电梯不想在你手还没离开按键的时候就强行关上门,防抖函数也是这个意思。

面试里经常有人把防抖和节流搞混,这里先记住一句话:防抖关注的是“最后一次”,节流关注的是“固定频率内最多一次”。后面我会单独用一小节把两者放在一起对比。


2. 从零手写防抖的完整演进

2.1 第一版:先跑通主流程

先写一个能工作的最简版本,只有核心逻辑,不考虑任何边界:

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

每一步都值得说清楚:

let timer = null放在返回函数外面,这是闭包的关键。返回函数每次被调用,都能通过作用域链找到同一个timer,所以才能“检查上一次的定时器是否存在”。如果timer每次调用都重新声明,那防抖就失效了。

每次调用时,先判断timer是否有值。有值说明上次调用的定时器还在等待中,clearTimeout直接把它取消掉。这一步非常重要,如果漏掉,就会同时挂多个定时器,等delay毫秒后所有排队的调用都会执行,那时候就不是防抖,而是纯粹的延迟。

setTimeout的回调里用了箭头函数。箭头函数没有自己的 this,它会从外层作用域捕获 this。这里捕获的是function (...args)这个普通函数的 this,也就是调用返回函数时传入的 this。在回调里用fn.apply(this, args)把 this 和参数完整透传给原始函数。

关于这一点多说一句:如果这里不用箭头函数,而是写成function () { fn.apply(this, args) },那回调里的 this 就不是调用时的 this 了。定时器回调由全局执行,浏览器环境下 this 一般是window,严格模式下是undefined。到时候你写的 Vue 组件方法或者 React 事件处理器内部,this 就全乱了。

2.2 第二版:把 this 和参数安全透传

有经验的面试官会追加一个问题:你刚才为什么用apply,不用fn(...args)?

答案在于 this。设想一个实际场景:

const user = { name: '张三', save() { console.log(this.name); } }; const saveWithDebounce = debounce(user.save, 500); saveWithDebounce();

如果debounce内部把 fn 直接当作普通函数调用,user.save里的 this 就丢失了,打印出来的会是undefined。因为user.save被赋值给了一个独立变量,调用时前面的主体不见了,this 自然不指向user。

fn.apply(this, args)做的事情,是把当前调用返回函数时的 this 完整传给原函数。也就是说,谁调用返回函数,原函数里的 this 就是谁。这符合 JavaScript 的函数调用规则:this 由调用方式决定,与定义位置无关。

关于参数透传,建议直接用 rest 参数:

return function (...args) { // ... };

...args会把调用时传入的所有参数合并成一个数组。相比传统的arguments对象,rest 参数语义更清晰,而且配合apply直接传数组非常顺手。

有一个细节要注意:如果你在debounce的函数签名里写了形参,比如:

function debounce(fn, delay, immediate) { return function (...args) { // ... }; }

那args和debounce自身的参数互不干扰。很多新手在这里容易混,以为...args会收集到fn和delay,其实不会。rest 参数的收集范围只针对它所在的那个函数,也就是说return function (...args)里的args收集的是返回函数收到的参数。

2.3 第三版:支持立即执行与主动取消

基础版已经能回答大多数面试题了,但面试官往往会追问:“如果我想第一次点击就立即执行,后续才等待,怎么做?”这就引入了immediate参数,也常被称为 leading 边缘触发。

function debounce(fn, delay, 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; }

这个版本的逻辑值得仔细看。

const callNow = immediate && !timer是判断“当前是否应该立即执行”。首次调用时,timer还是null,所以callNow为true,马上执行fn。执行完立即往下走,给timer赋了一个定时器 ID。在delay毫秒内再次调用,timer不是空值,callNow就是false,不会立即执行,只会反复重置定时器。

定时器到点后做了什么?把timer置回null,同时如果是非立即执行模式,在回调里执行fn。立即执行模式下的定时器不执行fn,它的唯一作用就是“计时解锁”——等delay毫秒过去后,重新让callNow变为可触发的状态。这也是这段代码里比较绕、容易看错的地方。

cancel方法是我建议所有手写版本都补上的。组件销毁、路由切换时,如果上一个防抖任务还在等待,直接取消,避免回调在一个已经不存在的组件上执行。

这里也回答面试里另一个高频追问:如果用户一直不停触发,防抖的普通版本是不是永远不会执行?答案是:会。没有maxWait限制的话,只要触发间隔小于delay,回调永远不会执行。这个问题后面的进阶章节专门讲。


3. 防抖原理深入拆解

3.1 闭包在防抖里的角色

防抖的实现离不开闭包,这是面试官连带着考的知识点。闭包的定义可以讲得很绕,但落到这道题上,它做了一件事:让返回函数在每次调用之间,还能访问同一个timer变量。

普通函数调用结束后,函数内部的局部变量会被回收。但debounce返回了一个内部函数,这个内部函数引用了外部函数作用域里的timer。JavaScript 引擎在判断回收条件时,发现timer还被返回函数引用着,就不会回收它。于是这个变量就像“挂”在了返回函数身上,每次调用都能看到上一次调用留下的状态。

用一个不太严谨但好记的类比:每次执行debounce(fn, delay),就像创建了一个独立的“调度台”。timer是调度台上的倒计时器,返回函数是调度台对外暴露的话筒。每来一个事件,调度台就把原来的倒计时重置一次。不同的防抖实例之间互不干扰,因为每个实例都是独立的闭包。

这也是为什么不能用全局变量存timer。全局变量只能存一份,两个组件各自调用debounce就会互相覆盖状态,一个组件重置了定时器,另一个组件的任务就被莫名其妙取消了。闭包天然隔离了这种状态。

3.2 定时器与事件循环的时序关系

理解setTimeout和clearTimeout的配合,比记住防抖模板重要得多。

setTimeout(fn, delay)本身并不保证 delay 毫秒后一定执行。它只保证 delay 毫秒后,回调会被放进任务队列。如果这个时候主线程还在执行其他代码,或者前面排了别的任务,实际执行时间会晚于 delay 毫秒。防抖对这种不精确性并不敏感,因为它处理的是“高频事件之间停下来了才执行”的场景,多等几毫秒不影响效果。

防抖的真正关键在于clearTimeout的取消机制。每次调用返回函数,都会把上一个未到期的定时器取消掉,再新建一个。这实际上是在不断把执行时间点向后推。只有事件的间隔超过了 delay,定时器才有机会到点触发。

有人会问:能不能不用定时器,用时间戳判断?

// 这种写法得不到防抖效果 function fakeDebounce(fn, delay) { let lastTime = 0; return function (...args) { const now = Date.now(); if (now - lastTime > delay) { fn.apply(this, args); } lastTime = now; }; }

这段代码确实限制了执行频率,但它不是防抖,更像是节流:固定时间间隔内最多执行一次。防抖要求“重置等待窗口”,本质上是说每一次新事件都会把上一次的执行计划作废。时间戳判断只能记录“上次执行过没”,做不到“取消已经排队的任务”。所以要实现防抖,clearTimeout + setTimeout的组合是绕不开的。

3.3 防抖和节流:面试容易一起考

面试官几乎必然会把防抖和节流放在一起问。两者的实现基础非常像,但思考方式不同。

对比维度防抖 debounce节流 throttle
核心策略频繁触发时,只执行最后一次固定时间间隔内,最多执行一次
触发时点停止触发后的延迟时刻时间轴上的固定时刻
典型场景搜索框联想、表单校验、resize 结束后的重排滚动监听、鼠标移动、游戏中的按键
实现核心clearTimeout + setTimeout时间戳比较 / 定时器锁
对持续触发的态度可能永远不执行(无 maxWait 时)一定会周期性执行

一句话区分:输入框搜索用防抖,因为你只关心用户停下来之后的结果;滚动加载用节流,因为就算用户一直滚,你也得每隔一段距离处理一次。

面试的时候,建议主动补一句:如果有“既要防止高频执行,又不能在用户持续操作时完全不响应”的需求,就要给防抖加上maxWait参数,这个进阶版我在最后一节展开。


4. 手写过程中的典型问题与排查

4.1 最常见的 this 指向丢失

我第一次面试紧张的时候,就写过这种错误版本:

function debounce(fn, delay) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn(...args); // 这里没传 this }, delay); }; }

问题很明显:fn(...args)调用时,this 是 undefined(严格模式)或全局对象。看起来逻辑没毛病,但一旦原函数内部用到this,立即出问题。

排查方法很简单,在浏览器 console 里跑一个测试:

const obj = { name: 'test', say() { console.log(this.name); } }; const debouncedSay = debounce(obj.say, 100); debouncedSay(); // 期望输出 test

如果输出的是undefined,基本可以确定 this 没有透传。修复方式就是把fn(...args)改成fn.apply(this, args),并且确保在箭头函数里写。

4.2 定时器状态残留导致逻辑错乱

还有一种常见问题:定时器回调执行后,没有把timer置为null。

function debounce(fn, delay) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); // 忘记置空 timer }, delay); }; }

在基础版里,这个写法看起来也能工作:每次调用都 clearTimeout,最后回调正常执行。但一旦加上immediate参数,问题就暴露了。callNow判断依赖timer是否为空,如果定时器已经触发但timer还是非空值,后续的“立即执行”就永远失效了。

正确的做法是每次定时器触发后,把timer重置为null,让状态回到“空闲”。这不仅是格式问题,而是状态管理的边界是否正确。

4.3 参数透传不完整

另一个隐蔽的 bug 是参数透传只传了第一个:

return function (arg) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn(arg); // 只透传了第一个参数 }, delay); };

很多教程里的例子都只传一个参数,看的时候不觉得有问题,真到项目里回调需要(event, data)的时候就翻车了。排查方式是在测试用例里传多个参数,比如:

const multiArgs = debounce((a, b, c) => { console.log(a, b, c); }, 100); multiArgs(1, 2, 3); // 期望输出 1 2 3

统一用 rest 参数收集,再通过apply或call展开,就不会漏参。

4.4 真实项目里更隐蔽的卸载调用问题

手写题本身不会考这个,但项目里一定会遇到:组件已经卸载,防抖回调却还在等待执行。

React 场景很典型。假设一个搜索框在onChange里调用防抖后的搜索函数,用户输入一半就点了跳转,组件卸载了。delay毫秒后回调照样会执行,此时如果回调里更新的是组件状态,可能会触发“在已卸载组件上执行更新”之类的警告,或者白屏报错。

解决办法是拿到debounce函数之后,在卸载生命周期里调用.cancel():

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

这也是为什么我前面强调手写时要带上cancel方法。它不只是加分项,是工程上的必需品。


5. 从题解到工程化:还能往哪里扩展

5.1 一个可以直接抄进项目的防抖函数

如果面试通过之后要在项目里落地,可以直接用下面这个带cancel和immediate的完整版:

function debounce(fn, delay, 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; }

坦白说,生产环境我建议优先用lodash/debounce,因为它的边界情况处理得比我手写的完整得多,比如maxWait、leading、trailing的组合逻辑。但手写版本仍然有价值:它帮你理解 lodash 到底做了什么。而且如果你想在面试里证明自己不是只会装包,这个版本已经足够展示基本功。

5.2 React 和 Vue 的使用姿势差异

React 里踩坑最多的是防抖函数实例不稳定。函数组件每次渲染都会重新执行,如果你在组件体内直接调用debounce(fn, delay),每次渲染都会生成一个新的防抖函数。旧实例还没执行完,新实例就把上下文替换了,等于防抖白写。

解决方法是把它挂在稳定的引用上:

function SearchBox({ onSearch }) { const debouncedSearch = useRef(debounce(onSearch, 500)).current; const handleChange = (e) => { debouncedSearch(e.target.value); }; // 组件卸载时主动取消 useEffect(() => { return () => debouncedSearch.cancel(); }, [debouncedSearch]); return <input onChange={handleChange} />; }

useRef创建的引用跨渲染稳定,.current只在首次渲染时赋值,之后渲染不会再生成新的防抖实例。Vue 里没有这个问题,因为 setup 函数只会执行一次,普通地在 setup 里调用debounce就能保持稳定。不过要留意响应式依赖:如果防抖函数里读取了响应式数据,最好通过参数传进去,避免闭包捕获旧值。

5.3 进阶追问:maxWait、leading 与 trailing

最后聊一个面试官喜欢深挖的点:如果用户一直持续操作,普通防抖永远不会执行,怎么办?

这就需要maxWait。思路是:在防抖等待期间,如果距上一次实际执行已经超过了maxWait,就立即强制执行一次,然后重新开始下一轮防抖。简单实现可以这样:

function debounceWithMaxWait(fn, delay, maxWait) { let timer = null; let lastInvoke = 0; const invoke = (args, thisArg) => { lastInvoke = Date.now(); fn.apply(thisArg, args); }; const schedule = (args, thisArg) => { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; invoke(args, thisArg); }, delay); }; return function (...args) { const now = Date.now(); if (now - lastInvoke >= maxWait) { // 超过最大等待时间,立即执行 if (timer) clearTimeout(timer); timer = null; invoke(args, this); } else { // 否则走普通防抖 schedule(args, this); } }; }

这只是最简版本,首次调用会立即执行一次,和 lodash 的leading行为类似。leading和trailing的组合是另一个面试加分话题:leading: true表示第一次触发时先执行一次,trailing: true表示最后一次触发后会再执行一次。两都开启的话,会在等待周期开始和结束时各执行一次。这种需求在“持续拖拽并实时上报位置,结束后再做额外处理”的场景里很常见。


我自己刷这道题的时候,最大的收获不是背会了一个模板,而是理解了timer这个变量是跨调用存活的。后来写 React 组件,每次遇到防抖失效,我都会先检查是不是函数实例被新渲染替换了。十次里至少有八次是这个原因。如果你也正在刷题,看完这篇之后建议合上编辑器自己动手写一遍,从第一版到第三版,每写一版就加一个测试用例。Debug 一轮下来,这道题的思路基本就长在脑子里了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询