
一句话:防抖(debounce)是「停止触发 N 毫秒后才执行」,节流(throttle)是「每 N 毫秒最多执行一次」;在 React 里封装成 Hook 的关键是「用
useRef存定时器/时间戳」「清理函数里清掉」「用useRef保存最新回调」。
一、防抖与节流的区别
防抖(debounce):
事件触发 → 重置计时 → 只有在「停止触发 N 毫秒」后才真正执行
┌─────────────────────────────────────┐
│ 输入: ● ● ● ● ● ● │
│ 执行: ↑ │ 只有最后一次触发后执行
└─────────────────────────────────────┘
节流(throttle):
事件触发 → 每 N 毫秒最多执行一次(首/尾执行可配置)
┌─────────────────────────────────────┐
│ 输入: ● ● ● ● ● ● │
│ 执行: ↑ ↑ ↑ ↑ │ 按固定间隔执行
└─────────────────────────────────────┘
| 维度 | 防抖 | 节流 |
|---|---|---|
| 执行时机 | 「停止触发后」 | 「按固定间隔」 |
| 适合 | 搜索输入、表单校验、窗口 resize 后的重算 | 滚动监听、鼠标移动、按钮防连点 |
| 是否会「丢失最后一次」 | ❌(一定会执行最后一次) | ⚠️(取决于配置,可能丢) |
| 典型等待时间 | 300~500ms | 100~200ms |
二、纯函数版(先理解原理)
// ✅ 基础防抖(纯函数版)
function debounce<T extends (...args: any[]) => void>(fn: T, delay = 300) {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: unknown, ...args: Parameters<T>) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
} as T;
}
// ⚠️ 为什么在 React 里「直接用」会有问题?
function Search() {
const [keyword, setKeyword] = useState('');
// ❌ 每次渲染都会创建一个新的防抖函数
const debouncedSearch = debounce((v: string) => search(v), 300);
// → 上一轮渲染创建的定时器「没人清理」(内存泄漏)
// → 每次输入调用的是「不同的防抖函数」→ 防抖失效(每次都立即执行)
return <input onChange={(e) => { setKeyword(e.target.value); debouncedSearch(e.target.value); }} />;
}
⭐ 三个问题:
① 每次渲染新建函数 → 上一次的定时器泄漏
② 「同一个输入」调用的是不同实例 → 防抖完全失效
③ 组件卸载时定时器没清理 → 卸载后仍在执行
✅ 解法:用 Hook 封装 —— 用 useRef 保存「定时器」与「稳定的防抖函数」
三、useDebouncedValue(值版,最常用)
import { useEffect, useState } from 'react';
/**
* 返回「防抖后的值」——value 停止变化 delay 毫秒后才更新
*/
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // ⭐ value 变化时清掉上一个定时器
}, [value, delay]);
return debounced;
}
// ✅ 使用:搜索输入
function Search() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
// ⭐ 只有防抖后的值变化才发请求(依赖 debouncedKeyword 而不是 keyword)
const { data, isLoading } = useQuery({
queryKey: ['search', debouncedKeyword],
queryFn: () => api.search(debouncedKeyword),
enabled: debouncedKeyword.length > 0,
});
const isStale = keyword !== debouncedKeyword; // ⭐ 可用来显示「正在输入」
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{isStale && <span>输入中…</span>}
{isLoading ? <Skeleton /> : <List items={data ?? []} />}
</>
);
}
⭐ 这个实现为什么正确?
① `useEffect` 每次 `value` 变化都会「清理上一个定时器 + 建新的」
→ 天然实现了「重置计时」
② `delay` 变化时也会重新计时(合理)
③ 组件卸载时清理(不会在卸载后 setState)
④ `setDebounced` 引用稳定(React 保证),不需要放进依赖
⚠️ 局限:它只适合「延迟使用某个值」的场景。
如果要「延迟执行一个副作用/请求」,用「函数版」更合适。
四、useDebouncedCallback(函数版,含保护)
import { useCallback, useEffect, useRef } from 'react';
interface DebouncedFn<Args extends unknown[]> {
(...args: Args): void;
/** 立即执行「待执行的调用」(如果有) */
flush: () => void;
/** 取消「待执行的调用」 */
cancel: () => void;
}
/**
* 返回一个「防抖后的稳定函数」——
* 引用稳定(可以安全放进依赖/传给 memo 子组件),且总是调用「最新的 fn」
*/
export function useDebouncedCallback<Args extends unknown[]>(
fn: (...args: Args) => void,
delay = 300,
options: { leading?: boolean } = {}
): DebouncedFn<Args> {
const fnRef = useRef(fn);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<Args | null>(null);
const leadingCalledRef = useRef(false);
// ⭐ 每次渲染同步最新回调(这样「引用稳定」与「读到最新 props」两不误)
useEffect(() => {
fnRef.current = fn;
});
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
leadingCalledRef.current = false;
}, []);
const flush = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (lastArgsRef.current) {
fnRef.current(...lastArgsRef.current);
lastArgsRef.current = null;
}
leadingCalledRef.current = false;
}, []);
const debounced = useCallback(
(...args: Args) => {
lastArgsRef.current = args;
// leading:第一次调用时「立即执行」
if (options.leading && !leadingCalledRef.current && !timerRef.current) {
leadingCalledRef.current = true;
fnRef.current(...args);
lastArgsRef.current = null;
}
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
timerRef.current = null;
// 如果 leading 已经执行过且没有新参数,就不再重复执行
if (lastArgsRef.current) {
fnRef.current(...lastArgsRef.current);
lastArgsRef.current = null;
}
leadingCalledRef.current = false;
}, delay);
},
[delay, options.leading]
) as DebouncedFn<Args>;
// ⭐ 卸载时清理(避免「组件卸载后仍执行」)
useEffect(() => () => cancel(), [cancel]);
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
// ✅ 使用:自动保存草稿
function Editor({ content, onSave }: { content: string; onSave: (v: string) => void }) {
const save = useDebouncedCallback((value: string) => {
onSave(value); // ⭐ 引用稳定,但内部用最新 content
}, 1000);
return (
<>
<textarea defaultValue={content} onChange={(e) => save(e.target.value)} />
<button onClick={() => save.flush()}>立即保存</button> {/* ⭐ flush */}
<button onClick={() => save.cancel()}>取消保存</button> {/* ⭐ cancel */}
</>
);
}
⭐ 三个关键设计:
① `fnRef` 保存最新回调
→ 只用 useCallback 会把「创建时的 fn」闭包住(读到旧值)
→ 用 ref 可以「引用稳定 + 读到最新」
② `timerRef` 保存定时器
→ 用 ref 而不是 useState(不需要触发渲染)
③ 返回的函数是【稳定引用】
→ 可以放进 useEffect 依赖、可以传给 memo 子组件
五、useThrottledCallback
import { useCallback, useEffect, useRef } from 'react';
interface ThrottledFn<Args extends unknown[]> {
(...args: Args): void;
cancel: () => void;
}
/**
* 返回「节流后的稳定函数」——每 delay 毫秒最多执行一次
*/
export function useThrottledCallback<Args extends unknown[]>(
fn: (...args: Args) => void,
delay = 200,
options: { leading?: boolean; trailing?: boolean } = { leading: true, trailing: true }
): ThrottledFn<Args> {
const fnRef = useRef(fn);
const lastTimeRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<Args | null>(null);
useEffect(() => {
fnRef.current = fn;
});
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
lastArgsRef.current = null;
}, []);
const throttled = useCallback(
(...args: Args) => {
const now = Date.now();
const elapsed = now - lastTimeRef.current;
const { leading = true, trailing = true } = options;
// ① 首次(或距上次执行已超过 delay)→ 立即执行
if (elapsed >= delay) {
if (leading) {
lastTimeRef.current = now;
fnRef.current(...args);
return;
}
lastTimeRef.current = now;
}
// ② 否则「安排一次尾部执行」(trailing)
if (trailing) {
lastArgsRef.current = args;
if (!timerRef.current) {
const remaining = delay - elapsed;
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (lastArgsRef.current) {
lastTimeRef.current = Date.now();
fnRef.current(...lastArgsRef.current);
lastArgsRef.current = null;
}
}, remaining);
}
}
},
[delay, options]
) as ThrottledFn<Args>;
useEffect(() => () => cancel(), [cancel]);
throttled.cancel = cancel;
return throttled;
}
// ✅ 使用:滚动监听
function BackToTop() {
const [visible, setVisible] = useState(false);
const onScroll = useThrottledCallback(() => {
setVisible(window.scrollY > 400);
}, 200);
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]); // ⭐ onScroll 引用稳定 → 不会反复绑定
return visible ? <button onClick={() => window.scrollTo({ top: 0 })}>回到顶部</button> : null;
}
⭐ 一个更简单的「节流值」版本(不常用,但知道就好):
function useThrottledValue<T>(value: T, interval = 200): T {
const [throttled, setThrottled] = useState(value);
const lastRef = useRef(Date.now());
useEffect(() => {
const now = Date.now();
if (now - lastRef.current >= interval) {
lastRef.current = now;
setThrottled(value);
} else {
const timer = setTimeout(() => {
lastRef.current = Date.now();
setThrottled(value);
}, interval - (now - lastRef.current));
return () => clearTimeout(timer);
}
}, [value, interval]);
return throttled;
}
六、什么时候用哪个版本
| 需求 | 用哪个 |
|---|---|
| 「输入停止后发请求/计算」 | useDebouncedValue(值版,最简单) |
| 「延迟执行一个副作用」(如自动保存) | useDebouncedCallback(函数版) |
| 「滚动/拖拽里限制执行频率」 | useThrottledCallback |
| 「需要立即执行/取消防抖」 | 函数版 + leading / flush / cancel |
| 「只是想让渲染降优先级」 | 不用防抖,用 useDeferredValue(见 2.10) |
⚠️ 「防抖」与「useDeferredValue」的区别(容易混):
· 防抖:**时间维度**,会「减少执行次数」(跳过中间输入)→ 适合**发请求**
· useDeferredValue:**优先级维度**,**不减少执行次数**,只是「不阻塞紧急更新」
→ 适合**同步的昂贵渲染**
· 实践上常组合:请求用防抖、渲染用 deferred(见 5.7)
七、进阶:更完整的功能
// ✅ 支持「返回值 Promise」的防抖(如防抖的搜索接口调用)
export function useDebouncedAsync<T extends (...args: any[]) => Promise<any>>(
fn: T,
delay = 300
): [(...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined>, { cancel: () => void }] {
const fnRef = useRef(fn);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resolversRef = useRef<Array<(v: any) => void>>([]);
useEffect(() => {
fnRef.current = fn;
});
const cancel = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
resolversRef.current.forEach((resolve) => resolve(undefined)); // ⭐ 取消时 resolve undefined
resolversRef.current = [];
}, []);
const debounced = useCallback(
(...args: Parameters<T>) => {
return new Promise<Awaited<ReturnType<T>> | undefined>((resolve) => {
if (timerRef.current) clearTimeout(timerRef.current);
resolversRef.current.push(resolve);
timerRef.current = setTimeout(async () => {
timerRef.current = null;
const resolvers = resolversRef.current;
resolversRef.current = [];
try {
const result = await fnRef.current(...args);
resolvers.forEach((r) => r(result));
} catch (err) {
resolvers.forEach((r) => r(undefined));
}
}, delay);
});
},
[delay]
);
useEffect(() => () => cancel(), [cancel]);
return [debounced, { cancel }];
}
八、常见坑
// ① 在组件里「直接」创建防抖函数(每次渲染新建 → 泄漏 + 失效)
const debounced = debounce(fn, 300); // ❌
// ② 用 useState 存定时器(会触发多余渲染)
const [timer, setTimer] = useState(null); // ⚠️ 应该用 useRef
// ③ 忘记清理(卸载后仍在执行 → 在已卸载组件里 setState)
useEffect(() => { /* 没 return */ }, []);
// ④ useCallback 里「闭包住了旧的回调」
const debounced = useCallback(() => fn(value), [value]); // ⚠️ value 变化 → 引用变化
// ✅ 用 useRef 保存最新回调,useCallback 依赖写 []
// ⑤ 依赖数组里放「不稳定的值」→ 防抖函数反复重建
useEffect(() => { ... }, [options]); // ⚠️ options 是对象字面量
// ⑥ 用「防抖」处理「滚动」→ 滚动停止前没有反馈(应该用节流)
// ⑦ 用「节流」处理「搜索输入」→ 仍会发多次请求(应该用防抖)
// ⑧ 忘记「防抖的延迟也要清理」(delay 变化时重新计时是正确的,但要清理旧的)
// ⑨ 「leading」与「trailing」都开 → 首次与末次都执行(有时会「重复执行」一次)
// ⑩ 认为「防抖能降低「渲染次数」」
// → 它降低「副作用/请求次数」;渲染次数取决于 state 更新(用 useDeferredValue)
// ⑪ 在「受控输入」上用防抖 → 输入框会「卡字」(受控值不能被防抖)
// ✅ 受控值保持实时(setKeyword),只对「派生计算/请求」防抖
// ⑫ 用「全局变量」存定时器(多实例互相覆盖)→ 必须用 useRef
// ⑬ 忘记「flush」(用户点「保存」时应立即执行待保存的内容)
// ⑭ 在 SSR 中访问 window/setTimeout(要判环境或放 useEffect 里)
// ⑮ 测试里用真实定时器(慢且不稳)→ 用 vi.useFakeTimers()(见 10.4)
// ⑪ 的正确做法
function Search() {
const [keyword, setKeyword] = useState(''); // ⭐ 受控值【不防抖】
const debouncedKeyword = useDebouncedValue(keyword, 300); // ⭐ 只对「使用」防抖
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<Results keyword={debouncedKeyword} />
</>
);
}
// ⑫ 的说明
let timer: number | null = null; // ❌ 模块级
function useBad() {
useEffect(() => {
timer = setTimeout(fn, 300); // 两个组件实例会互相覆盖
}, []);
}
function useGood() {
const timerRef = useRef<number | null>(null); // ✅ 每个实例独立
useEffect(() => {
timerRef.current = window.setTimeout(fn, 300);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, []);
}
九、面试延伸
- 「手写一个
useDebounce」
两种形式:① 值版(最常用)——useDebouncedValue(value, delay):用一个useState存「防抖后的值」,在useEffect里setTimeout延迟更新,清理函数clearTimeout(这样value每次变化都会「清掉上一个定时器、建新的」→ 天然实现「重置计时」);② 函数版——useDebouncedCallback(fn, delay):用useRef保存定时器与最新回调,返回一个useCallback包裹的稳定函数。关键点是「定时器放 ref 不放 state」「回调放 ref 保证读到最新」「卸载时清理」。 -
「为什么不能在组件里直接
debounce(fn, 300)?」
因为「每次渲染都会创建一个新的防抖函数实例」(debounce内部用闭包保存了定时器)。后果有三:① 防抖失效——每次调用的是「不同的实例」,各自有独立的定时器,所以「每次都立即执行」(等于没防抖);② 内存泄漏——上一轮渲染创建的定时器没人清理;③ 卸载后仍执行——旧实例的定时器会在组件卸载后触发(可能导致「在已卸载组件里 setState」)。所以必须用 Hook 把「定时器」与「防抖函数」提升到「跨渲染存活」的地方(useRef)。 -
「为什么「回调」要放在
useRef里?」
为了同时满足两个需求:① 返回的函数引用稳定(可以安全放进useEffect的依赖数组、或作为 props 传给React.memo子组件而不破坏优化);② 调用时读到最新的回调(如果直接用useCallback(fn, [fn]),那么fn一变化引用就变,稳定性就没了;如果用useCallback(fn, []),则会闭包住创建时的fn,读到旧值)。所以标准做法是「用useRef保存最新fn(在无依赖的 effect 里每次渲染更新),然后useCallback依赖写[]」。 -
「防抖和节流分别用在哪?」
① 防抖:搜索输入(停止输入后才请求)、表单校验(输入完成后校验)、窗口 resize 后的重算、自动保存草稿——特点是「只关心最终状态」;② 节流:滚动监听(如「回到顶部」按钮的出现、滚动位置上报)、鼠标移动(拖拽跟随、画布绘制)、按钮防连点——特点是「需要持续反馈但频率可控」。一句话:「防抖等停止、节流按间隔」。 -
「防抖和
useDeferredValue有什么区别?」
两个完全不同的维度:① 防抖是「时间维度」——通过「延迟固定毫秒」来减少执行次数(跳过中间输入),适合「发请求」(避免每次输入都请求);②useDeferredValue是「优先级维度」——不减少执行次数,只是让「非紧急的更新」不阻塞「紧急更新」(如输入回显),适合「同步的昂贵渲染」。实践上常组合使用:请求用防抖(减少网络请求),渲染用 deferred(保持输入流畅)。 -
「为什么说『受控输入不能被防抖』?」
因为「受控输入的值必须实时更新」——<input value={debouncedKeyword} onChange={setKeyword} />会让「输入框的显示」也延迟,表现为「打字卡顿/掉字」(用户打了一个字符,要等 300ms 才显示出来)。正确做法是:受控值保持实时(setKeyword(e.target.value)),只对「派生计算或请求」做防抖——即「keyword实时,useDebouncedValue(keyword)用于请求」。 -
「
leading/trailing/flush/cancel分别是什么?」
leading——是否在「第一次调用时立即执行」(默认 false,防抖通常只执行末次);trailing——是否在「等待结束后执行」(默认 true);flush——立即执行「当前待执行的调用」(如用户点「保存」时不想等防抖)、cancel——取消「待执行的调用」。这些选项让防抖/节流更灵活(如「按钮防连点」用leading: true更符合直觉;「自动保存」需要flush支持「立即保存」)。 -
「怎么测试防抖 Hook?」
三条:① 用 fake timers——vi.useFakeTimers()+vi.advanceTimersByTime(300)(避免真的等 300ms,测试又快又稳);② 断言「中间状态不触发」——快进一半时间时断言「还没被调用」,快进完整时间后断言「被调用一次」;③ 断言「上一次被取消」——连续触发多次后只应执行一次;④ 用renderHook(Testing Library)测试 Hook 本身(不需要渲染组件)。测试完记得vi.useRealTimers()。
一句话速记
防抖 = 停止触发 N 毫秒后执行(搜索输入/表单校验/自动保存,只关心最终状态),节流 = 每 N 毫秒最多一次(滚动/鼠标移动/防连点,需要持续反馈);在 React 里必须封装成 Hook(否则每次渲染新建实例 → 防抖失效 + 定时器泄漏);
useDebouncedValue(值版,useEffect+clearTimeout清理,最简单常用)、useDebouncedCallback(函数版,useRef存定时器、useRef保存最新回调、useCallback依赖写[]保证引用稳定、卸载时cancel);常用选项是leading/trailing/flush/cancel;受控输入的值不能防抖(会掉字)——只对「派生计算/请求」防抖;别用 useState 存定时器(用useRef)、别用模块级变量(多实例冲突);防抖(时间维度,减少次数)≠useDeferredValue(优先级维度,不减少次数),两者常组合。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!