防抖(debounce)和节流(throttle)是前端控制高频事件的两大基础手段。当用户连续滚动页面、拖拽窗口、或在搜索框里疯狂敲击时,绑定的回调函数会被每秒触发几十上百次——轻则页面卡顿掉帧,重则把后端接口直接打挂。理解防抖与节流的区别,并能在正确场景里选用合适的那一个,是每一个前端工程师的必修课。本文用可运行的原生代码,把这两种”高频事件限速器”彻底讲透。
一、先分清两个概念
1.1 防抖 debounce:只认最后一次
防抖的核心思想是”等安静”。事件触发后并不立即执行,而是等待一段时间(wait);如果这段等待期内又触发了同一个事件,计时器就会被重置。只有”停下来 wait 毫秒、期间再没人触发”时,函数才会真正执行一次。它关心的是最终结果。典型场景:搜索框输入联想、表单输入校验、窗口尺寸停止变化后的重排版。
1.2 节流 throttle:固定节奏最多一次
节流的核心思想是”按节奏放行”。无论事件触发得多频繁,在 wait 毫秒的时间窗口内,函数最多执行一次。它像水龙头上的限流器,保证一段时间内的执行次数有上限,关心的是采样频率。典型场景:scroll 滚动监听、resize 窗口变化、mousemove 鼠标移动、按钮防止连点提交。
二、防抖的实现:trailing 与 leading
2.1 基础 trailing 防抖(最常用)
下面是最常见、也是默认的”尾部防抖”:用户停止输入 wait 毫秒后才执行。注意用 fn.apply(this, args) 保留原函数的 this 与参数,避免绑定事件时 this 丢失。
function debounce(fn, wait) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 每次触发都重置计时
timer = setTimeout(() => {
fn.apply(this, args); // 等停下来 wait 毫秒才执行
}, wait);
};
}
// 用法:搜索框输入联想
const onInput = debounce((e) => {
fetchSuggest(e.target.value);
}, 300);
input.addEventListener('input', onInput);
2.2 带 leading 与可取消的增强版
有时我们希望”第一次触发就立刻执行”(比如按钮防重复点击),之后再忽略 wait 内的后续触发。下面的版本支持 leading 模式,并暴露 cancel() 便于组件卸载时清理计时器,避免内存泄漏。
function debounce(fn, wait, leading = false) {
let timer = null;
const debounced = function (...args) {
if (timer) clearTimeout(timer);
if (leading && !timer) fn.apply(this, args); // 首触发立即执行
timer = setTimeout(() => {
timer = null;
if (!leading) fn.apply(this, args); // 尾部补执行
}, wait);
};
debounced.cancel = () => { clearTimeout(timer); timer = null; };
return debounced;
}
三、节流的实现:时间戳、定时器与 rAF
3.1 时间戳版节流
时间戳版在”距离上次执行已超过 wait”时才放行,首触发立即响应,尾部可能少执行一次。实现简单、零依赖:
function throttle(fn, wait) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
// 用法:resize 时每 100ms 最多重算一次布局
const onResize = throttle(recalculateLayout, 100);
window.addEventListener('resize', onResize);
3.2 定时器版 + requestAnimationFrame 优化渲染
对于 scroll、mousemove 这类”每次都要和屏幕渲染对齐”的场景,用 requestAnimationFrame(rAF)节流最优雅:它保证回调跟着浏览器刷新节奏走,一帧最多一次,彻底告别掉帧。这和我们在 前端懒加载实战 里用 rAF/IntersectionObserver 控制加载时机的思路一脉相承。
function throttleRAF(fn) {
let ticking = false;
return function (...args) {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
fn.apply(this, args);
ticking = false;
});
}
};
}
const onScroll = throttleRAF(() => {
updateProgressBar(); // 每帧最多更新一次进度条
});
window.addEventListener('scroll', onScroll);
四、防抖与节流的核心区别
把两者放在一起对比,选型就清楚了:
| 维度 | 防抖 debounce | 节流 throttle |
|---|---|---|
| 执行节奏 | 停止触发 wait 后才执行一次 | 每 wait 时间内最多执行一次 |
| 关注点 | 结果(最后一次输入/变化) | 过程(按固定频率采样) |
| 适合场景 | 输入联想、表单校验、自动保存 | 滚动、resize、mousemove、防连点 |
| 是否丢中间值 | 会(只保留最后一次) | 会(按间隔采样丢弃) |
| 首触发响应 | 默认不响应,leading 模式可响应 | 时间戳版立即响应 |
五、真实场景怎么选
5.1 搜索框输入联想 → 防抖
用户连续打字时,你只关心”他最后停下来输入了什么”,而不是每一次击键。用 300ms 防抖,能避免每个字符都打一次接口,省流量也省后端压力。它和 前端表单验证实战 里的即时校验逻辑可以共用同一套防抖封装:校验失败提示也等用户停手再弹,体验更顺。
5.2 滚动与 resize → 节流
scroll 回调里常做位置计算、懒加载判断、读取元素尺寸——这些操作会触发 浏览器重排与重绘,高频执行直接掉帧。用节流(或 rAF 版)把频率压到每 100ms / 每帧一次,页面立刻跟手。配合懒加载,滚动时按节奏检查元素是否进入视口最稳,不会因回调爆发而漏判。
5.3 按钮防重复提交 → leading 防抖
下单、支付这类按钮,用户连点会重复提交订单。用 leading 防抖(首触发立即执行、wait 内忽略后续)或时间戳版节流都能拦住,再配合后端幂等才能真正万无一失。
六、一个合二为一的增强实现
如果既要”停止后补一次”又要”过程中限频”,可以把防抖和节流组合成”防抖但最终必执行一次”的 hybrid 函数。生产项目里更推荐直接用成熟库,避免自己踩边界 bug:
import { debounce, throttle } from 'lodash-es';
// 搜索:停下 300ms 才发请求
const onSearch = debounce((kw) => fetchSuggest(kw), 300);
// 滚动:每 100ms 最多采样一次
const onScroll = throttle(trackScroll, 100);
// 组件卸载时务必清理,防止闭包持有 timer
onSearch.cancel?.();
onScroll.cancel?.();
七、常见坑与排障
| 坑 | 原因 | 解决 |
|---|---|---|
| this 指向丢失 | 箭头函数 / 普通函数混用 | 用 fn.apply(this, args) 或箭头保留外层 this |
| 防抖后首触发无响应 | 默认 trailing 模式 | 开启 leading 模式立即执行 |
| 滚动仍卡顿 | 节流域内仍做重排 | 改用 rAF 版,或缓存读取、批量写 |
| 组件卸载后还在跑 | timer 未清理 | 调用 .cancel() 或在 useEffect 清理 |
| 等待值写死 | 不同场景 wait 混用 | 输入 300、滚动 100、resize 150 区分设置 |
八、小结
防抖与节流不是二选一,而是看你要”最后一次结果”还是”稳定的采样频率”。把它们放进你的性能工具箱,再结合 前端性能优化 里的 Lighthouse 度量,就能让高频交互既跟手又不卡。记住一句话:要最终结果用防抖,要稳定节奏用节流。当你下次面对一个”疯狂触发”的事件时,先问自己这个问题,选型就不会错。




