前端开发··2 阅读·预计 12 分钟

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/removeref 桥接 + 依赖数组只放常量

总结

自定义 Hook 的本质是「对 React 生命周期的语义封装」。三个铁律:

  1. 所有需要接收外部变化值的回调都必须通过 ref 桥接。 闭包不会自己更新,只有 ref 的 .current 可以。
  2. useEffect 的 cleanup 函数不是可选项,是硬性要求。 定时器、事件监听、网络请求、订阅——任何一种副作用的创建都必须有对应的销毁路径。
  3. 更新 state 时优先考虑函数式更新。 它能让你在零依赖的情况下获取最新 state,是摆脱依赖数组困境的最轻量手段。

下次 Code Review 看到空依赖数组的 useEffect,直接打回去。

0 评论

评论区

登录 后参与评论