人人都会AI编程

自定义 Hooks 的常见坑点

更新时间:2026-07-11

自定义 Hooks 是 React 中复用状态逻辑的利器,但因为其本质是普通函数,容易让开发者在编写时忽略 React 的运行机制,导致一些隐蔽的 bug。以下是在实际开发中最高频的几个坑点及应对方法。

1. 违反 Hooks 调用规则

自定义 Hooks 内部使用了 useStateuseEffect 等 Hooks,因此它也必须遵守 Hooks 规则:只在函数组件或自定义 Hook 的顶层调用,不能在条件、循环或嵌套函数中使用

❌ 错误示例:

function useAsyncData(shouldFetch) {
  if (shouldFetch) {
    const [data, setData] = useState(null); // 条件分支中调用,破坏链表顺序
  }
  // ...
}

✅ 正确做法:

function useAsyncData(shouldFetch) {
  // 始终在顶层声明状态
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!shouldFetch) return;
    // 数据请求逻辑...
  }, [shouldFetch]);
  
  return { data, loading };
}

原因:React 依赖 Hooks 的调用顺序来维护内部状态链表。如果某次渲染跳过了某个 Hook,顺序错乱会导致状态错位,引发难以追踪的错误。

2. 未清理的副作用(内存泄漏)

自定义 Hook 中经常使用 useEffect 处理定时器、事件监听、订阅等副作用。如果 Hook 被多个组件实例使用,或者组件频繁挂载/卸载,忘记清理副作用会导致内存泄漏。

❌ 错误示例:

function useWindowSize() {
  const [size, setSize] = useState(getSize());

  useEffect(() => {
    window.addEventListener('resize', handleResize);
    // 没有返回清理函数,组件卸载后事件监听仍在
  }, []);
}

✅ 正确做法:

function useWindowSize() {
  const [size, setSize] = useState(getSize());

  useEffect(() => {
    const handleResize = () => setSize(getSize());
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return size;
}

原则:副作用中创建的任何资源(监听器、定时器、订阅、网络请求)都要在清理函数中销毁或中断。

3. 闭包陷阱与过期引用

自定义 Hook 内部返回的回调函数如果依赖了外部的 state 或 props,但没有声明在依赖数组中,或者使用了 useCallback 但未提供正确依赖,会捕获到旧值,形成“闭包陷阱”。

❌ 错误示例:

function useCounter() {
  const [count, setCount] = useState(0);

  const logCount = useCallback(() => {
    console.log(count); // 始终打印初始值,因为依赖数组是空的
  }, []);

  return { count, increment: () => setCount(c => c + 1), logCount };
}

✅ 正确做法:

function useCounter() {
  const [count, setCount] = useState(0);

  const logCount = useCallback(() => {
    console.log(count); // 依赖 count,每次变化都会生成新的函数
  }, [count]);

  // 如果确实需要稳定引用,可以使用 ref 存储最新值
  const countRef = useRef(count);
  countRef.current = count;

  const stableLogCount = useCallback(() => {
    console.log(countRef.current); // 始终读到最新值
  }, []);

  return { count, increment: () => setCount(c => c + 1), logCount: stableLogCount };
}

经验:当自定义 Hook 返回回调函数并且这些函数会在异步回调、事件处理器等场景下被调用时,务必注意闭包过期问题。使用 ref 保存最新值是常见的“逃逸”方案。

4. 依赖项缺失或错误,导致无限循环

自定义 Hook 中的 useEffect 如果没有正确声明依赖项,可能陷入无限更新循环,直到浏览器直接卡死。

❌ 错误示例:

function useFetch(config) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(config.url).then(res => res.json()).then(setData);
  }, [config]); // config 是对象,每次渲染都是新的引用,导致效果无限执行
}

✅ 正确做法:

function useFetch(url, options) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;
    fetch(url, options).then(res => res.json()).then(d => {
      if (!cancelled) setData(d);
    });
    return () => { cancelled = true; };
  }, [url, options]); // 如果 options 由外部传入且不稳定,需要在文档中明确要求调用方稳定引用
}

更稳健的方案是使用 useRef 固定引用,或在内部对对象进行序列化比较,但这属于深度优化范畴。对于自定义 Hook 来说,更好的做法是在文档中明确说明引用稳定要求,让调用方自行负责,比如要求使用 useMemo 包裹 options

5. 返回对象未做缓存,导致下游不必要的重渲染

如果自定义 Hook 返回的是一个对象或数组,每次渲染都会生成新的引用,即使内部值未变,也会导致使用了该 Hook 的组件以及使用 React.memo/useMemo/useCallback 的子组件发生不必要的重渲染。

❌ 错误示例:

function useToggle(initial) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue(v => !v);
  return { value, toggle }; // 每次返回新对象
}

✅ 优化:

function useToggle(initial) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  // 使用 useMemo 缓存返回对象,只有值变化时才更新引用
  return useMemo(() => ({ value, toggle }), [value, toggle]);
}

虽然不一定所有 Hook 都需要这种优化,但在被大量引用或处于敏感路径时,这能避免性能问题。

6. 滥用自定义 Hooks 导致抽象不清晰

自定义 Hooks 的本质是复用状态逻辑,而不是简单的代码抽取。如果只是把几行 useState 包裹成一个函数,可能会增加理解成本而没有带来真正的价值。

❌ 过度抽象:

function useInput() {
  const [value, setValue] = useState('');
  const onChange = e => setValue(e.target.value);
  return { value, onChange };
}
// 这只是一个简单的封装,但没有复用复杂逻辑,直接写 useState 可能更清晰

✅ 有价值的使用场景:

  • 复用跨组件的副作用逻辑(如 useRequestuseAuth
  • 封装复杂的内部状态同步(如 useScrollSync
  • 对第三方库的无侵入 React 绑定(如 useQuery

原则:自定义 Hook 应该解决一个具体、通用的痛点,而不是无脑取代任何 useState 声明。当逻辑确实在多个组件中重复时再抽象为 Hook。

7. 在 Hook 内部直接修改状态对象/数组

虽然这个问题也存在于常规组件中,但在自定义 Hook 中更隐蔽。有些 Hook 内部会返回修改状态的函数,但实现时直接修改了原对象,导致 React 无法检测变化。

❌ 错误:

function useList(initial) {
  const [list, setList] = useState(initial);
  const addItem = (item) => {
    list.push(item);      // 直接修改原数组
    setList(list);        // 引用不变,React 不会重新渲染
  };
  return { list, addItem };
}

✅ 正确:

const addItem = useCallback((item) => {
  setList(prev => [...prev, item]); // 返回新数组
}, []);

不可变更新是 React 状态管理的基础,自定义 Hook 必须严格遵守。


总结:自定义 Hooks 的常见坑点大多源于对 Hooks 运行机制的疏忽。只要始终谨记:Hooks 规则、闭包与依赖、副作用清理、不可变更新和引用稳定性,就能写出健壮且可复用的逻辑。如果发现自定义 Hook 有很多隐式约束,不妨在 JSDoc 中注明注意事项,或通过 TypeScript 类型强化契约。