原文:https://dev.to/parsajiravand/debounce-and-throttle-in-javascript-the-complete-guide-2o61(作者 @parsajiravand)
在一个未做节流的搜索框中输入"javascript",然后打开网络面板:一次 HTTP 请求对应 j,第二次对应 ja,第三次对应 jav,总共十次请求——其中九次在响应返回之前就已经毫无意义。服务器为这个功能做了十倍的无效工作,界面还会为一条用户早在两次按键前就已放弃的查询闪烁出结果。解决方案是两个小巧到看似简单得不像话的函数——防抖(debounce)和节流(throttle)——而真正让人犯难的地方不在于怎么写它们,而在于精确把握它们各自的触发时机。
你将学到什么
读完本指南后,你将能够:
- 准确解释防抖和节流之间的区别,而不是只说"两者都是让操作变慢"
- 从零写出正确的防抖和节流实现,包括 leading/trailing 边缘触发行为
- 为搜索输入、滚动处理、窗口缩放处理和按钮点击场景选对方案
- 避开这两种模式在 React 和原生 JavaScript 中都会导致的内存泄漏与闭包过期(stale-closure)问题
- 使用一个支持
cancel()和flush()的即拿即用工具函数
适用读者: 你日常写 JavaScript,曾经给元素绑定过事件监听器,并且要么为这类问题手搓过 setTimeout 补丁,要么用过 lodash 却并不完全信任它的默认行为。
目录
- 防抖和节流为什么存在
- 心智模型:一个门卫,而不是一个过滤器
- 阶段 1:从零实现防抖
- 阶段 2:从零实现节流
- 阶段 3:leading 和 trailing 边缘
- 阶段 4:cancel、flush 与清理
- 阶段 5:在 React 中使用它们
- 边界情况与陷阱
- 最佳实践
- 常见问题
- 速查表
- 核心要点
防抖和节流为什么存在
下面是一个实时搜索框的朴素写法——几乎人人都会先写出这个版本:
// 错误写法——每次按键都会发一次请求
searchInput.addEventListener("input", (event) => {
fetchResults(event.target.value); // 每次按键一个 HTTP 请求
});
以正常速度输入一个六个字母的单词,这个写法会在远不到一秒内发出六次请求,其中五次在响应返回时就已经过时。更糟的是,网络响应的返回顺序并不总是与发送顺序一致——jav 的慢响应可能比 javascript 的快响应晚到,于是界面上显示的是一堆针对用户已经替换掉的查询的陈旧结果。这个 bug 在快速手动测试中看不出来,因为你的笔记本和 API 都很快;它只会在真实用户处于真实网络环境中时暴露,到那时给人的感觉是"搜索不太稳定",而不是"搜索请求发得太频繁"。
同样形态的问题也出现在滚动和缩放处理中,只是故障模式不同。scroll 事件每秒可以触发几十次。如果处理函数做了任何不算轻量的事情——读取 getBoundingClientRect()、更新布局、跑一段业务逻辑——页面就会开始掉帧,滚动变得卡顿,尽管严格来说没有任何东西"坏掉"。
两个问题都出自同一个根因:事件源的触发频率远远高于响应实际需要运行的频率。 防抖和节流是"多久算足够久"这个问题的两种不同答案,二者不可互相替换。
心智模型:一个门卫,而不是一个过滤器
心智模型: 这两个函数都不过滤事件——每个事件仍然到达你的包装函数,每个事件仍然会执行一次检查。变的是检查放行真正工作的频率,而这两个函数用截然相反的策略来决定这件事。
- 防抖说"等安静下来"。 每次调用都会重置一个计时器。包装的函数只有在调用真正停止并持续了配置的延迟时间之后才运行。想想电梯门:每次有人走近,门就会重置它的关门计时。只有当几秒内没人靠近,它才会关上。
- 节流说"每个时间间隔内至多一次"。 它不关心调用是否还在进来——它只是拒绝让包装函数再次运行,直到距离上次运行已经过去了固定的时间。想想节拍器,或者一个不管队伍多长都每两秒放一个人进门的门卫。
这一个区别——"等待沉默"与"按固定速率均匀分配"——几乎解释了本指南其余部分的所有行为差异。当你只关心最终状态(输入完的搜索词)时,防抖是对的。当你在连续活动期间需要定期更新时(用户滚动时滚动位置应当持续更新),节流是对的。
阶段一:从零实现防抖(debounce)
最小可用的防抖函数非常短——一个闭包持有一个定时器 ID 即可:
function debounce(fn, delayMs) {
let timeoutId; // 得益于闭包,它在多次调用间持续存在
return function debounced(...args) {
clearTimeout(timeoutId); // 取消之前任何待执行的定时器
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
};
}
const debouncedSearch = debounce((query) => fetchResults(query), 300);
searchInput.addEventListener("input", (e) => debouncedSearch(e.target.value));
关键概念: 每次调用
debounced都会取消上一个待执行的定时器并重新开启一个新的。只有在 300ms 内没有新调用的情况下,fn才会真正执行——这正是字面意义上的"等待安静期"。
快速输入 "js" 时,debounced 会执行两次(每次按键一次),但 fn 不会执行——直到你停下后,它才会在你的最后一次按键 300ms 后执行一次,携带的是 query 的最终值。这就是全部机制。本指南其余内容都是对这个六行函数的变体扩展。
阶段二:从零实现节流(throttle)
节流需要追踪的是上次运行的时间,而不是是否有定时器在等待:
function throttle(fn, intervalMs) {
let lastRun = 0; // 上次 fn 真正执行的毫秒时间戳
return function throttled(...args) {
const now = Date.now();
if (now - lastRun >= intervalMs) {
lastRun = now;
fn.apply(this, args);
}
};
}
const throttledOnScroll = throttle(() => updateScrollProgress(), 100);
window.addEventListener("scroll", throttledOnScroll);
关键概念: 这个实现在第一次调用时立即执行
fn(因为此时now - lastRun可视为无限大),随后在intervalMs时间窗口内忽略所有调用,直到窗口结束,下一个到来的调用才会执行。在"冷却期"内到达的调用会被完全丢弃——不排队、不延迟、直接抛弃。
最后一个细节很关键:使用这个特定实现时,如果一批调用在冷却窗口中途停止,这批调用的最后一次调用会直接丢失——fn 不会携带最新参数做最后的执行。阶段三会修复这个问题。
阶段三:前缘与后缘(leading and trailing edges)
"前缘"指在一连串调用中的首次调用时执行;"后缘"指在这串调用结束后再执行一次,携带最新参数。阶段一的防抖是纯后缘的,阶段二的节流是纯前缘的。生产级版本通常同时支持两者,因为有时仅丢弃后缘调用(节流)或把所有调用包括第一次都推迟(防抖)并非正确的取舍:
function throttle(fn, intervalMs, { leading = true, trailing = true } = {}) {
let lastRun = 0;
let timeoutId = null;
let lastArgs = null;
return function throttled(...args) {
const now = Date.now();
const remaining = intervalMs - (now - lastRun);
lastArgs = args;
if (remaining <= 0) {
if (leading || lastRun !== 0) {
lastRun = now;
fn.apply(this, args);
}
} else if (trailing && !timeoutId) {
timeoutId = setTimeout(() => {
timeoutId = null;
lastRun = Date.now();
fn.apply(this, lastArgs);
}, remaining);
}
};
}
关键概念:
leading控制一串调用中的首次调用是否立即执行;trailing控制这串调用安静下来后是否用最后收到的参数再触发一次。lodash 的_.throttle默认配置是{ leading: true, trailing: true },而它的_.debounce默认配置是{ leading: false, trailing: true }——这正是防抖"感觉上"只在结束时触发,而节流"感觉上"一开始就触发并持续周期性执行的原因。
阶段四:取消、立即执行与清理
一个无法取消的防抖或节流函数,在其宿主消失的那一刻就会成为隐患——组件卸载、模态框关闭、请求被新请求取代。把这些控制方法直接挂到返回的函数上:
function debounce(fn, delayMs) {
let timeoutId;
function debounced(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
}
debounced.cancel = () => clearTimeout(timeoutId); // 丢弃待执行的调用
return debounced;
}
const debouncedSave = debounce(saveDraft, 500);
debouncedSave("draft text");
debouncedSave.cancel(); // "draft text" 永远不会被保存
flush() 则是镜像操作:立即执行待处理的调用,而不是等待或丢弃它——当用户在一个防抖自动保存仍处于待执行状态时显式提交表单,两者会发生写入竞争,此时 flush() 就很有用。
第 5 阶段:在 React 中使用它们
在 React 中的陷阱不在于防抖函数本身,而在于你创建它的 位置。每次渲染都创建一个新的防抖函数会破坏整个机制,因为每次渲染的闭包都无法记住上一次渲染的定时器:
// 错误写法——每次渲染都会创建一个全新的防抖函数(以及全新的定时器)
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedSearch = debounce((q) => fetchResults(q), 300); // 每次渲染都会重建
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value); // 这个实例的定时器还没触发,就被新的实例替换了
}}
/>
);
}
用 useMemo 或 useRef 只创建一次防抖函数,并在组件卸载时取消它:
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedSearch = useMemo(() => debounce((q) => fetchResults(q), 300), []);
useEffect(() => {
return () => debouncedSearch.cancel(); // 组件卸载后不再发起请求
}, [debouncedSearch]);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
关键概念: 防抖函数必须比单次渲染活得更久(只创建一次,存储在 ref 中,或用空依赖数组的
useMemo记忆化),并且必须在清理函数中显式取消;否则它挂起的定时器会在组件已卸载后仍然调用fetchResults,去操作一个已经不存在的状态。
边界情况与陷阱
- 过期闭包(Stale closures)。 如果防抖/节流内部的
fn闭包捕获了一个在多次调用之间会变化的变量(某个状态、某个 props),最终触发的调用可能会用到过期的值。useCallback/useRef模式正是为了解决 React 中的这个问题;在原生 JavaScript 中,把当前值作为参数传入,而不是依赖闭包。 - `this` 绑定。 上面手写的实现使用了
fn.apply(this, args),因此被防抖/节流的 方法 仍然能拿到正确的this。如果去掉这一层,调用debounce(obj.method, 300)()会让method内部的this静默失效。 - 防抖一个 `async` 函数并不能取消正在进行的任务。 防抖只是延迟 调用 函数;对于上一次调用已经发出的
fetch,它无能为力。如果一个慢请求可能在本应被取代之后才返回,并覆盖较新的结果,那就需要把防抖和AbortController搭配使用。 - 尾调用与组件卸载之间的竞态。 防抖的
setTimeout会保持对fn的引用,即使创建它的组件已经卸载。如果在清理时没有调用cancel(),尾调用仍然会触发,可能抛出错误(“cannot update state on an unmounted component”),或者写入一个已经不再适用的资源。 - 节流间隔与动画。 把
scroll或mousemove的节流设置为固定的毫秒间隔,可能会产生明显的卡顿,因为它没有和浏览器的绘制周期同步。凡是涉及视觉效果的操作,优先使用基于requestAnimationFrame的节流:每帧最多执行一次,而不是每 N 毫秒一次。 - 测试。 这两种模式都依赖真实时间的流逝,如果测试里用
sleep会导致不稳定。请使用假定时器(jest.useFakeTimers()/vi.useFakeTimers()),并显式地推进时间(jest.advanceTimersByTime(300)),而不是去等墙钟时间。 - 防抖延迟与感知响应速度。 对大多数用户来说,300ms 的防抖几乎感觉不到延迟;但在边输入边搜索的输入框里,一旦超过约 500ms,用户就会觉得变迟钝了,因为他们在心里已经“发出”了查询。
最佳实践(什么时候用,什么时候不用)
需要防抖的场景: 你只关心活动稳定后的最终值:边输入边搜索、自动保存、不需要每次击键都触发的表单校验、只需要最终尺寸的 resize 触发布局重算。
需要节流的场景: 你需要在持续活动 期间 周期性更新,而不只是在结束时:滚动位置跟踪、跟随 mousemove 的进度指示器、限制“用户正在输入”指示器向服务器发送心跳的频率、无限滚动触发检查。
两者都要避免的场景: 每次操作都必须有即时反馈——按钮点击、“加入购物车”、键盘快捷键。延迟或丢弃这些操作会损害用户对界面的信任,即使它在技术上更“高效”。如果点击处理函数很慢,就去修复处理函数本身,不要用防抖来包一层。
不要无意中叠加它们。 把一个已经被节流过的处理函数再包进另一个库的防抖(或反过来),是“我的滚动处理函数怎么随机卡顿”类 bug 的常见根源——每个事件源只选一种策略,并刻意设置延迟。
FAQ
debounce 和 throttle 到底有什么区别?
debounce 会等活动停止后再执行一次;throttle 则无论活动是否仍在持续,都按固定的最大频率执行。debounce 回答的是"最终状态是什么";throttle 回答的是"这件事还在持续时,定期给我更新"。
lodash 的 _.debounce 和手写实现行为有差异吗?
从功能上讲,一个正确的尾沿(trailing-edge)手写 debounce 与 _.debounce 的默认行为(leading: false, trailing: true)一致。lodash 额外提供 cancel()、flush() 和 maxWait 选项(即使在持续活动下,也限制调用最多能被延迟多久)——这些是有实际用处的增强,而不是核心算法的差异。
可以对 async 函数做 debounce 吗?
可以,但 debounce 只控制"何时调用"——它不关心 async 函数之后做了什么。如果被先前(已被取代的)调用的 promise 在较晚调用之后才 resolve,你仍然可能得到乱序的结果,除非你自己记录"这是不是最新一次调用",或用 AbortController 取消先前的请求。
处理 scroll 或 resize 时,应该用 requestAnimationFrame 代替 throttle 吗?
只要是更新视觉内容(位置、大小、透明度)的场景,答案是肯定的——requestAnimationFrame 节流将工作量限制在每次绘制一次,既更流畅,又不会超过浏览器实际能显示的工作量。基于毫秒的 throttle 仍然适用于非视觉的限速场景,比如限制 ping 分析端点的频率。
如何取消一个已经 debounce 或 throttle 的调用?
在返回的函数上挂一个 .cancel() 方法(Stage 4),然后调用它——在 setTimeout 实现中就是 clearTimeout;在 lodash 中,_.debounce 和 _.throttle 返回的函数都自带 .cancel()。
速查表
| 任务 | 代码 | 说明 |
| --- | --- | --- |
| debounce(仅尾沿) | debounce(fn, 300) | 在调用停止后 300ms 执行一次。最适合搜索/自动保存。 |
| throttle(首沿 + 尾沿) | throttle(fn, 100, { leading: true, trailing: true }) | 立即执行,然后最多每 100ms 执行一次,并在爆发结束后再补一次。 |
| 取消挂起的调用 | debounced.cancel() | 清除定时器;延迟调用永远不会执行。 |
| 立即执行挂起的调用 | debounced.flush() | 立即执行挂起的调用,而不是继续等待。 |
| 视觉/动画限速 | requestAnimationFrame 循环 | 将工作量限制为每次绘制一次;对 scroll/resize 比固定毫秒的 throttle 更流畅。 |
| React:只创建一次 | useMemo(() => debounce(fn, ms), []) | 绝不要在渲染函数体内重新创建。 |
| React:清理 | useEffect(() => () => debounced.cancel(), []) | 防止卸载后调用仍然触发。 |
// 完整模式,可直接复制使用
function debounce(fn, delayMs) {
let timeoutId;
function debounced(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
}
debounced.cancel = () => clearTimeout(timeoutId);
return debounced;
}
function throttle(fn, intervalMs, { leading = true, trailing = true } = {}) {
let lastRun = 0;
let timeoutId = null;
let lastArgs = null;
function throttled(...args) {
const now = Date.now();
const remaining = intervalMs - (now - lastRun);
lastArgs = args;
if (remaining <= 0) {
if (leading || lastRun !== 0) {
lastRun = now;
fn.apply(this, args);
}
} else if (trailing && !timeoutId) {
timeoutId = setTimeout(() => {
timeoutId = null;
lastRun = Date.now();
fn.apply(this, lastArgs);
}, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
return throttled;
}
<!-- playground:start -->
动手试试
[▶️ 打开交互式练习场 →](https://bestpractic.org/blog/weekly-debounce-and-throttle/playground)
_直接在浏览器里运行——动手调一调,亲眼看着这个概念实时反应。_
<!-- playground:end -->
核心要点
- debounce 等待静默后只执行一次;throttle 无论活动持续多久都按固定节奏执行。
- 每个事件仍然会到达包装函数——改变的是背后的"实际任务"被允许执行的频率。
- 用 debounce 处理"最终值是什么"(搜索、自动保存);用 throttle(或
requestAnimationFrame)处理"持续给我更新"(scroll、resize、拖拽)。 - 两者都需要在清理逻辑中接入
cancel()——未取消的 debounce 或 throttle 就是一次等待触发到已卸载组件上的调用。 - 包装函数只在创建时生成一次,而不是每次渲染都重新创建。
<!-- quiz:start -->
测试一下自己
觉得掌握了吗?[做一下这 8 题测验 →](https://bestpractic.org/blog/weekly-debounce-and-throttle/quiz)
_每道题都有即时反馈、提示,以及答案讲解——无论对错。_
开头提到的那个搜索框只需要改一行代码——把处理函数包进 debounce(fetchResults, 300) 里——那 10 次浪费的请求就会变成 1 次,而且是在用户真正停止输入的那一刻才发出。你自己的事件处理器里,还有哪些在白白触发超过所需 10 倍的次数?
原文:https://dev.to/parsajiravand/debounce-and-throttle-in-javascript-the-complete-guide-2o61(作者 @parsajiravand)




