React 自定义Hooks

自定义Hooks让你在不编写class的情况下复用状态逻辑,是React函数组件的强大武器。

什么是自定义Hooks?

自定义Hook是一个JavaScript函数,其名称以"use"开头,函数内部可以调用其他的Hook。通过自定义Hook,可以将组件逻辑提取到可重用的函数中。

1
发现问题: 多个组件中有重复的逻辑代码
2
提取逻辑: 将重复逻辑提取到自定义Hook中
3
复用逻辑: 在不同组件中使用自定义Hook
4
保持同步: 各组件状态自动同步,逻辑一致

创建第一个自定义Hook

重复逻辑的组件

// ComponentA.js
function ComponentA() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(0);

  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={increment}>增加</button>
      <button onClick={decrement}>减少</button>
      <button onClick={reset}>重置</button>
    </div>
  );
}

// ComponentB.js - 相同的逻辑重复了!
function ComponentB() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(0);

  return (
    <div>
      <p>另一个计数: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>0</button>
    </div>
  );
}

使用自定义Hook

// useCounter.js - 自定义Hook
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

// ComponentA.js - 使用自定义Hook
function ComponentA() {
  const { count, increment, decrement, reset } = useCounter(0);

  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={increment}>增加</button>
      <button onClick={decrement}>减少</button>
      <button onClick={reset}>重置</button>
    </div>
  );
}

// ComponentB.js - 复用同一个Hook
function ComponentB() {
  const { count, increment, decrement, reset } = useCounter(10);

  return (
    <div>
      <p>另一个计数: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>0</button>
    </div>
  );
}

自定义Hook命名规范

必须use开头,这样React才能识别它为Hook并应用Hook规则检查。
命名方式 示例 是否推荐
use + 名词 useCounter, useTimer, useForm 推荐
use + 动词 useFetch, useLocalStorage, useEventListener 推荐
不以use开头 counterHook, customTimer 错误
使用下划线 use_counter 不推荐

常用自定义Hook示例

1. useLocalStorage - 持久化状态

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // 从localStorage读取初始值
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error('读取localStorage失败:', error);
      return initialValue;
    }
  });

  // 监听storedValue变化并更新localStorage
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error('写入localStorage失败:', error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

// 使用示例
function ThemeSwitcher() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <div className={`app ${theme}`}>
      <button onClick={toggleTheme}>
        切换到{theme === 'light' ? '深色' : '浅色'}主题
      </button>
    </div>
  );
}

2. useFetch - 数据获取

import { useState, useEffect, useCallback } from 'react';

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

  const fetchData = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP错误: ${response.status}`);
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [url, options]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  const refetch = () => {
    fetchData();
  };

  return { data, loading, error, refetch };
}

// 使用示例
function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useFetch(
    `https://api.example.com/users/${userId}`
  );

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error}</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>邮箱: {user.email}</p>
      <button onClick={refetch}>刷新数据</button>
    </div>
  );
}

3. useDebounce - 防抖

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

// 使用示例 - 搜索框防抖
function SearchBox() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);

  const debouncedSearchTerm = useDebounce(searchTerm, 300);

  useEffect(() => {
    if (debouncedSearchTerm) {
      // 执行搜索API调用
      fetch(`/api/search?q=${debouncedSearchTerm}`)
        .then(res => res.json())
        .then(data => setResults(data));
    } else {
      setResults([]);
    }
  }, [debouncedSearchTerm]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="输入搜索关键词..."
      />
      <ul>
        {results.map(result => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  );
}

4. useEventListener - 事件监听

import { useEffect, useRef } from 'react';

function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef();

  // 保存最新的handler到ref中
  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    // 确保element支持addEventListener
    const isSupported = element && element.addEventListener;
    if (!isSupported) return;

    const eventListener = (event) => {
      if (savedHandler.current) {
        savedHandler.current(event);
      }
    };

    element.addEventListener(eventName, eventListener);

    return () => {
      element.removeEventListener(eventName, eventListener);
    };
  }, [eventName, element]);
}

// 使用示例 - 监听窗口大小变化
function WindowSizeDisplay() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEventListener('resize', () => {
    setWindowSize({
      width: window.innerWidth,
      height: window.innerHeight
    });
  });

  return (
    <div>
      窗口尺寸: {windowSize.width} x {windowSize.height}
    </div>
  );
}

5. usePrevious - 获取上一次的值

import { useRef, useEffect } from 'react';

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

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

  return ref.current;
}

// 使用示例 - 显示变化
function CounterWithPrevious() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>当前计数: {count}</p>
      <p>上一次计数: {prevCount !== undefined ? prevCount : '无'}</p>
      <button onClick={() => setCount(count + 1)}>
        增加
      </button>
    </div>
  );
}

Hook演示区

useToggle Hook 演示

useHover Hook 演示

鼠标移上来看看效果

组合多个Hooks创建复杂Hook

import { useState, useEffect, useCallback } from 'react';

// 组合多个Hooks创建表单管理Hook
function useForm(initialValues = {}, validate = null) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 处理输入变化
  const handleChange = useCallback((name, value) => {
    setValues(prev => ({
      ...prev,
      [name]: value
    }));

    // 标记字段为已触摸
    setTouched(prev => ({
      ...prev,
      [name]: true
    }));
  }, []);

  // 处理表单提交
  const handleSubmit = useCallback((onSubmit) => {
    return async (event) => {
      if (event) event.preventDefault();

      // 验证表单
      if (validate) {
        const validationErrors = validate(values);
        setErrors(validationErrors);

        if (Object.keys(validationErrors).length > 0) {
          return;
        }
      }

      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } catch (error) {
        console.error('表单提交错误:', error);
      } finally {
        setIsSubmitting(false);
      }
    };
  }, [values, validate]);

  // 重置表单
  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
    setValues
  };
}

// 使用示例
function UserForm() {
  const validate = (values) => {
    const errors = {};
    if (!values.name) errors.name = '姓名不能为空';
    if (!values.email.includes('@')) errors.email = '邮箱格式不正确';
    return errors;
  };

  const {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleSubmit
  } = useForm({ name: '', email: '' }, validate);

  const onSubmit = async (formValues) => {
    console.log('提交表单:', formValues);
    // 调用API等操作
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          type="text"
          value={values.name}
          onChange={(e) => handleChange('name', e.target.value)}
          placeholder="姓名"
        />
        {touched.name && errors.name && <span>{errors.name}</span>}
      </div>
      <div>
        <input
          type="email"
          value={values.email}
          onChange={(e) => handleChange('email', e.target.value)}
          placeholder="邮箱"
        />
        {touched.email && errors.email && <span>{errors.email}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '提交'}
      </button>
    </form>
  );
}

测试自定义Hooks

使用React Testing Library测试Hooks

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

describe('useCounter', () => {
  test('应该正确初始化计数', () => {
    const { result } = renderHook(() => useCounter(5));

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

  test('增加计数应该工作', () => {
    const { result } = renderHook(() => useCounter());

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

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

  test('重置计数应该工作', () => {
    const { result } = renderHook(() => useCounter(10));

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

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

// 测试useFetch
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';

global.fetch = jest.fn();

describe('useFetch', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('应该正确加载数据', async () => {
    const mockData = { id: 1, name: '测试' };
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockData,
    });

    const { result } = renderHook(() => useFetch('/api/test'));

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
  });
});

优秀自定义Hook库推荐

ahooks

阿里巴巴开源的React Hooks库,包含100+实用Hooks

状态管理 副作用 DOM操作
react-use

Streamich的React Hooks集合,非常全面

工具函数 网络 动画
@tanstack/react-query

数据获取、缓存和同步的Hook库

网络请求 状态缓存
usehooks-ts

TypeScript友好的自定义Hooks集合

TypeScript DOM

TypeScript支持

import { useState, useEffect } from 'react';

// 定义Hook返回类型
interface UseLocalStorageReturn<T> {
  value: T;
  setValue: (value: T) => void;
  removeValue: () => void;
}

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

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

  const removeValue = () => {
    localStorage.removeItem(key);
    setValue(initialValue);
  };

  return { value, setValue, removeValue };
}

// 使用示例 - 类型安全
interface UserPreferences {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
}

function Preferences() {
  const {
    value: preferences,
    setValue: setPreferences
  } = useLocalStorage<UserPreferences>('preferences', {
    theme: 'light',
    language: 'zh-CN',
    notifications: true
  });

  // TypeScript会检查类型
  const toggleTheme = () => {
    setPreferences({
      ...preferences,
      theme: preferences.theme === 'light' ? 'dark' : 'light'
    });
  };

  return (
    <div>
      <p>当前主题: {preferences.theme}</p>
      <button onClick={toggleTheme}>切换主题</button>
    </div>
  );
}

最佳实践

  1. 单一职责: 每个Hook只做一件事
  2. 依赖明确: 使用useEffect的依赖数组,避免无限循环
  3. 参数默认值: 为可选参数提供合理的默认值
  4. 错误处理: 在Hook内部处理错误,避免组件崩溃
  5. 性能优化: 使用useCallback和useMemo避免不必要的重渲染
  6. 类型安全: 使用TypeScript定义清晰的类型
  7. 文档完善: 使用JSDoc为Hook添加文档注释
  8. 测试覆盖: 为Hook编写单元测试
注意: 自定义Hook的state和effect是隔离的。每次使用Hook,都会获得独立的state、effect和ref。Hook之间不会共享状态!

常见问题与解决方案

问题 原因 解决方案
Hook在条件或循环中调用 违反Hook规则 确保Hook在顶层调用,不在条件、循环或嵌套函数中
无限重渲染循环 useEffect依赖数组不正确 检查依赖项,使用useCallback/useMemo
Hook状态不同步 闭包问题 使用useRef保存可变值,或使用useReducer
类型错误 TypeScript类型定义不完善 完善泛型类型定义,使用类型断言
测试困难 Hook依赖浏览器API 使用renderHook测试,模拟浏览器API
自定义Hook交互演示控制台
输入 'help' 查看可用命令