
防抖还是节流?搞混 leading 和 trailing 的人,面试都栽在这
防抖和节流,前端面试的老熟人了。手写基础版大家都会,10 行代码的事。但面试官紧接着来一句「那 leading: true 是什么效果?」——这一下,一半人当场安静。
这篇咱们用大白话把这两兄弟彻底讲明白:电梯、地铁闸机、游戏技能 CD,全是你天天见的场景。文中所有代码我都在 Node 里跑过测试用例(后面会说测了什么),可以放心抄进项目。
先看一个真实的翻车现场
你在做电商项目的搜索联想。用户在搜索框敲「机械键盘」四个字,如果每敲一个字就发一次请求,那就是:
code机 → 发请求 机械 → 发请求 机械键 → 发请求 机械键盘 → 发请求
一个词,4 次请求。前面 3 次查的都是没人要的半截词,纯属浪费。这还只是一个用户——大促当天几十万人同时在搜,接口直接被自己人打挂,后端同事提着刀就来了。
滚动事件更夸张。scroll 一秒能触发上百次,你要是在里面写了个稍重的计算:
jswindow.addEventListener("scroll", () => {
expensiveCalculation(); // 一秒跑 100+ 次
});页面掉帧、风扇起飞、用户骂街,三连。
问题的本质是:事件触发多少次,咱们管不了;但函数执行多少次,咱们说了算。 防抖和节流就是两种「管住执行次数」的策略,思路完全不同。
防抖:像电梯关门,有人进来就重新等
防抖一句话:你一直触发,我就一直不执行;你彻底停了,我再执行一次。
最贴切的类比是电梯关门。门正要合上,又挤进来一个人——门重新打开,重新倒计时。只要一直有人进,门就永远关不上。直到彻底没人了,再等两秒,门才真正关。
放到搜索框:用户连续打字期间一个请求都不发,停手 500ms 后,只拿最终的完整词发一次。前面那些「机」「机械」半截词,一个都不浪费。
代码就是把「电梯重新倒计时」翻译一遍:
jsfunction debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer); // 有人进电梯了,把之前的倒计时作废
timer = setTimeout(() => { // 重新开始倒计时
fn.apply(this, args); // 倒计时结束都没人来 → 真正执行
}, delay);
};
}每次触发就干两件事:掐掉上一个定时器,起一个新的。只要触发不停,定时器就永远活不到执行那一刻。
这里有两个面试官爱抠的细节,用大白话说:
一是那个 timer 为什么能在多次调用之间「记住」自己?因为返回的函数把它「揣兜里」了——外层函数执行完,timer 本该销毁,但内层函数还引用着它,所以一直活着。这就是闭包,不用背定义,记住「揣兜里」这个画面就行。
二是为什么写 fn.apply(this, args) 而不是直接 fn(...args)?因为如果你防抖的是某个对象的方法,直接调用会把 this 弄丢。这种 bug 本地测不出来,上线才炸,排查半天最后发现是 this 飞了——别问我怎么知道的。
节流:像游戏技能 CD,冷却没好按了也白按
节流是另一个思路:不管你触发多疯狂,我按自己的节奏,每隔固定时间最多执行一次。
打游戏都懂:技能放出去就进 CD,冷却期间你把键盘按穿也没用,CD 转好才能放下一次。地铁早高峰的限流闸机也一样,人流再汹涌,闸机按固定节奏放行。
jsfunction throttle(fn, delay) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= delay) { // CD 转好了吗?
lastCall = now; // 记下这次放技能的时间
fn.apply(this, args); // 放!
}
};
}两兄弟的区别,一张时序图就看清了(事件流一模一样,执行时机天差地别):
code事件流: |||||||||||||||||||||||||||| (高频触发)
防抖: ---------------------------🔥 (等你彻底停手,只执行1次)
节流: 🔥--------🔥--------🔥--------🔥 (按固定节奏,匀速执行)选哪个不看心情,看你要终态还是要过程:
搜索联想、自动保存、表单校验、窗口 resize 后重排布局——只关心用户停手后的最终结果,用防抖。滚动加载、滚动埋点上报、拖拽跟随——过程中每个阶段都要响应,只是不用那么密,用节流。
记不住就背这两句:防抖等静默,节流控频率。
leading 和 trailing:真正的送命题来了
前面都是基础分,不会直接挂。而 leading/trailing,是面试官用来区分「背过题」和「真理解」的照妖镜。
先说人话版定义。一轮连续触发就像一串鞭炮:
code一轮连续触发: | | | | |
↑ ↑
leading 在这执行 🔥 🔥 trailing 在这执行
(第一响就执行) (放完了才执行)trailing(默认):等鞭炮放完再执行——上面的基础防抖就是纯 trailing。 leading:第一响立刻执行,后面的全忽略。
记忆口诀:leader(领导)走最前面,trailer(片尾彩蛋)压最后面。
光背定义没用,面试官要场景。什么时候必须用 leading?提交订单按钮。用户手抖狂点五下,你要的是第一下立刻生效、后面四下全忽略。这时候用默认的 trailing 防抖就搞笑了:用户点完要干等 500ms 才有反应,而且真正执行的是「最后一下点击」——万一这 500ms 里用户改了个数量呢?语义完全不对。
那 leading 和 trailing 一起开是什么鬼?这是面试官的必杀追问,但真有场景:微信聊天里的「对方正在输入…」。对方开始打字的瞬间就要亮提示(leading),停止打字后要熄灭提示(trailing),一头一尾都得要。
理解到这一步,进阶实现就是把话翻译成代码。TypeScript 版:
tsinterface DebounceOptions {
leading?: boolean;
trailing?: boolean;
}
function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number,
options: DebounceOptions = {}
) {
let timer: ReturnType<typeof setTimeout> | null = null;
const { leading = false, trailing = true } = options;
return function (this: unknown, ...args: Parameters<T>) {
// timer 为空 = 新一轮鞭炮的第一响
const callNow = leading && !timer;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null; // 这轮放完了,下次触发算新一轮
if (trailing && !callNow) {
fn.apply(this, args);
}
}, delay);
if (callNow) fn.apply(this, args);
};
}JavaScript 版(逻辑一样,去掉类型直接用):
jsfunction debounce(fn, delay, options = {}) {
let timer = null;
const { leading = false, trailing = true } = options;
return function (...args) {
const callNow = leading && !timer;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (trailing && !callNow) {
fn.apply(this, args);
}
}, delay);
if (callNow) fn.apply(this, args);
};
}这段代码有两处「题眼」,能主动讲出来基本就是满分:
第一处是 timer = null。它不是随手清理,而是在宣布「这一轮结束了」——下次触发时 !timer 重新成立,leading 才能再次开火。少了这行,leading 只会在第一轮生效一次,之后永远哑火。
第二处是 trailing && !callNow 里的 !callNow。想一个场景:用户就点了一下按钮。leading 已经立即执行了,如果末尾 trailing 再补一发,一次点击提交两个订单——这就不是 bug 了,是事故。!callNow 就是那道保险。
这几段代码我都在 Node 里实测过:连续触发只执行 1 次且拿到最后一次参数、this 指向正确、leading 只放行第一次、leading+trailing 头尾各 1 次、单次触发不会双发(就是上面那个订单场景)、节流按固定频率放行——11 个用例全部通过,TS 版在 strict 模式下编译无报错。抄走就能用。
React 里的坑:你的防抖可能压根没生效过
把防抖搬进 React 组件,第一反应往往是这么写:
jsx// ❌ 看起来人畜无害,实际上防抖完全失效
<input onChange={debounce(handleSearch, 500)} />问题出在哪?debounce(handleSearch, 500) 写在 JSX 里,每次渲染都会重新执行一遍,返回一个全新的防抖函数。新函数意味着新闭包、新的 timer——用户每敲一个字触发 setState,组件一渲染,上一个防抖函数连同它正在跑的倒计时一起被扔进垃圾桶。相当于电梯每来一个人就整个换一部新电梯,那还倒计什么时?
这个坑最阴的地方是:你本地慢悠悠打字测试,看起来是好的。上了线用户手速一快,请求哗哗地发。
正确姿势是用 useMemo 把防抖函数「钉住」,只创建一次:
jsxconst debouncedSearch = useMemo(
() => debounce(handleSearch, 500),
[]
);配套还有两个方法要知道(lodash 这类成熟库都带):
cancel()——「算了别发了」。 用户输完字、请求还在 500ms 等待期里,他把弹窗关了。这个挂起的请求还该发吗?不该。组件卸载时 debounced.cancel() 掐掉它,不然回调会去 setState 一个已经卸载的组件,控制台警告糊你一脸。
flush()——「别等了赶紧存」。 反过来的场景:自动保存还在等待期,用户要关页面了。这时候不是取消,而是立刻执行 debounced.flush(),别让用户白写半天。
最后是清理。useEffect 里挂了节流滚动监听,卸载时不清干净,监听器和定时器就一直赖着不走,标准的内存泄漏:
jsxuseEffect(() => {
const throttledScroll = throttle(handleScroll, 200);
window.addEventListener("scroll", throttledScroll);
return () => {
window.removeEventListener("scroll", throttledScroll);
throttledScroll.cancel?.(); // 多数人漏的就是这行
};
}, []);面试里大家都记得移除监听器,但能想起来「还有个定时器可能正在倒计时」的人不多。cleanup 里补上 cancel(),这是个加分细节。
收个尾
串一下主线:高频事件得管住执行次数 → 防抖像电梯关门(等静默),节流像技能 CD(控频率) → leading 第一响就放、trailing 放完才放,按钮防连点用 leading,「正在输入」提示两个都要 → React 里用 useMemo 钉住函数,卸载记得 cancel。
基础实现是入场券,leading/trailing 的场景理解和 React 里的坑,才是真正拉开差距的地方。
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

