1. 为什么"手写防抖"成了面试标配:从一次线上事故说起
先说个我自己经历过的真实场景。某次给一个搜索框做联想建议功能,第一版实现很简单粗暴,监听 input 事件,用户每按一个键就发一次请求。功能上线当天没啥问题,第二天后端同学就找过来了,说这个接口的调用量翻了十几倍,而且大部分请求的参数只差一两个字符——用户其实还在打字,压根没想好搜什么,请求已经打出去了几十发。
我当时第一反应是"加个防抖不就好了",结果一搜现成工具库,lodash.debounce 一行代码搞定。这事确实简单,但真正让我意识到防抖重要性的,是后来有一次在面试中让候选人手写防抖,发现十个里有七个写不出来,或者写出了明显的 bug:this 指向丢了、arguments 没传递、连续触发时计时器没清掉、原函数返回值永远拿不到……
从那时起我就认定,"手写防抖"这道题绝不是在刁难人。它考察的是你对 JavaScript 闭包、this 绑定、定时器机制、函数式编程思维的综合理解,而这些恰恰是日常开发里最容易出错、也最值得吃透的基础功。
这篇文章不讲虚的,直接带你从零开始手写一个能真正用在生产环境的防抖函数。我会从最朴素的版本起步,逐步加上 this 处理、参数透传、立即执行、取消防抖、手动触发等能力,最后再聊一聊防抖和节流的边界,以及面试官大概率会追着问的几个问题。不管你是刚入门的前端新人,还是准备跳槽的老兵,这篇文章都值得花十分钟过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一版实现:一个能跑但"不够格"的防抖
防抖的核心逻辑其实一句话就能说清:事件触发后等待 delay 毫秒再执行回调,如果 delay 时间内又被触发,则重新计时。就像电梯门即将关闭时有人按了按钮,电梯会重新开门等一会儿,而不是立刻关门走人。
2.1 最朴素的写法
先看一个最基础、几乎每个前端人都能写出来的版本:
javascript复制function debounce(fn, delay) {
let timer = null;
return function () {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(fn, delay);
};
}
用起来是这样的:
javascript复制const handleSearch = (keyword) => {
// 发送搜索请求
console.log('搜索:', keyword);
};
const debouncedSearch = debounce(handleSearch, 500);
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
这段代码逻辑上没错:每次触发都会清掉上一次的定时器,然后重新设置一个。如果两次触发间隔小于 delay,第一次的定时器被清除,只有最后一次触发等待 delay 毫秒后执行。这也是防抖的典型行为——只认"最后一次"。
2.2 这个版本有什么问题
如果拿这段代码去面试,我只能说:及格边缘,但过不了关。因为它有三个致命问题:
第一个问题是 this 指向丢失。setTimeout(fn, delay) 中的 fn 在定时器回调里会以普通函数调用方式执行,此时函数内部的 this 指向 window(非严格模式)或 undefined(严格模式)。如果你 debounce 的是一个对象的方法,比如:
javascript复制const obj = {
name: 'my-search-box',
search: function () {
console.log(this.name); // 期望拿到 'my-search-box'
}
};
const debouncedSearch = debounce(obj.search, 500);
debouncedSearch(); // 打印 undefined 或者直接报错
这个 bug 在真实项目中很容易偶发,而且不好排查,因为很多函数里面并不依赖 this,问题就被隐藏了。
第二个问题是 参数没传透。外层 return function () {...} 没有收集参数,也没有传给 fn。前面示例里虽然 debouncedSearch(e.target.value) 传了参数,但 fn 执行时根本拿不到它——定时器里的 fn 调用没有携带任何入参。
第三个问题是 返回值丢失。如果 fn 有返回值,比如翻页接口需要拿到数据条数,或者某个校验函数需要返回布尔值,当前版本直接返回 undefined,完全取不到结果。
这三个问题不解决,代码就是"玩具",不是工具。接下来逐个击破。
3. 面试官真正想看的是这些:this 绑定、参数透传与返回值
如果说基础版本体现了基本逻辑,那么这一节的三个处理就是从"能写出来"到"能干活"的分水岭。具体来说,我们要给 debounce 返回的函数加上三个能力:
- 正确绑定执行时的
this上下文。 - 把调用时传入的参数透传给原函数。
- 让原函数的返回值可以拿到(至少在外层函数上能拿到)。
我直接给出处理后的代码,然后逐行解释:
javascript复制function debounce(fn, delay) {
let timer = null;
return function (...args) {
const context = this;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
3.1 this 为什么要在外层保存,而不是在定时器回调里取
这里有个关键细节:const context = this 这行写在外层函数体内。为什么?
因为 this 的指向是在外层函数被调用时确定的。我们在调用 debouncedSearch() 时,如果它是 input 事件的原生监听回调,那么 this 自动指向 input 元素;如果是对象方法调用,this 指向该对象。此时立即用 const context = this 把这个值"快照"保存下来,之后再传给定时器回调里的 fn.apply(context, args),就能保证原函数拿到的 this 和调用防抖函数时的 this 完全一致。
而如果你把 this 的获取放到定时器回调内部:
javascript复制timer = setTimeout(function () {
fn.apply(this, args); // 这里的 this 是 window/undefined
}, delay);
那就晚了——定时器回调执行时,它自己的 this 已经变成了全局对象(或 undefined),跟外层调用者毫无关系。
3.2 参数透传:用展开运算符收集,用 apply 传递
return function (...args) 里的 ...args 是 ES6 的剩余参数语法,会把所有传入参数收集成一个数组。然后在定时器回调里用 fn.apply(context, args) 展开成多参形式传给原函数。
为什么用 apply 而不是直接写 fn(context, ...args)?这其实是等效的,apply 写法更通用一些,尤其当 args 可能是类数组对象(老代码场景)时也能兼容。不过在现代代码里,我更推荐写成:
javascript复制timer = setTimeout(() => {
fn.call(context, ...args);
}, delay);
语义上更清晰,可读性也更好。两种都可以,你面试时选一种讲清楚就够。
3.3 返回值:这里有个"能力边界"要说清
防抖函数有一个天然的特性:如果回调是延迟执行的,那么外层函数的返回时机已经过去了。你调用 debouncedSearch() 时,它只是设置了一个定时器,立刻 return undefined 返回了。等 delay 毫秒后原函数才真正执行,这时的返回值没人能接住。
所以,"让防抖函数返回原函数返回值"这个需求,在标准的延迟执行场景下是无解的。它只能用"立即执行版"来解决——下一节会详细讲。
4. 带"立即执行"的完整版防抖:leading-edge 与 trailing-edge
很多真实场景不只要求"最后一次触发后延迟执行",还要求第一次触发时立刻执行一次。举个例子:用户点击"提交"按钮,我们希望第一次点击就能立刻提交,防止误触;但如果用户连点十下,后面九下都应该被防抖拦截。又比如滚动加载场景:用户往下滚第一下就触发加载,后续在停顿前不再重复触发。
这种场景需要的是"前缘触发"(leading-edge)+ "后缘触发"(trailing-edge)结合的能力。lodash.underscore 里通过第三个参数 immediate(underscore 风格)或者 options.leading / options.trailing(lodash 风格)来实现。
4.1 最简单的立即执行版:underscore 风格
我们先从理解门槛最低的版本说起,第三个参数直接传布尔值 immediate:
javascript复制function debounce(fn, delay, immediate = false) {
let timer = null;
let result;
return function (...args) {
const context = this;
if (timer) {
clearTimeout(timer);
}
if (immediate) {
// 如果 timer 为空,说明不在冷却期,立即执行
const callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay);
if (callNow) {
result = fn.apply(context, args);
}
} else {
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
}
return result;
};
}
这段代码的巧妙之处在于,它用一个"冷却定时器"的概念替代了"真实执行定时器"。当 immediate: true 时:
- 首次调用,
timer为null,callNow为true,立刻执行fn,设置一个delay毫秒后把timer置空的定时器。 - 在
delay毫秒内再次调用,timer不为null,callNow为false,只清掉旧定时器再重新设置新的冷却定时器,不执行fn。 - 直到最后一次触发后的
delay毫秒结束,timer被置空,此时如果再有新调用,callNow又为true,再次立即执行。
简单说,它在"冷却期"内用重新计时来延后冷却结束时刻,从而保证"最后一次触发之后再等一个 delay,玩家可以再次立即触发"。这正是 UI 交互里"防止连点"最常见的需求。
4.2 对这个版本问自己三个问题
这一版是面试高频答案,但很多人背了代码却不理解,面试官一追问就露馅。我建议你写完后问自己三个问题:
第一个问题:立即执行版里为什么还要返回 result?
因为当前一次触发是"立即执行"的情形时,原函数在调用前已经执行完毕,返回值可以同步返回给调用者。但后续如果处于冷却期,result 保存的还是上一次执行的结果。这是一种"尽力而为"的返回值处理策略——像是缓存了最近一次执行结果。你可以在代码注释里写清楚这一点,面试时能主动说出这个边界,印象分会明显加分。
第二个问题:如果 fn 执行过程中抛了个异常,result 会怎样?
result 不会被赋值,异常会直接冒泡到调用防抖函数的地方。这其实是合理的行为——你调用 debouncedSearch() 时同步抛错,开发者能第一时间感知。但在"延迟执行"分支里,异常发生在异步定时器内,连 try/catch 都拦不到,只能在原函数内部处理。这也是实际项目中我会额外封装错误捕获的原因,后面展开说。
第三个问题:immediate: true 时,用户第一次点击就执行,最后一次点击后还会执行吗?
不会。这个版本的语义是"只在冷却期开始时执行一次",最后一次触发不会补执行。如果你需要"既立即执行一次,最后再补执行一次"(比如搜索输入框:第一次输入就搜,停顿后再搜一次),就需要引入 leading + trailing 双触发模型。
4.3 双触发版:lodash 风格的 leading / trailing
lodash 的做法是把配置项变成 { leading: true, trailing: true } 对象,支持同时开启前缘和后缘触发。逻辑比上面的"单开关"版本更复杂,但用起来更灵活。
我提供一个可读性优化过的简化版:
javascript复制function debounce(fn, delay, options = {}) {
let timer = null;
let lastArgs = null;
let lastContext = null;
let result;
const leading = options.leading ?? false;
const trailing = options.trailing ?? true;
const later = () => {
timer = null;
if (trailing) {
result = fn.apply(lastContext, lastArgs);
}
lastArgs = null;
lastContext = null;
};
const debounced = function (...args) {
const context = this;
const isTimerPending = timer !== null;
if (!isTimerPending && leading) {
// 没有定时器且启用 leading,立即执行
result = fn.apply(context, args);
} else {
lastArgs = args;
lastContext = context;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(later, delay);
return result;
};
return debounced;
}
这段代码把"是否立即执行"和"是否延迟补执行"拆成了两个独立开关。默认 trailing: true、leading: false,行为等价于第二节的基础版;设置为 { leading: true, trailing: true },就得到"前缘 + 后缘"都触发的双保险。
不过注意,这个简化版有个细节:当 leading 为 true 时,第一次触发立即执行完,之后 lastArgs/lastContext 是 null,定时器到点后 later 里因为 trailing 为 true 会执行一次 fn.apply(null, null)——这明显不对。所以这个版本只适合理解原理,真要落地建议直接看 lodash 的完整实现,或者给 later 加上哨兵判断:只有 lastArgs !== null 时才补执行。写成这样:
javascript复制const later = () => {
timer = null;
if (trailing && lastArgs) {
result = fn.apply(lastContext, lastArgs);
}
lastArgs = null;
lastContext = null;
};
这样当第一次立即执行后、冷却期内没有新调用时,lastArgs 为 null,就不做多余的补执行,语义就对了。
5. cancel 与 flush:给防抖函数加上"后悔药"和"手动触发"
写到这里,基础面试题已经完全够用了。但如果你把这些能力加进去,你给出的答案实际上是超越面试预期的,在项目中也能直接落地,避免很多隐蔽 bug。
5.1 cancel:让防抖可以被主动取消
考虑一个表单页面:用户输入时给搜索框加防抖,但如果用户在这期间点了"清空所有条件"按钮,之前排队的那个"500 毫秒后发送搜索请求"的定时器还在滴答作响,一旦到点就会拿着旧参数发请求,返回的数据可能把新的界面状态冲掉。
解决思路很简单:防抖函数内部维护的 timer 对外不透明,但我们可以暴露一个 cancel 方法:
javascript复制function debounce(fn, delay, immediate = false) {
let timer = null;
const debounced = function (...args) {
const context = this;
if (timer) clearTimeout(timer);
if (immediate) {
const callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay);
if (callNow) fn.apply(context, args);
} else {
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
}
};
debounced.cancel = function () {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
}
调用方式:
javascript复制const debouncedRefresh = debounce(refreshList, 300);
searchInput.addEventListener('input', debouncedRefresh);
clearBtn.addEventListener('click', () => {
debouncedRefresh.cancel();
});
这段代码的细节在于 cancel 里不仅 clearTimeout(timer) 还手动把 timer = null。不要小看这一步——如果不把 timer 置空,当 immediate: true 时,callNow = !timer 会永远为 false,立即执行功能就永久失效了。这个 bug 特别隐蔽,很多人写完测试时只测了基础版,没测立即执行版,上线后才发现问题。
5.2 flush:不等 delay,立刻执行排队的调用
与 cancel 相对的是 flush——它的作用是"立刻执行当前已经排上队的那个调用,然后清空定时器"。这在你需要"强制提交最后一次输入"的场景中很实用,比如搜索输入框旁边有个"立即搜索"按钮,用户输入停顿还没到 500ms 就点了按钮,等着拿最新的关键词结果,此时应该 flush 而不是再等。
javascript复制debounced.flush = function () {
if (timer) {
const context = lastContext;
const args = lastArgs;
clearTimeout(timer);
timer = null;
fn.apply(context, args);
}
};
注意,flush 要成立,就必须在基础版本里保存 lastArgs 和 lastContext。这块逻辑跟第 4 节的双触发版本是一致的,所以生产级防抖函数通常会把 lastArgs/lastContext 的保存作为基本功,这也解释了为什么 lodash.debounce 内部实现那么"啰嗦"——每一个变量都有它的用途。
5.3 完整版代码与使用注意
把 cancel 和 flush 整合进一个最终版本,大概是这个样子(我会在注释里标清楚每个字段的职责):
javascript复制function debounce(fn, delay, immediate = false) {
let timer = null;
let lastArgs = null;
let lastContext = null;
let result;
const debounced = function (...args) {
const context = this;
lastArgs = args;
lastContext = context;
if (timer) clearTimeout(timer);
if (immediate) {
const callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay);
if (callNow) result = fn.apply(context, args);
} else {
timer = setTimeout(() => {
timer = null;
result = fn.apply(context, args);
}, delay);
}
return result;
};
debounced.cancel = function () {
if (timer) {
clearTimeout(timer);
timer = null;
}
lastArgs = null;
lastContext = null;
};
debounced.flush = function () {
if (timer) {
const args = lastArgs;
const context = lastContext;
clearTimeout(timer);
timer = null;
if (args) {
result = fn.apply(context, args);
}
}
return result;
};
return debounced;
}
使用注意点有三条:
- cancel 之后一定要让
lastArgs/lastContext也归零,否则下次调用时,flush 可能会误执行一次旧参数调用。 - flush 和 cancel 应当配套使用。我通常这样写:用户点击"立即搜索"时先调用
flush()拿到结果,如果flush没东西可执行(比如 timer 本来就是空),再手动调用防抖函数。 - 频繁调用
flush会破坏防抖的意义。它是异常路径的逃生舱口,不是常规路径,设计 API 时要有意识地让它"不容易被误用"。
6. 防抖和节流,别再傻傻分不清
很多人在面试中最怕的一句话是"那你再讲讲防抖和节流的区别"。这两个概念高度相似,但使用场景完全不同。我在实际项目中见过有人在滚动加载里用防抖,结果用户快速滚动时数据完全加载不出来,直到停下才加载——体验极差。
6.1 一句话区分
防抖是"以最后一下为准,重置等待时间";节流是"固定节奏执行,不因高频触发而加速或减速"。
用一个生活化类比:你在火锅店涮毛肚。防抖是"每当你夹起一块毛肚,旁边有人提醒你'再等等,刚才那块还没熟',你一直夹一直等,最后只吃了最后一块熟的";节流是"锅里每 10 秒就自动浮起一批毛肚,不管你是不是一直在夹,它每隔 10 秒就供应一轮"。
6.2 两种实现与适用场景对照
| 行为 | 防抖(debounce) | 节流(throttle) |
|---|---|---|
| 触发规律 | 连续触发只执行最后一次 | 连续触发按固定时间间隔执行 |
| 核心变量 | 一个 timer,反复重置 | 一个时间戳或 timer 状态位 |
| 典型场景 | 搜索联想、表单输入、窗口 resize 后的重算 | 滚动加载、拖拽事件、动画帧更新、按钮频率限制 |
| 对系统的影响 | 大幅减少调用次数,适合"结果只关心终点"的场景 | 保证调用上限,适合"过程必须平滑持续"的场景 |
手写节流(时间戳版)我给大家留一份常用代码,方便对比:
javascript复制function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const context = this;
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(context, args);
}
};
}
时间戳版的缺陷是首尾不触发,尾部的最后一次调用可能会被漏掉。更完善的节流版需要"定时器 + 时间戳"组合,但那已经不是本篇文章的主题,提醒你面试时主动提一嘴"时间戳版有什么缺点",会显得你考虑得周全。
6.3 "先节流后防抖"的复合需求
真实项目中还有一种复合场景:既要保证拖拽过程中持续有反馈(节流),又要在停止拖拽后做一次收尾处理(防抖)。比如拖拽画布,拖拽过程中每 200ms 重绘一次(节流),拖拽结束后 500ms 再执行一次画布缩放适配(防抖)。
做法是分别创建两个函数,监听同一个事件:
javascript复制const renderDuringDrag = throttle(renderCanvas, 200);
const settleAfterDrag = debounce(fitToViewport, 500);
canvas.addEventListener('pointermove', renderDuringDrag);
dragEnded = () => {
settleAfterDrag();
};
不要试图写一个"既是防抖又是节流"的万能函数,那会让代码逻辑混乱,也难以维护。各司其职,组合使用,才是正确的设计姿势。
7. 面试追问与实战验收:从"能写出来"到"真的理解"
最后这部分,我想把面试中高频追问的四个问题拆开讲。每个问题背后都对应实际项目里的一个隐患,提前想明白,比临时背答案有用得多。
7.1 追问一:防抖函数的 timer 为什么不直接用闭包外的某个全局变量?
因为防抖的正确性依赖"每次调用都操作同一个 timer",而全局变量会让多个 debounce 实例互相干扰。假设页面里有两个搜索框,各自用了同一个防抖内部函数不同实例,如果共用全局 timer,A 框触发后清掉了 B 框的定时器,B 框永远不会执行。闭包的本质是为每个防抖实例创建独立的词法环境,这是设计意图的核心。
7.2 追问二:连续调用 debouncedFn 十次,最后一次执行后,fn 内部有没有可能拿到一个"半路上被修改"的参数?
答案是:不可能。因为防抖内部每次调用都先把最新参数存到 lastArgs 里,再清掉旧定时器、设置新定时器。定时器到点执行时,读取的是最新一次保存的 lastArgs,而不是第一次调用的旧参数。这是"以最后一次为准"的实现基础。
7.3 追问三:如果延迟时间为 0,防抖还有意义吗?
延迟时间为 0 的防抖等价于"宏任务微任务化"——相当于把回调推到下一次事件循环。这个操作本身仍然有意义,因为它可以让连续同步触发在渲染前只执行最后一次,常用于某些 DOM 更新合并。但要注意,delay = 0 时的定时器并不保证 0 毫秒执行,浏览器有最小延迟限制(通常 4ms 左右),在性能敏感场景下要考虑这个误差。
7.4 追问四:在生产项目里,你会直接使用手写的 debounce 还是引入工具库?
这是个开放题,我的答案是分情况。如果你的项目已经引入了 lodash,直接用 lodash.debounce,因为它的边界处理(leading/trailing/maxWait/cancel/flush)经过大量真实场景验证,比我手写的版本健壮得多。如果你不想为了一个函数引入整个 lodash,抄一个"最小可用版"放到项目公共工具文件里也完全合理,只要测试覆盖到位即可。
关于测试,我想多说一句。很多人在项目里"手写防抖却从没测过",这是个危险习惯。防抖涉及定时器,普通的单元测试里如果没有 fake timers 配合,很容易产生不稳定的测试用例。我用 vitest 或 jest 的时候,一定会把 vi.useFakeTimers() 开启,然后 vi.advanceTimersByTime(delay) 手动推进时间,再断言回调是否被调用。这比写一个"等 500ms 再断言"的测试稳定得多,也不会拖慢测试速度。
javascript复制it('debounce should only call fn once after rapid calls', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 500);
debounced();
debounced();
debounced();
vi.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
});
这段代码单独拎出来,也方便你看清防抖的本质:三次调用、一个定时器、一次执行。
我在实际项目里踩过一次坑:给一个"保存草稿"的功能加了防抖,用户连续编辑时不会反复写本地存储,停顿后才保存一次。这本是好意,但后来用户反馈"我点了保存草稿按钮,然后马上关掉了页面,结果草稿丢了"。原因就是关页面时,防抖的定时器还挂着,没来得及执行。那个版本的代码里没有 flush 方法,我也没意识到这个边界。后来吸取教训,给所有涉及持久化操作的防抖函数都加上一个"beforeunload / pagehide 时 flush 一次"的处理,问题才彻底解决。这也是我在这篇文章里花一整节写 cancel 和 flush 的原因——面试时它们是加分项,生产环境中它们是保命符。
如果你从头看到了这里,我建议你别只是读,而是关掉浏览器,纯靠记忆把最终版代码写出来,再自己跑一遍"连点十下只执行一次""等待结束后还能再次触发""cancel 后不再执行""flush 立即执行"这四个基础用例。能跑通这四关,手写防抖这道题,你就真的吃透了。
