开头
如果让我用一个真实场景来解释什么叫防抖,我会说:你在搜索框里打“防抖”两个字,每敲一个字母,前端就向后端发一次请求,六个字母敲完,后端被打了一轮组合拳。更有意思的是,“防抖”这两个字你其实是边思考边打出来的,中间可能停顿了三次,每一次停顿前的请求都带着不完整的搜索词,全部浪费了。这就是我最初被逼着手写防抖的背景。
这篇文章聊的是“js手写防抖”这件事,但它绝对不只是背一段代码那么简单。防抖的核心是“控制执行频率”,它解决的问题不是“函数运行太慢”,而是“调用太频繁”。我会从为什么需要防抖讲起,手写一版最小可用的防抖,再逐步加上 immediate、cancel、maxWait 这些进阶能力,顺手把和节流的区别、在 React 和 Vue 里的实际用法也聊透。无论你是准备面试、刚入职接手老项目,还是做了两年前端想开始抠底层逻辑,这篇文章都值得看完。
1. 先搞清楚你写的到底是什么:防抖解决的问题不是“慢”而是“频繁”
1.1 一个让我被迫去写防抖的实际场景
我曾经维护过一个后台管理系统的搜索模块。那个页面顶上有个搜索框,用户每输入一个字符,前端就会把输入框的值作为查询条件发到后端。逻辑本身很简单,但上线之后测试反馈“系统卡顿、接口报错频繁”。我打开浏览器控制台一看,Network 面板里一排红字,全是 429 或者后端超时。原因很直白:输入“张三”两个字,实际发出了 2 次请求;输入“张三丰”发出 3 次请求。如果用户手速快,1 秒内打了 10 个字符,就是 10 次请求。这还没算上有些用户会来回删了再打,那请求次数直接翻倍。
这个场景让我意识到,前端很多时候不是不知道要防抖,而是不知道防抖到底在防什么。防抖防的是“短时间内的重复触发”,它的目标是把多次触发合并成一次执行。如果用户连续操作了 10 次,防抖只让函数执行 1 次;如果用户操作完停下来了,防抖在停止后的某个时间点执行那最后 1 次。这个过程有点像等电梯:电梯门正要关上,又有人按了按钮,门重新打开再等一会儿,直到没有人再按了,电梯才关门运行。
1.2 防抖的核心定义:先想清楚“等待”和“重置”
网上关于防抖的定义很多,但真正动手写代码之前,需要把两个关键动作理解透:
- 等待(delay):触发事件后,函数不立即执行,而是进入一个倒计时等待期。
- 重置(reset):在等待期内再次触发事件,就把上一个倒计时清掉,重新开始计时。
这两个动作合在一起,产生的效果就是“以最后一次触发为准”。从用户角度感知,就是我们常说的“停下来才执行”。从代码角度感知,其实就是一个“反复重设定时器”的过程。
我第一次手写防抖时,把代码写成这样:
javascript复制function debounce(fn, delay) {
let timer = null
return function () {
timer = setTimeout(fn, delay)
}
}
看起来很像那么回事,但实际用起来全是问题。问题出在哪?我下面会专门展开。这里你先记住一个判断标准:如果一段代码里只看到 setTimeout,没有看到 clearTimeout,那它大概率不是防抖,而是一个“慢速执行”的普通函数。防抖必须有“清除上一次定时器”这一步,这是整个机制的灵魂。
1.3 用“蹲点收快递”来理解防抖的本质
为了让你彻底放下对防抖的畏惧,我再打一个不那么技术的比方。想象你在家等一个快递,快递员打电话说“10 分钟后到”。结果 8 分钟时快递员又打来电话说“不好意思,还得 10 分钟”。于是你把闹钟往后拨了 10 分钟。再过 5 分钟他又打来电话说“还得再等一会儿”,你又把闹钟拨了 10 分钟。直到最后快递员没有再打电话来,闹钟响了,你下楼取件。
在防抖这个机制里,每一次事件触发都像快递员的电话,每一次 setTimeout 都是你拨的闹钟,而 clearTimeout 则是把上一个闹钟取消掉。事件触发得越频繁,闹钟就被重置得越频繁,最终只有在事件停止触发一段时间后,函数才会真正执行。这就是防抖的全部秘密。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一个最小可用版本:闭包、定时器和 this 的拉扯
2.1 从最直观的 setTimeout 思路开始
防抖的最小实现,核心是“返回一个函数”,这个返回的函数内部维护一个定时器。注意“返回一个函数”这个动作本身,就是闭包的典型应用场景。外层 debounce 函数被调用时,内部定义一个 timer 变量,内层返回的函数通过闭包机制访问这个 timer,于是每次调用返回的函数时,都能访问到上一次的定时器 ID,进而实现清除和重置。
我用一个最贴近实战的例子来说明。假设页面上有一个输入框,用户输入完成后需要做联想搜索:
html复制<input type="text" id="searchInput" placeholder="请输入关键词" />
正常写法是:
javascript复制const input = document.getElementById('searchInput')
input.addEventListener('input', function (event) {
search(event.target.value)
})
这样写是“每次输入立即触发搜索”。现在我们把它包一层防抖:
javascript复制function debounce(fn, delay) {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
const input = document.getElementById('searchInput')
input.addEventListener('input', debounce(function (event) {
search(event.target.value)
}), 500)
这里有一个细节值得展开:为什么 setTimeout 里面用箭头函数,并且调用 fn.apply(this, args)?原因在于,原生事件监听器调用回调函数时,会把回调里的 this 指向绑定事件的元素 input。如果我们在返回的函数里直接用 fn(...args),那 fn 内部的 this 就会变成 undefined(严格模式下)或者全局对象(非严格模式下),这显然不是我们想要的。用 fn.apply(this, args) 可以把正确的 this 透传给原函数,这是手写防抖时最容易忽略的一个点。
2.2 第一版代码的致命问题:this 丢失和 event 对象被吞
我见过很多手写防抖的版本,第一版往往都存在两个问题:
第一个问题是 this 丢失。很多初学者这样写:
javascript复制function debounce(fn, delay) {
let timer = null
return function () {
setTimeout(fn, delay)
}
}
这样写,原函数 fn 在执行时,this 不会指向事件的绑定元素,而且 fn 的入参(比如 event 对象)也被吃掉了。在真实业务中,我们经常需要读取 event.target.value、event.keyCode 之类的信息,参数丢失导致功能直接失效。
第二个问题是定时器初始化后没有置空。上面那段代码里,timer 在 setTimeout 执行完后仍然保留着上一次的 ID,虽然不影响功能,但会造成不必要的状态残留。在需要判断“是否正在等待防抖执行”的场景中,这个残留状态会干扰业务逻辑。所以更规范的写法是在定时器回调里把 timer 置为 null。
改进后的版本是:
javascript复制function debounce(fn, delay) {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
这个版本在大部分简单场景下已经可以放心使用了。它做到了三件事:保留 this、透传全部参数、正确清除和重置定时器。
2.3 正确的最小版本:加一个立即执行开关让首帧响应更快
在某些场景下,“停下来才执行”并不理想。比如用户点击“提交”按钮,如果要求“点击后立即提交,然后短时间内禁止重复提交”,那单纯等待防抖就会让用户觉得按钮没反应。这时候需要给防抖加一个 immediate 开关,让它支持“第一次触发立即执行,后续连续触发全部丢弃,直到停止触发一段时间后,下一次触发才再次立即执行”。
下面这个版本加入了 immediate 参数:
javascript复制function debounce(fn, delay, immediate = false) {
let timer = null
let isInvoked = false
return function (...args) {
const callNow = immediate && !isInvoked
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
isInvoked = false
}, delay)
if (callNow) {
isInvoked = true
fn.apply(this, args)
}
}
}
这个版本的执行逻辑是:
- 如果
immediate为true且还没有在本次防抖周期内执行过(isInvoked为false),则立即执行一次。 - 同时设置一个定时器,在
delay时间后把isInvoked重置为false。 - 在这段时间内继续触发只会反复重置定时器,不会再次执行。
用这个版本做“按钮提交防抖”,效果就是首次点击立即生效,后续快速连点被拦截,等用户停止操作一段时间后,再次点击才会生效。这个模式在很多前端组件库里都有应用。
3. 一写就错的三件事:定时器复用、清除时机和返回值处理
3.1 忘记清除上一次定时器:防抖直接退化成节流
我见过最离谱的手写错误,是返回的函数里只 setTimeout,不 clearTimeout。这样每次触发都会新开一个定时器,到时间就执行,根本起不到“合并触发”的作用。更隐蔽的是,如果每次触发还用一个新变量接收定时器 ID,那么之前已经排队的定时器根本不会被清理,最终结果是函数被不规律地多次执行,行为介于防抖和节流之间,非常难排查。
我在排查生产环境问题时遇到过类似情况:页面上有个图表组件,监听窗口 resize 事件做重绘。团队里有人封装了一个 debounce,但忘了 clearTimeout,结果拖动窗口时图表重绘了 20 多次,直接卡死。表面看是防抖没生效,实际是定时器没清干净。
所以在手写时,一定要把这两行背下来:
javascript复制if (timer) clearTimeout(timer)
timer = setTimeout(...)
看见 clearTimeout 出现在 setTimeout 前面,才算合格。
3.2 清除时机搞反导致首帧失效
还有一个常见错误,是把清除放在 setTimeout 回调之后,或者错误地先 clearTimeout 再把 timer 赋新值,都没问题,但容易搞反的其实是另一种情况:
javascript复制return function (...args) {
timer = setTimeout(() => {
fn.apply(this, args)
clearTimeout(timer)
}, delay)
}
这样写的问题在于,clearTimeout 执行时,定时器回调已经执行了,clearTimeout 毫无意义。如果你把 clearTimeout 放在 setTimeout 里且放在 fn 执行之后,那只是在“自欺欺人”。正确的清除时机应该是:在下一次触发时清除上一次的定时器,而不是在回调内部清除自己。
当然,回调执行完把 timer 置为 null 是一个习惯性动作,便于后续判断状态,这个不冲突。
3.3 参数和返回值到底要不要管
每次手写防抖,都会被问到“如果有返回值怎么办”。这个问题要先分情况。
如果是同步函数,防抖后的函数在“延迟执行”时返回什么?答案是 undefined。因为函数被延后了,调用方无法同步拿到结果。如果你非要拿结果,有两个思路:
- 让防抖函数返回一个 Promise,在定时器回调里 resolve。
- 先把参数存起来,在定时器回调里把返回值存到某个变量里,但这样调用方依然拿不到,意义不大。
实战中更常见的是异步场景,比如防抖后发送请求,请求结果要渲染到页面上。这种情况防抖函数本身不需要返回请求结果,因为请求会自己回传。但如果接口是需要在函数里 await 的,那就要小心竞态问题。
给一个支持 Promise 的防抖版本的参考思路:
javascript复制function debouncePromise(fn, delay) {
let timer = null
return function (...args) {
return new Promise((resolve, reject) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
Promise.resolve(fn.apply(this, args)).then(resolve).catch(reject)
timer = null
}, delay)
})
}
}
这个版本把每次调用的 Promise 都挂到对应的定时器回调上,但要注意:如果连续触发多次,只有最后一次会真正 resolve,之前 Promise 会一直挂着直到被下一次触发清除。所以它更适合“需要拿到最终结果”的场景,不适合“每次触发都要拿到结果”的场景。
4. 进阶版:immediate、cancel 和返回值处理
4.1 immediate 版本:第一次立即执行,后面等稳定
上面已经给了一个带 immediate 的版本,这里我把它的使用场景再补充透。最常见的使用场景是搜索建议,用户输入完停顿 500 毫秒后展示建议,但如果用户输入第一个字符时希望立刻出下拉框(哪怕只能给一个空态骨架屏),就需要 immediate。
还有一种场景是“防抖 + 节流组合”:既要保证高频操作能定期执行一次,避免永远不执行,又要保证最后一次操作被执行到。这个单独用防抖做不到,需要引入 maxWait 最大等待时间。我之前在滚动加载列表的场景里就遇到过类似需求:用户持续滚动,理论上应该等滚动停止后再加载更多,但有的用户会一直不停滚动,导致永远触发不了加载。这时候必须在“最大等待时间”内强制执行一次,这就是 maxWait 的作用。
先给一个支持 maxWait 的版本:
javascript复制function debounce(fn, delay, maxWait) {
let timer = null
let lastCallTime = 0
let lastInvokeTime = 0
return function (...args) {
const currentTime = Date.now()
if (lastCallTime === 0) {
lastCallTime = currentTime
}
if (currentTime - lastInvokeTime >= maxWait) {
// 超过最大等待时间,强制执行一次
fn.apply(this, args)
lastInvokeTime = currentTime
lastCallTime = currentTime
if (timer) clearTimeout(timer)
timer = null
return
}
lastCallTime = currentTime
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
lastInvokeTime = Date.now()
timer = null
}, delay)
}
}
这个版本的逻辑是:如果在 maxWait 时间内一直有触发,不管定时器怎么重置,到了 maxWait 还是会执行一次,从而保证函数不会“饿死”。注意这只是核心思路,完整版还要处理 leading、trailing 等边界条件,生产级实现远比这复杂,但思路对了就不怕面试问。
4.2 cancel:取消防抖并清理定时器
防抖函数返回的包装函数,经常需要提供一个 cancel 方法来手动取消。什么时候需要手动取消?比如用户在搜索框输入了内容,还没到延迟时间,页面被关闭了;或者组件卸载了,定时器回调里还拿着旧组件的引用去操作 DOM,就会内存泄漏。
实现 cancel 的方法很简单,在返回的函数上挂一个属性:
javascript复制function debounce(fn, delay) {
let timer = null
const debounced = function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
debounced.cancel = function () {
if (timer) clearTimeout(timer)
timer = null
}
return debounced
}
在 React 的 useEffect 里,这个 cancel 方法非常有用。组件卸载时调用 debounced.cancel(),可以避免回调在组件卸载后触发。Vue 的 beforeUnmount 钩子同理。
4.3 flush 和返回值:面试官深挖时的加分项
除了 cancel,很多源码级的实现还提供 flush 方法,用于立即执行当前排队的函数,然后清除定时器。这个能力用在“用户想强制刷新”的场景很合适,比如输入关键字后手动点击搜索按钮,这时可以直接 flush 一次,等于是提前触发防抖队列。
javascript复制debounced.flush = function (...args) {
if (timer) {
clearTimeout(timer)
timer = null
fn.apply(this, args)
}
}
有了 cancel 和 flush,手写防抖的完成度就向 lodash 看齐了。不过我说实话,面试中能在白板手写出 immediate 版本就已经超过绝大多数候选人了,如果还能把 cancel 和 flush 讲明白,属于明显的加分项。
5. 防抖与节流的辩驳:什么时候不能只靠防抖
5.1 防抖和节流的本质差异
防抖和节流经常被放在一起说,但它们解决的问题完全不同。防抖的眼里只有“最后一次”,不管触发多少次,最终只执行最后一次(或者第一次,取决于 immediate)。节流的眼里有一条“时间线”,不管怎么触发,每隔一段时间必须执行一次,不追求最后一次,但保证频率上限。
打个比方:防抖像“憋气后大口喘气”,连续憋着不呼吸,直到憋不住了你才喘一口;节流像“匀速呼吸”,不管外界怎么刺激,始终保持固定的呼吸频率。两者并不冲突,很多时候可以组合使用。
5.2 场景搭配:搜索、滚动、resize、拖拽
不同场景适合不同的方案,我的建议是这样选:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 输入框实时搜索 | 防抖(500ms) | 等待用户输入停稳后再请求,减少无效请求 |
| 按钮点击提交 | 防抖(immediate 版) | 首次点击立即生效,后续连点被拦截 |
| 窗口 resize 重绘 | 防抖 | resize 事件会连续触发几百次,只需最终状态 |
| 滚动加载列表 | 节流或防抖+maxWait | 持续滚动不能饿死,需要保证每段时间执行一次 |
| 拖拽元素定位 | 防抖 | 拖拽结束时获取最终位置即可 |
| 鼠标移动生成图表 | 节流(16ms 或 100ms) | 需要按固定频率更新,不能等停止才更新 |
这里我想多解释一下“滚动加载列表”这个场景。单纯用防抖,用户手指一直按住滚动,事件会一直触发,防抖的定时器会被反复重置,导致 loadMore 永远不执行。这就是“防抖饿死”问题。解决办法有两个:一是用节流,保证每 500ms 最多加载一次,但这样最后一次可能落入节流间隔内不执行,还要额外兜底;二是用防抖加 maxWait,保证即使高频触发,最多每 2 秒强制加载一次,等滚动停止后再加载一次。实际项目中我更喜欢第二种,行为最自然。
5.3 节流的手写实现,顺便补全对比图
既然要对比防抖和节流,那节流的手写肯定不能只说不练。节流有“时间戳版”和“定时器版”两种:
时间戳版的节流,第一次触发立即执行,后续间隔内触发会被忽略,间隔结束后再次触发才会执行:
javascript复制function throttle(fn, interval) {
let lastTime = 0
return function (...args) {
const currentTime = Date.now()
if (currentTime - lastTime >= interval) {
fn.apply(this, args)
lastTime = currentTime
}
}
}
定时器版的节流,第一次触发延迟到 interval 后执行,interval 内的触发会被忽略,但最后一次触发会补执行一次:
javascript复制function throttle(fn, interval) {
let timer = null
return function (...args) {
if (timer) return
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, interval)
}
}
两个版本在真实场景中各有优缺点:时间戳版首帧响应快,但尾帧可能会丢;定时器版首帧要等,但尾帧一定执行。成熟方案(比如 lodash)会把两者结合,提供 leading 和 trailing 参数分别控制是否执行首尾。
5.4 为什么面试总爱让手写防抖和节流
说句题外话,面试官爱考手写防抖,不是因为它难,而是因为它能同时考察好几个基础点:
- 是否会使用闭包保存状态
- 是否理解定时器的清除与重置
- 是否能正确处理
this和参数 - 是否具备在延时任务中规避竞态的思维
这些恰恰是平时写业务代码中最容易忽视的底层能力。所以这篇文章虽然是从“手写防抖”切入,本质上其实是在帮你补一块“JavaScript 异步基础”的短板。
6. 生产环境中的防抖:React、Vue 里的那些坑和正确用法
6.1 React 函数组件中的防抖写法
在 React 函数组件里写防抖,最容易踩的坑是:每次组件渲染都会重新创建一个防抖函数,导致防抖状态丢失。举个例子:
jsx复制const App = () => {
const [query, setQuery] = useState('')
// 错误示范:每次 render 都会创建新的 debouncedFunc
const handleChange = debounce((value) => {
setQuery(value)
}, 500)
return <input onChange={(e) => handleChange(e.target.value)} />
}
这样写有个致命问题:每次输入触发 render,都会重新生成一个全新的 debounce 函数,原来那个函数内部的定时器状态在新函数上根本不生效。换句话说,防抖彻底失效,每个字符输入都会被立即执行。正确做法是用 useMemo 或者 useRef 缓存防抖函数,保证它是内存中的同一个引用。
jsx复制const App = () => {
const [query, setQuery] = useState('')
const handleChange = useMemo(
() =>
debounce((value) => {
setQuery(value)
}, 500),
[]
)
return <input onChange={(e) => handleChange(e.target.value)} />
}
如果你还同时依赖了外部变量,那就要小心 useMemo 的依赖数组。依赖一变,防抖函数会被重新创建,之前排队的执行也会被清掉。实际项目中,我会尽量减少防抖函数对外部变量的依赖,把所有需要的数据都作为参数传进去,这样 useMemo 的依赖数组基本是空的,防抖函数稳定,不容易产生心智负担。
6.2 React 里防抖回调访问最新 state 的问题
这是一个更难察觉的坑。假设你的防抖函数内部需要读取 state,比如在搜索时要带上当前的筛选条件:
jsx复制const App = () => {
const [filter, setFilter] = useState('all')
const [keyword, setKeyword] = useState('')
const handleSearch = useMemo(
() =>
debounce((value) => {
// 这里的 filter 可能是旧值
search(keyword, filter)
}, 500),
[keyword, filter] // 如果依赖 filter,防抖函数会频繁重建
)
}
问题在于,防抖函数通过闭包捕获了 filter 的值。如果 filter 没有出现在依赖数组里,回调读取到的就是上一次 render 时的旧值;如果 filter 出现在依赖数组里,防抖函数会在 filter 变化时被重建,之前的排队也被打掉。怎么解决?
我的做法是用 useRef 来保持最新值:
jsx复制const filterRef = useRef(filter)
filterRef.current = filter
const handleSearch = useMemo(
() =>
debounce((value) => {
search(value, filterRef.current)
}, 500),
[]
)
在每次 render 时更新 ref,防抖函数内部通过 filterRef.current 读取最新值,既保证了防抖函数稳定,又避免了闭包捕获旧值的问题。这个技巧在复杂的搜索组件里非常实用。
useCallback 也可以做类似的事,但它的依赖管理更容易出错。相比之下,useMemo 返回防抖函数,useRef 保存动态值,整个模式清晰可靠,我推荐优先用这个组合。
6.3 Vue 里的防抖处理:watch 与事件绑定
Vue 里防抖的写法也有自己的特点。在 <script setup> 组合式 API 中,直接写一个 debounce 函数,然后在 watch 或用模板事件调用时要注意 this 指向,但更需要注意的是 Vue 的响应式机制。
用防抖监听输入框的话,我推荐直接在 watch 里做:
javascript复制import { watch, ref, onBeforeUnmount } from 'vue'
const keyword = ref('')
function debounce(fn, delay) {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
const onSearch = debounce((val) => {
searchApi(val)
}, 500)
watch(keyword, (val) => {
onSearch(val)
})
onBeforeUnmount(() => {
onSearch.cancel && onSearch.cancel()
})
这里有个细节,如果要在模板上直接绑定防抖函数,最好用 v-debounce 自定义指令。指令里定义 bind 和 unbind 钩子,在 bind 时用 addEventListener 绑定防抖后的函数,在 unbind 时取消。这样比在模板里每次渲染都调用 debounce 更可控,也不会因为响应式触发重新创建函数。
6.4 生产环境的最终建议:直接用一个成熟的 debounce 库
手写防抖作为学习手段,非常值得;但如果真上生产环境,我的建议是直接用 lodash 的 debounce,或者在一个工具库里统一封装自己的 debounce。原因很简单:lodash 经过了大量边界测试,性能、健壮性、配置项丰富度都远超大多数自写实现。它提供的 leading、trailing、maxWait、cancel、flush 这些能力,我发现自己手写的版本要么缺这少那,要么在某几个边界条件上踩坑。
当然,如果你是在一个禁止引入额外依赖的团队里,那自己封装一个短小精悍的防抖函数也足够用了。我的习惯是:把核心防抖、节流函数单独放在 utils/performance.js 里,加好注释和 JSDoc,全项目公用,方便维护。
7. 从手写防抖到工程思维:几个值得反复回看的问题
7.1 常见面试追问:lodash 的 debounce 到底做了哪些事
面试中如果手写完了防抖,面试官大概率会追加几个问题。这里我把频率最高的几类整理出来。
第一个问题是:“lodash 的 debounce 和你的实现有什么区别?”这里面最重要的差异是 leading 和 trailing 两个参数。lodash 允许你分别控制“首次是否执行”和“尾部是否执行”,可以只执行首帧,可以只执行尾帧,也可以两个都执行。而普通的手写版本要么只有尾帧,要么加一个 immediate 开关。要做到同时支持首尾双执行,核心逻辑是用一个 lastInvokeTime 和 timeWaiting 的差来判断当前触发距上次执行的时间是否大于 maxWait,再配合定时器重置。
第二个问题是:“在并发场景下防抖会有问题吗?”答案是会有。防抖本质上是把 N 次触发合并成 N 次之后的 1 次触发,如果每次触发都会产生异步任务(如请求),那么 1 次触发对应的回调里可能也带着异步结果,旧的回调执行结果可能晚于新的回调返回,导致页面展示旧数据。这就需要给防抖回调内部做竞态处理,比如每次调用前递增一个 requestId,只有 requestId 最大的那次回调才允许更新数据。
第三个问题是:“手写防抖有没有可能造成内存泄漏?”有。防抖函数内部持有的定时器如果一直不触发,并且在函数已经不需要使用的时候没有清除,闭包里的引用就不会被回收。所以在组件卸载、页面切换等生命周期结束时,一定要调用 cancel 或者清理定时器。这也是为什么我强烈建议手写时一定要暴露 cancel 方法。
7.2 我对防抖手写的一些实际心得
这篇文章写到这里,核心内容基本结束了。最后再分享几个我实际工作里的习惯。
第一,防抖延时的选择不要拍脑袋。搜索场景用 300ms 到 500ms 比较合适,重活(如复杂图表重绘)可以放宽到 800ms。延时太短的防抖形同虚设,延时太长又会让用户察觉交互有迟滞感。我一般会先设 500ms,然后根据用户反馈和接口耗时微调。
第二,如果团队里有人对防抖不熟,不要急着骂人,先检查代码里有没有 clearTimeout。大多数“防抖失效”的问题,追根溯源都是没有清除上一次定时器。我曾经为一个同事排查过两个小时的问题,最后发现他把 clearTimeout 写成了 clearInterval,所以定时器根本没有被清除。这个经验告诉我:越是基础的函数,越值得亲手写一遍,把每个细节的“为什么”弄清楚。
第三,如果你的项目里用的是 TypeScript,强烈建议把防抖函数泛型化:
typescript复制function debounce<Fn extends (...args: any[]) => void>(
fn: Fn,
delay: number,
immediate = false
): Fn & { cancel: () => void; flush: (...args: Parameters<Fn>) => void } {
// 实现同上
}
这样在页面里用的时候能保留原函数的参数类型检查,调用 debounced.cancel() 或者 debounced.flush(...) 时,IDE 也能正确推导出类型。别小看这个细节,它能让团队协作的代码质量上一个台阶。
防抖看起来只有十几行代码,但它背后牵扯出的是 JavaScript 闭包、异步时序、竞态处理、组件生命周期管理这一整套知识网。如果你认真把上面这些内容消化了,下次遇到“为什么连续点击只发一次请求”“为什么滚动总是卡顿”“为什么防抖函数在 React 里不生效”这类问题,应该都能第一时间定位到根因。这就是手写基础函数的价值:不是为了炫技,是为了在你真正踩坑的时候,脑子里有一张清晰的地图。
