React 自定义 Hook 的依赖黑洞:六个被忽视的闭包过期与内存泄漏场景
引子:你的 useDebounce 可能从来没有生效过
先看一个几乎所有项目里都出现过的 Hook:
// ❌ 反例:闭包过期——回调引用永远停留在首次渲染
function useDebounce(fn: () => void, delay: number) {
const timerRef = useRef<number>();
return () => {
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(fn, delay);
};
}
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedSearch = useDebounce(() => {
fetch(`/api/search?q=${query}`); // query 永远是 ''
}, 300);
return <input onChange={(e) => { setQuery(e.target.value); debouncedSearch(); }} />;
}
问题在哪?useDebounce 首次调用时闭包捕获了 query = ''。之后 query 更新了,但 fn 的闭包引用从未更新——因为没有依赖数组,也没有用 ref 做桥梁。
正确做法是用 useRef 保存最新回调,用 useCallback 返回稳定的函数引用:
// ✅ 正例:ref 桥接 + 稳定引用,永远执行最新回调
function useDebounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
const fnRef = useRef(fn);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
// 同步最新回调到 ref,不触发额外渲染
fnRef.current = fn;
return useCallback((...args: Parameters<T>) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fnRef.current(...args), delay);
}, [delay]); // 只依赖 delay,返回的函数引用稳定
}
同时做了两件事:依赖数组只有 delay,返回的 debounced 函数引用稳定;fnRef.current = fn 在每次渲染时同步,确保 setTimeout 触发时执行的是最新版回调。
陷阱二:useEventListener 的残留监听
窗口事件、document 事件是单例资源,泄漏一次 = 全局污染:
// ❌ 反例:每次重新绑定但从不清理,卸载后事件仍在触发
function useWindowResize(handler: () => void) {
useEffect(() => {
window.addEventListener('resize', handler);
// 缺少 cleanup:没有 removeEventListener
});
}
标准答案:返回 cleanup 函数,并确保 handler 变化时能正确替换:
// ✅ 正例:ref 保存最新 handler + cleanup 解绑
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const listener = (e: WindowEventMap[K]) => handlerRef.current(e);
window.addEventListener(event, listener);
return () => window.removeEventListener(event, listener);
}, [event]); // handler 变化不会导致重新绑定,只通过 ref 桥接
}
handlerRef 在每次渲染时更新,但 useEffect 只依赖 event——意味着不会频繁 addEventListener / removeEventListener,性能与正确性兼得。
陷阱三:useInterval 的闭包过期
setInterval 与 React 的状态更新天然矛盾:
// ❌ 反例:count 被闭包锁定,始终为 0
function useCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count 永远 === 0
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖 = 闭包永久冻结在初始值
}
解法是用函数式更新,让 React 自身提供最新 state:
// ✅ 正例:函数式 setState 不依赖闭包中的 count
function useCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // 直接从 React 内部队列获取最新值
}, 1000);
return () => clearInterval(id);
}, []);
}
函数式更新 prev => prev + 1 绕过了闭包限制。React 保证 prev 始终是队列中最新的 state 值。
陷阱四:useFetch 的竞态条件
快速切换 tab 或连续搜索时,旧的网络请求可能在新请求之后返回,导致 UI 展示过期数据:
// ❌ 反例:没有处理竞态,旧请求的响应可能覆盖新结果
function useFetch(url: string) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}, [url]);
return data;
}
用 AbortController + cleanup 消除竞态:
// ✅ 正例:cleanup 中 abort,确保只有最新请求的结果被提交
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then((json: T) => { if (!cancelled) setData(json); })
.catch((err: Error) => {
if (err.name !== 'AbortError' && !cancelled) setError(err);
});
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
return { data, error };
}
两层保险:cancelled 标志位处理已经返回的 Promise,AbortController 从网络层终止还在飞行中的请求。
陷阱速查
| 陷阱 | 根因 | 修复 |
|---|---|---|
| 闭包过期 | 回调引用未通过 ref 桥接 | fnRef.current = fn + useCallback |
| 事件泄漏 | useEffect 缺少 cleanup | 返回 removeEventListener |
| 定时器冻结 | 空依赖数组中闭包锁定 state | 函数式更新 prev => prev + 1 |
| 竞态请求 | 旧请求后于新请求返回 | AbortController + cancelled 标志位 |
| 重复绑定 | handler 变化导致频繁 add/remove | ref 桥接 + 依赖数组只放常量 |
总结
自定义 Hook 的本质是「对 React 生命周期的语义封装」。三个铁律:
- 所有需要接收外部变化值的回调都必须通过 ref 桥接。 闭包不会自己更新,只有 ref 的
.current可以。 useEffect的 cleanup 函数不是可选项,是硬性要求。 定时器、事件监听、网络请求、订阅——任何一种副作用的创建都必须有对应的销毁路径。- 更新 state 时优先考虑函数式更新。 它能让你在零依赖的情况下获取最新 state,是摆脱依赖数组困境的最轻量手段。
下次 Code Review 看到空依赖数组的 useEffect,直接打回去。
评论区
登录 后参与评论