防抖为什么会失效?React 面试里的闭包和定时器
很多人手写防抖时,定时器逻辑本身没错,但一放进 React 组件,点击几次还是会发出几次请求。
我更愿意先问一句:这个 debounce 实例在每次 render 之后是不是还是同一个?

先看最容易踩的写法
tsx
function SearchBox() {
const [keyword, setKeyword] = useState('');
const search = debounce((value: string) => {
fetch('/api/search?q=' + value);
}, 300);
return (
<input
value={keyword}
onChange={event => {
setKeyword(event.target.value);
search(event.target.value);
}}
/>
);
}
debounce 每次调用都会创建一个新对象。输入一个字符会触发 setKeyword,组件重新渲染,下一次输入拿到的已经是另一个 debounce。上一个实例手里的 timer 没有被新实例接管,于是"连续输入只执行一次"这个前提被破坏了。
这不是闭包把值"弄丢"这么简单,根因是实例生命周期和组件 render 生命周期不一致。
实例稳定之后,还会遇到旧闭包
把实例挪到 useMemo 里能解决重复创建:
tsx
const search = useMemo(
() =>
debounce((value: string) => {
fetch('/api/search?q=' + value);
}, 300),
[],
);
但真实业务里回调通常会读组件状态、鉴权信息或请求函数。依赖数组写空,回调就会一直闭包住第一次 render 的值;依赖写满,又会随着依赖变化重建 debounce,之前的 timer 仍可能被丢下。
这里我会把"稳定的定时器"和"最新的业务回调"拆开:
tsx
function useDebouncedCallback<TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
delay: number,
) {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
return useCallback((...args: TArgs) => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
callbackRef.current(...args);
}, delay);
}, [delay]);
}
调用时:
tsx
const search = useDebouncedCallback((value: string) => {
fetch('/api/search?q=' + value);
}, 300);
<input value={keyword} onChange={event => {
setKeyword(event.target.value);
search(event.target.value);
}} />
这个 Hook 的关键有两个:
timerRef让定时器跨 render 保持同一份状态。callbackRef在每次提交后更新,真正执行时读取的是最新回调。
React 官方文档也提醒过,Effect 的清理函数会在组件卸载前执行;函数依赖如果每次 render 都变,Effect 就会被反复触发。这里把 timer 的清理放在卸载阶段,避免组件离开页面后还执行搜索。
面试官继续追问时
"把 debounce 包进 useMemo 就够了吗?"
不一定。只要回调需要读取会变化的状态,就要处理旧闭包;只要 delay 会变化,就要决定是取消旧 timer,还是让旧 timer 用旧延迟完成。这个取舍应该在 Hook 的 API 里说清楚。
"为什么不用 useCallback 包住 debounce?"
useCallback 只缓存函数引用,不会替你管理 debounce 内部的 timer。它能让返回给子组件的调用函数稳定,但 timer 仍然要放在 useRef 或专门的 debounce 实例里。
"组件卸载时一定要取消吗?"
搜索建议取消。否则 timer 到点后仍可能发请求,回调还会访问已经离开的页面逻辑。写 Hook 时把 clearTimeout 放进 cleanup,测试时用 fake timers 检查这一点。
比如在组件卸载前输入了"react",300 毫秒内切走页面,网络面板里不应再出现这条搜索请求。