人人都会AI编程

8.4 自定义 Hooks 设计与封装

更新时间:2026-07-11

自定义 Hook 是 React 中复用状态逻辑的核心手段。它允许你将组件内的有状态逻辑(如数据获取、表单处理、订阅、计时器等)提取到可复用的函数中,从而让组件更专注于渲染,逻辑更清晰、可测试。

8.4.1 什么是自定义 Hook

自定义 Hook 本质上是一个函数,其名称以 use 开头,内部可以调用其他 Hook(如 useStateuseEffectuseRef 等)。它遵循 React Hook 的所有规则,但可以自由组合参数和返回值,封装任意复杂的逻辑。

function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

调用方只需:

function HomePage() {
  useDocumentTitle('首页 - 我的应用');
  return <div>...</div>;
}

自定义 Hook 让“设置文档标题”这一逻辑可以在任何组件中零成本复用,而组件本身不受影响。

8.4.2 封装原则与命名规范

命名规范

  • 必须以 use 开头,这是 React 用来校验 Hook 规则的约定。
  • 名称应描述功能而非实现,如 useFetchuseMediaQueryuseLocalStorage

封装原则

  1. 单一责任:一个自定义 Hook 只做一件事。如果一个 Hook 既请求数据又管理分页,考虑拆分为 useFetch + usePagination
  2. 可配置性:通过参数让行为可定制,例如请求的 URL、防抖的延迟时间等。
  3. 返回值精简:返回足够少但必要的数据和方法。典型返回一个对象,支持解构。
  4. 无副作用泄漏:确保 useEffect 等副作用有清理逻辑,避免内存泄漏。
  5. 纯逻辑,不渲染:自定义 Hook 返回的是状态和更新函数,不返回 JSX。UI 交由组件负责。

8.4.3 从业务组件中提取自定义 Hook

常见的需求是“一个按钮,点击后发送请求,需要 loading 状态和错误处理”。如果直接在组件中写,代码会臃肿且不可复用:

// ❌ 耦合在组件中
function SubmitButton() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async () => {
    setLoading(true);
    try {
      await api.submit();
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return <button onClick={handleSubmit} disabled={loading}>提交</button>;
}

提取为 useAsync

function useAsync(asyncFn) {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = useCallback(async (...args) => {
    setLoading(true);
    setError(null);
    try {
      const result = await asyncFn(...args);
      return result;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setLoading(false);
    }
  }, [asyncFn]);

  return { loading, error, execute };
}

复用后,组件变得极其简洁:

function SubmitButton() {
  const { loading, error, execute } = useAsync(api.submit);
  return (
    <div>
      <button onClick={execute} disabled={loading}>提交</button>
      {error && <p>{error}</p>}
    </div>
  );
}

8.4.4 常见通用 Hooks 封装示例

1. useLocalStorage:持久化状态

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

用法:const [theme, setTheme] = useLocalStorage('theme', 'light');

2. useDebounce:防抖输入

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

用于搜索输入:const debouncedKeyword = useDebounce(keyword, 500);,然后根据 debouncedKeyword 发起请求。

3. useMediaQuery:响应式查询

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

用法:const isMobile = useMediaQuery('(max-width: 768px)');

4. usePrevious:获取上一轮渲染的值

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

常用于比较前后 Props 变化,但注意返回值在初次渲染时为 undefined

5. useIsMounted:检查组件是否仍挂载

function useIsMounted() {
  const isMounted = useRef(false);
  useEffect(() => {
    isMounted.current = true;
    return () => {
      isMounted.current = false;
    };
  }, []);
  return isMounted;
}

避免在已卸载的组件上设置状态。

8.4.5 自定义 Hooks 的常见坑点与解决

1. 闭包过期(Stale Closure)

当自定义 Hook 内部使用了外部变量或状态,但没有在依赖数组中声明,可能导致使用了旧的引用。

function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay);
    return () => clearInterval(id);
  }, [delay]); // ❌ 缺少 callback 依赖
}

解法:将 callback 用 useRef 保存,避免需要其作为依赖。

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);
  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

2. 过度抽象

不要为了“DRY”而创建只有一处使用的自定义 Hook。如果一个 Hook 没有明显的复用价值,或者其抽象使得逻辑难以理解,保持内联在组件中可能更好。等待第二次复用时再提取。

3. 返回值混乱

如果一个 Hook 返回了太多状态和方法,调用方难以理解。尽量返回对象而非数组(除非明确像 useState 那样解构),并且保持接口稳定。

4. 副作用清理不完整

useEffect 中订阅、添加事件监听、设置定时器,务必返回清理函数。自定义 Hook 内部同样适用这条规则,否则在组件卸载后可能发生状态更新报错。

8.4.6 设计一个健壮的自定义 Hook:useFetch 示例

一个封装数据请求的 Hook 需要考虑:加载、错误、取消竞态、缓存、自动重取等。这里给出一个基础但实用的版本:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    const fetchData = async () => {
      setLoading(true);
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error('Network error');
        const json = await response.json();
        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    };
    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

关键设计:cancelled 标志避免竞态下旧请求覆盖新请求的状态。调用方只需:

const { data, loading, error } = useFetch('/api/user');

8.4.7 通用业务 Hooks 封装示例

真实项目中常见的自定义 Hook 往往与业务逻辑相关,例如权限检查、日志上报、表单校验等。

function usePermission(requiredPermission) {
  const { user } = useUser(); // 假设有获取当前用户的 Hook
  return user?.permissions?.includes(requiredPermission) ?? false;
}

这样任何组件都可以通过 const canEdit = usePermission('edit') 干净地获取权限状态。

8.4.8 最佳实践总结

  • 从组件中提取,而不是凭空设计:当发现多个组件有相似的状态逻辑时,再抽象成 Hook。
  • 保持函数纯净:自定义 Hook 自身应该是纯函数?不,内部可以使用 Hooks,但应确保没有隐式外部依赖,所有依赖都应通过参数或内部 Hook 显式声明。
  • 测试友好:自定义 Hook 可以独立于组件进行测试,利用 renderHook 方法(来自 @testing-library/react)验证行为。
  • 使用 TypeScript 标注类型:明确参数和返回值类型,方便 IDE 提示和调用方理解。

自定义 Hook 是 React 组合模式的高阶体现,它将状态逻辑从组件中剥离,使得代码结构更扁平、复用性更强。掌握了自定义 Hook 的设计,你就能够构建一套抽象良好、可扩展的应用逻辑层。