如何设计自定义 Hook?有哪些最佳实践?

一、是什么

自定义 Hook 是以 use 开头的 JavaScript 函数,内部可以调用其他 Hooks。它是 React 复用状态逻辑的核心机制,不同于组件共享 UI,自定义 Hook 共享的是状态逻辑

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

每次调用自定义 Hook 都会获得独立的状态副本。两个组件同时调用 useOnlineStatus(),各自维护独立的 isOnline 状态——共享的是逻辑,不是状态本身。

二、设计原则

1. 单一职责

每个 Hook 只做一件事,职责越单一越容易复用和测试:

// 好:职责单一
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item !== null ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      console.warn(`Failed to save ${key} to localStorage`);
    }
  }, [key, value]);

  return [value, setValue];
}

// 差:做了太多事
function useUserFormWithValidationAndStorage() {
  // ... 表单状态 + 校验 + 持久化 + 提交,应该拆分
}

2. 接口设计要直观

返回值的约定:

  • 单个值:直接返回
  • 一对值(state + setter):返回数组 [value, setValue],类似 useState
  • 多个值:返回对象 { data, loading, error }
// 返回数组——调用者可自由命名
const [name, setName] = useLocalStorage('name', '');
const [theme, setTheme] = useLocalStorage('theme', 'dark');

// 返回对象——字段名明确
const { data, loading, error, refetch } = useFetch('/api/users');

3. 参数设计

简单参数直接传递,复杂配置用 options 对象:

function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}

function useFetch(url, options = {}) {
  const { method = 'GET', headers = {}, enabled = true } = options;
  // ...
}

三、实用自定义 Hook 示例

useDebounce — 防抖

function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}

function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetchSearchResults(debouncedQuery);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

useFetch — 数据请求

function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
  });

  const refetch = useCallback(() => {
    setState(prev => ({ ...prev, loading: true, error: null }));
    return fetchData();
  }, [url]);

  const fetchData = useCallback(async () => {
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json();
      setState({ data: json, loading: false, error: null });
    } catch (err) {
      setState({ data: null, loading: false, error: err });
    }
  }, [url]);

  useEffect(() => {
    let cancelled = false;
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(json => {
        if (!cancelled) setState({ data: json, loading: false, error: null });
      })
      .catch(err => {
        if (!cancelled && err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err });
        }
      });

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

  return { ...state, refetch };
}

useMediaQuery — 响应式断点

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);

    mql.addEventListener('change', handler);
    setMatches(mql.matches);

    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

function Layout() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');

  return (
    <div className={`${isMobile ? 'mobile' : 'desktop'} ${prefersDark ? 'dark' : 'light'}`}>
      {isMobile ? <MobileNav /> : <DesktopNav />}
    </div>
  );
}

usePrevious — 获取上一次的值

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>当前:{count},上一次:{prevCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

useClickOutside — 点击外部检测

function useClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) return;
      handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}

function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useClickOutside(ref, () => setOpen(false));

  return (
    <div ref={ref}>
      <button onClick={() => setOpen(o => !o)}>菜单</button>
      {open && <ul><li>选项 1</li><li>选项 2</li></ul>}
    </div>
  );
}

四、命名规范

规范示例
use 开头useAuthuseFetch
动词描述行为useToggleuseDebounce
返回布尔值用 is/hasuseIsOnlineisOnline
避免泛化命名useUserPermissions 而非 useData

use 开头不仅是约定,也是 React 和 ESLint 插件识别 Hook 的依据。如果函数名不以 use 开头,React 不会检查其内部的 Hooks 调用规则。

五、测试自定义 Hook

使用 @testing-library/reactrenderHook

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  return { count, increment, decrement, reset };
}

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

test('should reset to initial value', () => {
  const { result } = renderHook(() => useCounter(10));

  act(() => {
    result.current.increment();
    result.current.increment();
    result.current.reset();
  });

  expect(result.current.count).toBe(10);
});

六、常见的规则与约束

  1. 不要在条件/循环中调用 Hooks——和内置 Hooks 一样的规则
  2. 只能在函数组件或自定义 Hook 中调用
  3. 不要在 Hook 中返回 JSX——那应该是组件的职责
  4. 避免过度抽象——如果一段逻辑只在一个组件使用且不复杂,就地编写即可
  5. 注意闭包陷阱——确保依赖数组完整,或使用 useRef 持有最新值

七、总结

自定义 Hook 是 React 函数式编程范式的精髓。它让我们把组件的"做什么"(UI)和"怎么做"(逻辑)分离开来。好的自定义 Hook 应该具备:

  • 可组合性:多个简单 Hook 可以像函数一样组合
  • 可测试性:纯逻辑,不依赖渲染层
  • 可复用性:跨组件、跨项目共享

设计 Hook 时,优先思考"调用者需要什么",然后反推内部实现。接口先行,实现在后。