防抖(debounce)和节流(throttle)是前端面试的钉子户,也是实际开发里真能救命的优化手段。但很多人能把概念背得滚瓜烂熟,一让手写就卡壳;或者干脆把两个用反了;又或者会写、却不知道在框架里有个坑能让它彻底失效。这篇不绕弯子,把原理、时间线图解、各种版本的手写实现、框架里的坑、面试的追问,一次讲透。看完你应该能做到:闭着眼写出来,知道每个场景该用哪个,也讲得清它背后的取舍。文章有点长,但这是个"一次搞懂、终身受用"的知识点。
为什么需要防抖和节流
有些事件触发得特别勤:input(每敲一个字符一次)、scroll(滚一下几十次)、resize、mousemove(移一下几百次)、touchmove。这些事件本身没有问题 —— 问题在于"在它们的回调里干重活"。
浏览器一帧的预算只有约 16.6ms(60fps 下)
事件触发 ──► 你的回调(发请求 / 改 DOM / 算布局)──► 浏览器要渲染这一帧
如果回调干的活太重、又触发得太频繁,
一帧的预算被你的回调吃光 ──► 来不及渲染 ──► 掉帧、卡顿、页面发涩
scroll 滚一下,可能触发几十次;mousemove 移一下,几百次;
input 每敲一个字一次;resize 拖一下,连续几十次。
事件本身没问题,问题是"在它们的回调里干重活"。
浏览器渲染一帧的时间预算,在 60fps 下只有约 16.6ms。这 16.6ms 里要跑你的 JavaScript、算样式、布局、绘制。如果你在一个高频事件的回调里发网络请求、大量操作 DOM、做复杂计算,而这个事件一秒触发几十上百次 —— 一帧的预算被吃光,浏览器来不及渲染,结果就是用户能直观感受到的卡顿、掉帧、页面发涩。
防抖和节流,本质上都是"降频":事件该触发还触发(我们拦不住用户滚动、打字),但你的重活别跟着每一次触发都干。它俩的全部区别,只在于"用什么策略来降频"。理解了这一句,后面全是细节。
一张时间线图,先把区别看明白
文字描述容易绕,先上一张时间线图。横轴是时间,▲ 表示事件被触发,★ 表示你的函数真正执行了一次:
时间线图解(▲ = 事件触发,★ = fn 真正执行,delay/interval = 300ms)
【原始】每次触发都执行:
▲★ ▲★ ▲★ ▲★ ▲★ ▲★ ▲★ ▲★ ← 触发几次就执行几次,最浪费
【防抖 debounce】只在"停下来 300ms 后"执行一次:
▲ ▲ ▲ ▲ ▲ ▲
└─安静300ms─┘★ └─安静300ms─┘★
← 频繁触发期间一次不执行,等彻底停了才执行。一阵狂点 = 只执行 1 次
【节流 throttle】每 300ms 最多执行一次:
▲★ ▲ ▲ ▲★ ▲ ▲ ▲★ ▲ ▲ ▲★
└─300ms─┘ └─300ms─┘ └─300ms─┘
← 频繁触发期间也执行,但被压成固定节奏
看这张图,两者的区别一目了然:防抖在"频繁触发期间"是完全沉默的,只在"安静下来之后"动一次;节流在"频繁触发期间"也动,但被掐成了固定的节拍。把这张图记在脑子里,后面所有的实现细节都是为了达成这两种效果。
防抖:等你彻底消停了,再做一次
防抖的策略是:每次触发都重新计时,只有"安静"了一段时间(delay)没再触发,才真正执行一次。
打个比方 —— 电梯关门。有人进来,门就重新等几秒;又有人进来,再重新等;直到没人进了、安静够久了,门才关。在频繁有人进的这段时间里,门一次都不关。防抖就是这个逻辑。
基础版防抖:
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 又触发了?把上一个计时器作废
timer = setTimeout(() => {
fn.apply(this, args); // 安静 delay 毫秒后,才真正执行一次
}, delay);
};
}
input.addEventListener('input', debounce(() => search(input.value), 300));
核心机制就一句话:clearTimeout 把上一个计时器作废,重新 setTimeout。只要还在频繁触发,计时器就一直被重置,永远到不了"执行"那一刻;一旦停下来超过 delay,最后那个没被清掉的计时器才走完、执行。
最经典的场景是搜索框联想:用户还在打字就别发请求 —— 既省掉大量无效请求,又避免了"结果乱跳"(早发的请求后回来,把新结果覆盖了)。其他典型场景:表单实时校验、窗口 resize 后重排、编辑器自动保存草稿。
防抖进阶:立即执行 + 可取消
基础版有两个在实战和面试里都常被要求补的能力。第一个是首次立即执行(immediate / leading edge):用于"第一次操作要马上响应,后续才防抖"的场景 —— 比如按钮第一次点立刻生效,但短时间内的连点被屏蔽。第二个是可手动取消(cancel):用于组件卸载时把还挂着的计时器清掉,否则可能在组件已销毁后那个迟到的回调还在执行,轻则报错,重则内存泄漏。
完整版防抖:支持 immediate(首次立即执行)+ cancel(可取消)
function debounce(fn, delay, immediate = false) {
let timer = null;
function debounced(...args) {
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
}
debounced.cancel = function () { // 组件卸载时调用,清掉挂起的计时器
clearTimeout(timer);
timer = null;
};
return debounced;
}
这个带 immediate 和 cancel 的版本,是面试里"请写一个完整的防抖"的标准答案。注意 callNow 的判断要在 clearTimeout 之前算好 —— 因为 clearTimeout 不会把 timer 变量本身置空,我们靠 timer 是否存在来判断"是不是第一次触发"。
防抖的进阶变体:返回 Promise
实战里有个很常见的需求:"防抖地发请求,而且我还要拿到请求的结果"。基础版防抖的返回值是 undefined,没法 await。这时候需要一个返回 Promise 的版本:
进阶变体:返回 Promise 的防抖(常用于"防抖地发请求、还要拿到结果")
function debounceAsync(fn, delay) {
let timer = null;
return function (...args) {
return new Promise((resolve, reject) => {
clearTimeout(timer);
timer = setTimeout(async () => {
try { resolve(await fn.apply(this, args)); }
catch (e) { reject(e); }
}, delay);
});
};
}
// 用法:const search = debounceAsync(api.search, 300);
// const list = await search(keyword);
它的思路是:每次调用都返回一个新的 Promise,但只有最后一次(防抖通过的那次)对应的 Promise 会被 resolve。这样调用方就能用 const list = await search(keyword) 这种顺手的写法,既享受了防抖、又能拿到结果。这个变体在做"搜索 + 拿数据渲染"的场景里特别实用,面试里也算亮点。
节流:再急,也按固定节奏来
节流的策略是:不管你触发多频繁,我固定每隔一段时间(interval)才执行一次。打比方 —— 水龙头拧到最小,水(事件)一直在流,但它匀速地滴。节流不像防抖那样"频繁期间一次不干",它在频繁触发期间也照干,只是把频率压到固定节奏上。
节流有两种经典实现,差别值得搞清楚:
节流实现一:时间戳版 —— 第一次立即执行,停止后不补最后一次
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) { last = now; fn.apply(this, args); }
};
}
节流实现二:定时器版 —— 第一次延迟 interval 才执行,但会补最后一次
function throttle(fn, interval) {
let timer = null;
return function (...args) {
if (timer) return;
timer = setTimeout(() => { timer = null; fn.apply(this, args); }, interval);
};
}
时间戳版:记一个"上次执行的时间戳",每次触发都看"距离上次够不够久"。特点是 —— 第一次触发会立即执行,但停止触发后不会补最后一次。定时器版:用一个计时器"占位",计时器在跑就忽略新触发。特点是 —— 第一次触发会延迟 interval 才执行,但停止触发后会补最后一次。
你在意"第一下要立刻有反应",用时间戳版;在意"最后一下不能丢",用定时器版。而生产级的库(比如 lodash 的 throttle)其实是把两者合一:
合体版节流:既首次立即执行,又补最后一次(lodash throttle 的核心思路)
function throttle(fn, interval) {
let last = 0, timer = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) { // 到点了,立即执行
if (timer) { clearTimeout(timer); timer = null; }
last = now;
fn.apply(this, args);
} else if (!timer) { // 没到点,挂一个定时器把"最后一次"补上
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
这个"合体版"既保证首次立即执行,又用一个收尾的定时器把最后一次补上,是最完整的节流实现。能在面试里写出这一版,基本就是"节流大师"了 —— 因为大多数人只会背单独的某一版。
防抖 vs 节流:一张表说清
| 对比项 | 防抖 debounce | 节流 throttle |
|---|---|---|
| 核心策略 | 频繁触发期间不执行,停下来才执行一次 | 频繁触发期间也执行,但按固定节奏 |
| 关注点 | "结束"——只要最终结果 | "过程"——过程中也要持续响应 |
| 比喻 | 电梯门(有人进就重新等) | 水龙头拧小(匀速滴) |
| 一直触发会怎样 | 永远不执行 | 每隔 interval 执行一次 |
| 典型场景 | 搜索联想、表单校验、resize 后重排 | 滚动加载、拖拽、视差、游戏开火 |
一句话记忆,刻进脑子:防抖看"结束",节流看"过程"。
实战场景大全:到底该用哪个
| 场景 | 该用 | 原因 |
|---|---|---|
| 搜索框输入联想 | 防抖 | 只关心最终输入,中途发请求纯浪费,还会结果乱跳 |
| 表单输入实时校验 | 防抖 | 等用户写完一段再校验,体验更好 |
| 窗口 resize 后重新布局 | 防抖 | 只需在拖动结束后算一次 |
| 按钮防重复提交 | 防抖(immediate) | 首次立即响应,之后短时间内屏蔽连点 |
| 编辑器自动保存草稿 | 防抖 | 用户停止编辑一段时间后存一次,够了 |
| 滚动加载更多 / 懒加载判断 | 节流 | 过程中要持续判断位置,但不必每像素都判断 |
| 滚动进度条 / 视差效果 | 节流(rAF 版) | 要持续更新,跟着刷新率走最跟手 |
| 拖拽元素 / 画板取坐标 | 节流 | 过程中要持续跟随,但可以压频 |
| 射击游戏开火 / 技能 CD | 节流 | 本质就是"固定频率才能触发一次" |
进阶:requestAnimationFrame 版节流
对于"滚动时更新视觉效果"这类纯渲染相关的高频任务,用固定毫秒数节流其实不够优雅 —— 你定 16ms 还是 32ms?和屏幕刷新率对不齐,要么浪费要么掉帧。更好的做法是用 requestAnimationFrame 节流,让执行频率自动对齐浏览器的渲染节奏:
requestAnimationFrame 版节流 —— 改"视觉"的高频任务专用
function rafThrottle(fn) {
let ticking = false;
return function (...args) {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { fn.apply(this, args); ticking = false; });
};
}
window.addEventListener('scroll', rafThrottle(updateScrollProgress), { passive: true });
rAF 版的好处:天然和屏幕刷新率同步(60Hz 屏约每 16.6ms 一次,120Hz 屏更密);页面切到后台标签时 requestAnimationFrame 会自动暂停,省电省 CPU;执行时机正好在浏览器下一次绘制之前,改样式最跟手、最不容易出现中间态闪烁。
选择规则:改"视觉"的高频任务用 rAF 节流;干"逻辑"的高频任务用普通毫秒节流。另外注意上面给 scroll 加了 { passive: true } —— 这是另一个独立的滚动优化,告诉浏览器"这个监听器不会调 preventDefault",和节流是叠加生效的。
框架里的坑:React / Vue 中正确使用
这是实战中最容易踩、又最隐蔽的坑:在组件的渲染过程中直接 debounce(fn, 300),每一次渲染都会生成一个全新的防抖函数。新函数内部的 timer 是全新的、和上一次接不上 —— 防抖完全失效,而你还以为自己写对了。
// ✗ 错:每次渲染都新建一个防抖函数,内部 timer 是全新的,接不上 —— 等于没防抖
function SearchBox() {
const onInput = debounce((v) => search(v), 300); // 每次 render 都是新函数
return <input onChange={(e) => onInput(e.target.value)} />;
}
// ✓ 对(React):用 useMemo 让它只创建一次,卸载时 cancel
function SearchBox() {
const onInput = useMemo(() => debounce((v) => search(v), 300), []);
useEffect(() => () => onInput.cancel(), [onInput]);
return <input onChange={(e) => onInput(e.target.value)} />;
}
// ✓ 对(Vue 3):在 setup 顶层创建一次即可
const onInput = debounce((v) => search(v), 300);
onUnmounted(() => onInput.cancel());
解决思路对 React 和 Vue 一样:保证防抖/节流函数在组件整个生命周期里只被创建一次。React 用 useMemo(或 useRef);Vue 3 在 setup 顶层创建一次即可。别忘了组件卸载时调 cancel() 清掉挂起的计时器。
怎么验证防抖节流真的生效了
写完之后怎么确认它真的在工作,而不是"看起来加了其实没生效"(比如踩了上面那个框架的坑)?三个办法:
- 最简单:在被防抖/节流的函数里
console.log打个标记,加时间戳。然后疯狂触发事件,看 console 里这个标记出现的频率 —— 防抖应该是"停手后才出现一次",节流应该是"匀速出现"。如果它还是跟着事件疯狂打印,说明没生效。 - Network 面板:如果防抖的是发请求,直接看 Network 面板里请求的数量和时间间隔,最直观。
- Performance 面板:录一段操作,看主线程的火焰图 —— 没优化前是密密麻麻的长任务,优化后应该明显稀疏。这也是定位"到底是不是这里卡"的硬办法。
养成"加了优化就验证一下"的习惯,能帮你避免"自以为优化了、其实没生效"的尴尬。
防抖节流之外:有时候有更好的工具
防抖节流是"通用降频手段",但针对某些具体场景,现代浏览器提供了更专门、更高效的 API:
- 判断元素是否进入视口 → 用
IntersectionObserver,不要监听 scroll。图片懒加载、无限滚动、曝光埋点,过去要 scroll + 节流 + 算位置,现在直接用IntersectionObserver,原生、更准、更省。 - 监听元素尺寸变化 → 用
ResizeObserver,不要监听 window 的 resize。而如果只是想根据尺寸切样式,用 CSS 容器查询连 JS 都不用。 - 纯视觉的持续动画 → 用
requestAnimationFrame驱动,而不是在 scroll/mousemove 里硬算。
这不是说防抖节流过时了 —— 它们依然是"输入框、按钮、通用回调降频"的首选。只是提醒你:遇到"判断可见性""监听尺寸"这种具体需求时,先想想有没有更对口的专用 API。
面试常见追问
Q:防抖里为什么用 fn.apply(this, args) 而不是直接 fn()?为了保证回调里的 this 指向和参数(尤其是事件对象 event)能正确传进去。直接 fn() 会丢掉 this 和 event,你回调里想拿 event.target 就拿不到了。
Q:能不能实现一个"既首次立即执行、又结束时补一次"的防抖?能,就是 lodash debounce 把 leading 和 trailing 都设为 true 的效果。在进阶版基础上把两个分支都打开,并处理好"只触发一次时别执行两遍"的边界。
Q:节流和防抖能组合使用吗?能,且复杂交互里很常见。比如"持续滚动时用节流每 200ms 给一次反馈,滚动彻底停下后再用防抖做一次精确的最终处理"。
Q:有 lodash 现成的,为什么还要会手写?手写一遍能让你真正理解它的行为边界 —— 什么时候 leading、什么时候 trailing、cancel 清的是什么、为什么在 React 里直接用会失效。用库时遇到诡异问题,这些理解能让你知道去哪儿找原因。
Q:防抖的 delay 设多少合适?看场景。搜索联想一般 200-400ms;按钮防连点几百毫秒到 1 秒;resize 重排 150-300ms。原则是"在不让用户觉得卡顿迟钝的前提下,尽量长"。
Q:节流和 setInterval 有什么区别?setInterval 是"不管有没有事件,我都固定间隔执行";节流是"有事件触发时,才按固定间隔执行,没触发就不执行"。节流是"事件驱动 + 限频",setInterval 是"纯定时"。
实战里还会踩的两个 bug
bug 一:在列表里给每一项都用了同一个防抖函数。如果列表每项需要独立防抖,它们必须各自持有独立的防抖实例(独立的 timer)。共用一个的话,操作 A 项会把 B 项的计时器也清了。要么给每项创建独立实例,要么用一个以"项 id"为 key 的 Map 管理多个 timer。
bug 二:防抖包裹的函数依赖了"创建时的闭包变量",但那个变量后来变了。防抖函数通常只创建一次,它闭包里捕获的变量也是创建那一刻的。如果回调依赖某个会变的状态,要么把状态作为参数传进来,要么用 ref 之类的手段让它能拿到最新值 —— 这和 React 里的"闭包陷阱"是同一类问题。
写在最后
防抖和节流的原理其实不难,难的是用对场景、写全实现、避开框架里那个"重复创建"的坑。把这篇的几段代码亲手敲一遍,把那张时间线图和"场景大全"表记住,这个知识点就真正是你的了。
核心再收一遍:高频事件回调干重活会卡顿,所以要降频;防抖"等结束"、节流"按节奏";防抖记得带 immediate 和 cancel,还有返回 Promise 的变体;节流分时间戳版、定时器版和合体版;改视觉用 rAF 节流;在 React/Vue 里务必让防抖函数只创建一次、卸载时 cancel;加了优化记得验证它真生效。
无论是面试被追问,还是实际开发里遇到页面卡顿,你都能立刻反应过来:这里该降频、该用哪个、该怎么写、坑在哪。这就是把一个知识点真正吃透的价值。
—— 别看了 · 2026