前端高频事件如 scroll、resize、input、click 很容易在短时间内被大量触发,如果不加以控制,可能导致服务端压力激增、页面卡顿甚至逻辑错误。防抖和节流是两种经典的时间控制方案,但很多开发者对它们的区别和使用边界仍然模糊。本文从原理出发,手写实现,并封装适用于 React 的自定义 Hook,帮你彻底搞懂并在项目中正确落地。

一、防抖(Debounce)与节流(Throttle)的本质区别
防抖 :事件被触发后,等待一段时间,如果在这段时间内事件又被触发,则重新计时。只有在最后一次触发后的一段时间内没有再次触发 ,才执行目标函数。
典型场景 :搜索框输入联想、窗口 resize 结束后重新计算布局。
节流 :无论事件触发有多频繁,目标函数在固定时间间隔内最多只执行一次 。
典型场景:滚动加载更多、按钮防重复点击(但要配合状态防重)。
- 防抖是"你一直点,我就不理你,等你停下来我才干活"。
- 节流是"你尽管点,我每秒钟最多干一次"。
二、手写防抖和节流
1. 防抖(Debounce)
javascript
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
使用示例:
javascript
const handleInput = debounce((e) => {
fetch(`/api/suggest?q=${e.target.value}`);
}, 300);
input.addEventListener('input', handleInput);
如果需要立即执行一次 (比如按钮点击第一次立即响应),可以增加一个 immediate 参数:
javascript
function debounce(fn, delay = 300, immediate = false) {
let timer = null;
return function (...args) {
const callNow = immediate && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
};
}
2. 节流(Throttle)
时间戳版本(第一次立即执行):
javascript
function throttle(fn, interval = 200) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
定时器版本(最后一次也会执行):
javascript
function throttle(fn, interval = 200) {
let timer = null;
return function (...args) {
if (!timer) {
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, interval);
}
};
}
实际中常用时间戳版本,保证第一次立即执行,适合滚动加载等需要即时响应的场景。
三、在 React 中封装自定义 Hook
在 React 函数组件中,普通的防抖/节流函数会遇到闭包问题------防抖函数内部捕获的 state 是旧值。需要用 useRef 保持最新的回调引用,或者配合 useCallback 进行封装。
1. useDebounce(对值进行防抖)
将频繁变化的值进行防抖,常用于搜索关键词:
tsx
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
useEffect(() => {
if (debouncedKeyword) {
fetch(`/api/search?q=${debouncedKeyword}`);
}
}, [debouncedKeyword]);
}
这种方式将防抖逻辑和副作用分离,非常清晰。
2. useDebouncedCallback(防抖回调)
如果需要直接得到一个防抖后的函数,并且函数内部能访问到最新的 state,可以用 useRef 保存回调,避免过期闭包:
tsx
import { useRef, useCallback } from 'react';
function useDebouncedCallback(fn: (...args: any[]) => void, delay: number) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fnRef = useRef(fn);
fnRef.current = fn; // 每次渲染保持最新
return useCallback(
(...args: any[]) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
fnRef.current(...args);
}, delay);
},
[delay]
);
}
3. useThrottle(对值进行节流)
与 useDebounce 类似,保证在一定间隔内值只变化一次:
tsx
function useThrottle<T>(value: T, interval: number): T {
const [throttledValue, setThrottledValue] = useState(value);
const lastUpdated = useRef(Date.now());
useEffect(() => {
const now = Date.now();
if (now - lastUpdated.current >= interval) {
lastUpdated.current = now;
setThrottledValue(value);
} else {
const timer = setTimeout(() => {
lastUpdated.current = Date.now();
setThrottledValue(value);
}, interval - (now - lastUpdated.current));
return () => clearTimeout(timer);
}
}, [value, interval]);
return throttledValue;
}
4. useThrottledCallback(节流回调)
用于函数调用频率限制,例如滚动加载,内部同样使用 ref 保持最新回调:
tsx
function useThrottledCallback(fn: (...args: any[]) => void, interval: number) {
const lastTimeRef = useRef(0);
const fnRef = useRef(fn);
fnRef.current = fn;
return useCallback(
(...args: any[]) => {
const now = Date.now();
if (now - lastTimeRef.current >= interval) {
lastTimeRef.current = now;
fnRef.current(...args);
}
},
[interval]
);
}
四、真实场景落地
1. 搜索框输入联想(防抖值)
tsx
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/suggest?q=${debouncedQuery}`)
.then(res => res.json())
.then(setSuggestions);
}, [debouncedQuery]);
2. 按钮防重复点击(防抖回调)
tsx
const handleSubmit = useDebouncedCallback(async () => {
await api.submit(formData);
message.success('提交成功');
}, 1000);
return <Button onClick={handleSubmit}>提交</Button>;
注意:如果提交是异步且有 loading 状态,更推荐使用状态控制(disabled={loading})来从 UI 层面阻止重复点击,防抖只能作为辅助。
3. 滚动加载更多(节流回调)
tsx
const handleScroll = useThrottledCallback(() => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
loadMore();
}
}, 200);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
五、注意事项与常见坑
- 过期闭包 :在直接使用普通防抖函数时,如果内部读取了 React state,需要使用
ref保持最新值,否则会读到旧的 state。 - 防抖时间设置:搜索输入通常 200-500ms;按钮防重复 500-1000ms;窗口 resize 300-500ms。
- 节流间隔设置:滚动 100-200ms;鼠标移动 50-100ms。
- 清理定时器:在组件卸载时务必清除定时器,否则会导致内存泄漏或尝试更新已卸载组件的状态。
- 防抖与节流的选择:如果关注的是"连续操作停止后的最终结果",用防抖;如果关注的是"操作过程中的平滑控制",用节流。
六、总结
防抖和节流是前端性能优化的基石,它们从时间维度上控制了昂贵操作的执行频率。在 React 中,将它们封装为 Hook 可以使代码更简洁、更符合声明式的编程习惯。