Skip to content

React 自定义 Hook

概述

自定义 Hook 是 React 提供的一种复用状态逻辑的方式。通过创建自定义 Hook,我们可以将组件逻辑提取到可复用的函数中,实现代码的模块化和复用。本章将学习如何创建和使用自定义 Hook。

🎣 基础自定义 Hook

简单状态管理 Hook

jsx
// 自定义 Hook:计数器
function useCounter(initialValue = 0) {
  const [count, setCount] = React.useState(initialValue);
  
  const increment = React.useCallback(() => {
    setCount(prev => prev + 1);
  }, []);
  
  const decrement = React.useCallback(() => {
    setCount(prev => prev - 1);
  }, []);
  
  const reset = React.useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);
  
  return {
    count,
    increment,
    decrement,
    reset,
    setCount
  };
}

// 使用自定义 Hook
function CounterExample() {
  const counter1 = useCounter(0);
  const counter2 = useCounter(10);
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>自定义计数器 Hook</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <h3>计数器 1</h3>
        <p>当前值: {counter1.count}</p>
        <button onClick={counter1.increment}>+1</button>
        <button onClick={counter1.decrement}>-1</button>
        <button onClick={counter1.reset}>重置</button>
      </div>
      
      <div>
        <h3>计数器 2 (初始值: 10)</h3>
        <p>当前值: {counter2.count}</p>
        <button onClick={counter2.increment}>+1</button>
        <button onClick={counter2.decrement}>-1</button>
        <button onClick={counter2.reset}>重置</button>
      </div>
    </div>
  );
}

切换状态 Hook

jsx
// 自定义 Hook:布尔值切换
function useToggle(initialValue = false) {
  const [value, setValue] = React.useState(initialValue);
  
  const toggle = React.useCallback(() => {
    setValue(prev => !prev);
  }, []);
  
  const setTrue = React.useCallback(() => {
    setValue(true);
  }, []);
  
  const setFalse = React.useCallback(() => {
    setValue(false);
  }, []);
  
  return {
    value,
    toggle,
    setTrue,
    setFalse,
    setValue
  };
}

// 使用示例
function ToggleExample() {
  const modal = useToggle(false);
  const sidebar = useToggle(true);
  const darkMode = useToggle(false);
  
  return (
    <div style={{ 
      padding: '20px',
      backgroundColor: darkMode.value ? '#333' : '#fff',
      color: darkMode.value ? '#fff' : '#333',
      minHeight: '400px'
    }}>
      <h2>切换状态演示</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <button onClick={modal.toggle}>
          {modal.value ? '关闭' : '打开'}模态框
        </button>
        <button onClick={sidebar.toggle} style={{ marginLeft: '10px' }}>
          {sidebar.value ? '隐藏' : '显示'}侧边栏
        </button>
        <button onClick={darkMode.toggle} style={{ marginLeft: '10px' }}>
          {darkMode.value ? '浅色' : '深色'}模式
        </button>
      </div>
      
      {sidebar.value && (
        <div style={{
          position: 'fixed',
          left: 0,
          top: 0,
          width: '200px',
          height: '100vh',
          backgroundColor: darkMode.value ? '#444' : '#f8f9fa',
          padding: '20px',
          borderRight: '1px solid #ddd'
        }}>
          <h3>侧边栏</h3>
          <ul>
            <li>导航项 1</li>
            <li>导航项 2</li>
            <li>导航项 3</li>
          </ul>
        </div>
      )}
      
      {modal.value && (
        <div style={{
          position: 'fixed',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          backgroundColor: 'rgba(0,0,0,0.5)',
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          zIndex: 1000
        }}>
          <div style={{
            backgroundColor: darkMode.value ? '#444' : 'white',
            padding: '30px',
            borderRadius: '8px',
            maxWidth: '400px',
            width: '90%'
          }}>
            <h3>模态框</h3>
            <p>这是一个使用自定义 Hook 控制的模态框。</p>
            <button onClick={modal.setFalse}>关闭</button>
          </div>
        </div>
      )}
    </div>
  );
}

🌐 网络请求 Hook

基础数据获取 Hook

jsx
// 自定义 Hook:数据获取
function useFetch(url, options = {}) {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  
  const fetchData = React.useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      
      // 模拟网络延迟
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      // 模拟 API 响应
      const mockData = {
        '/api/users': [
          { id: 1, name: '张三', email: 'zhangsan@example.com' },
          { id: 2, name: '李四', email: 'lisi@example.com' }
        ],
        '/api/posts': [
          { id: 1, title: '第一篇文章', content: '这是第一篇文章的内容' },
          { id: 2, title: '第二篇文章', content: '这是第二篇文章的内容' }
        ]
      };
      
      if (Math.random() > 0.8) {
        throw new Error('网络请求失败');
      }
      
      setData(mockData[url] || { message: '数据获取成功' });
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [url]);
  
  React.useEffect(() => {
    if (url) {
      fetchData();
    }
  }, [url, fetchData]);
  
  const refetch = React.useCallback(() => {
    fetchData();
  }, [fetchData]);
  
  return {
    data,
    loading,
    error,
    refetch
  };
}

// 使用数据获取 Hook
function DataFetchingExample() {
  const [endpoint, setEndpoint] = React.useState('/api/users');
  const { data, loading, error, refetch } = useFetch(endpoint);
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>数据获取演示</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <label>选择接口: </label>
        <select value={endpoint} onChange={(e) => setEndpoint(e.target.value)}>
          <option value="/api/users">用户列表</option>
          <option value="/api/posts">文章列表</option>
          <option value="/api/unknown">未知接口</option>
        </select>
        <button onClick={refetch} style={{ marginLeft: '10px' }}>
          重新获取
        </button>
      </div>
      
      {loading && (
        <div style={{ textAlign: 'center', padding: '40px' }}>
          ⏳ 加载中...
        </div>
      )}
      
      {error && (
        <div style={{
          backgroundColor: '#f8d7da',
          color: '#721c24',
          padding: '15px',
          borderRadius: '4px',
          border: '1px solid #f5c6cb'
        }}>
          ❌ 错误: {error}
        </div>
      )}
      
      {data && !loading && (
        <div style={{
          backgroundColor: '#d4edda',
          color: '#155724',
          padding: '15px',
          borderRadius: '4px',
          border: '1px solid #c3e6cb'
        }}>
          <h3>数据获取成功</h3>
          <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

高级异步 Hook

jsx
// 自定义 Hook:异步操作
function useAsync(asyncFunction, immediate = true) {
  const [status, setStatus] = React.useState('idle');
  const [value, setValue] = React.useState(null);
  const [error, setError] = React.useState(null);
  
  const execute = React.useCallback(async (...args) => {
    setStatus('pending');
    setValue(null);
    setError(null);
    
    try {
      const response = await asyncFunction(...args);
      setValue(response);
      setStatus('success');
      return response;
    } catch (err) {
      setError(err);
      setStatus('error');
      throw err;
    }
  }, [asyncFunction]);
  
  React.useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);
  
  return {
    execute,
    status,
    value,
    error,
    isPending: status === 'pending',
    isSuccess: status === 'success',
    isError: status === 'error',
    isIdle: status === 'idle'
  };
}

// 模拟异步函数
const mockApiCall = async (type) => {
  await new Promise(resolve => setTimeout(resolve, 2000));
  
  if (type === 'error') {
    throw new Error('模拟的错误');
  }
  
  return {
    type,
    data: `${type} 的数据`,
    timestamp: new Date().toISOString()
  };
};

// 使用异步 Hook
function AsyncExample() {
  const api1 = useAsync(() => mockApiCall('success'), false);
  const api2 = useAsync(() => mockApiCall('error'), false);
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>异步操作演示</h2>
      
      <div style={{ display: 'flex', gap: '20px' }}>
        <div style={{ flex: 1, border: '1px solid #ddd', padding: '15px', borderRadius: '8px' }}>
          <h3>成功场景</h3>
          <button 
            onClick={() => api1.execute('success')}
            disabled={api1.isPending}
          >
            {api1.isPending ? '请求中...' : '发起请求'}
          </button>
          
          {api1.isPending && <div>⏳ 请求中...</div>}
          {api1.isSuccess && (
            <div style={{ color: 'green', marginTop: '10px' }}>
              ✅ 成功: {JSON.stringify(api1.value, null, 2)}
            </div>
          )}
          {api1.isError && (
            <div style={{ color: 'red', marginTop: '10px' }}>
              ❌ 错误: {api1.error.message}
            </div>
          )}
        </div>
        
        <div style={{ flex: 1, border: '1px solid #ddd', padding: '15px', borderRadius: '8px' }}>
          <h3>错误场景</h3>
          <button 
            onClick={() => api2.execute('error')}
            disabled={api2.isPending}
          >
            {api2.isPending ? '请求中...' : '发起请求(会失败)'}
          </button>
          
          {api2.isPending && <div>⏳ 请求中...</div>}
          {api2.isSuccess && (
            <div style={{ color: 'green', marginTop: '10px' }}>
              ✅ 成功: {JSON.stringify(api2.value, null, 2)}
            </div>
          )}
          {api2.isError && (
            <div style={{ color: 'red', marginTop: '10px' }}>
              ❌ 错误: {api2.error.message}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

💾 本地存储 Hook

localStorage Hook

jsx
// 自定义 Hook:本地存储
function useLocalStorage(key, initialValue) {
  // 获取初始值
  const [storedValue, setStoredValue] = React.useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error('Error reading localStorage:', error);
      return initialValue;
    }
  });
  
  // 设置值
  const setValue = React.useCallback((value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error('Error setting localStorage:', error);
    }
  }, [key, storedValue]);
  
  // 删除值
  const removeValue = React.useCallback(() => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error('Error removing localStorage:', error);
    }
  }, [key, initialValue]);
  
  return [storedValue, setValue, removeValue];
}

// 使用本地存储 Hook
function LocalStorageExample() {
  const [name, setName, removeName] = useLocalStorage('user-name', '');
  const [settings, setSettings, removeSettings] = useLocalStorage('user-settings', {
    theme: 'light',
    notifications: true,
    language: 'zh-CN'
  });
  const [todos, setTodos, removeTodos] = useLocalStorage('todos', []);
  
  const addTodo = () => {
    const newTodo = {
      id: Date.now(),
      text: `待办事项 ${todos.length + 1}`,
      completed: false
    };
    setTodos([...todos, newTodo]);
  };
  
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const updateSettings = (key, value) => {
    setSettings(prev => ({ ...prev, [key]: value }));
  };
  
  return (
    <div style={{ 
      padding: '20px',
      backgroundColor: settings.theme === 'dark' ? '#333' : '#fff',
      color: settings.theme === 'dark' ? '#fff' : '#333',
      minHeight: '100vh'
    }}>
      <h2>本地存储演示</h2>
      
      {/* 用户名设置 */}
      <div style={{ marginBottom: '20px' }}>
        <h3>用户名</h3>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="输入用户名"
          style={{ 
            padding: '8px', 
            marginRight: '10px',
            backgroundColor: settings.theme === 'dark' ? '#444' : '#fff',
            color: settings.theme === 'dark' ? '#fff' : '#333',
            border: '1px solid #ddd'
          }}
        />
        <button onClick={removeName}>清除</button>
        {name && <p>欢迎,{name}!</p>}
      </div>
      
      {/* 设置 */}
      <div style={{ marginBottom: '20px' }}>
        <h3>设置</h3>
        <div style={{ marginBottom: '10px' }}>
          <label>
            主题: 
            <select 
              value={settings.theme} 
              onChange={(e) => updateSettings('theme', e.target.value)}
              style={{ marginLeft: '10px' }}
            >
              <option value="light">浅色</option>
              <option value="dark">深色</option>
            </select>
          </label>
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>
            <input
              type="checkbox"
              checked={settings.notifications}
              onChange={(e) => updateSettings('notifications', e.target.checked)}
            />
            启用通知
          </label>
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>
            语言: 
            <select 
              value={settings.language} 
              onChange={(e) => updateSettings('language', e.target.value)}
              style={{ marginLeft: '10px' }}
            >
              <option value="zh-CN">中文</option>
              <option value="en-US">English</option>
            </select>
          </label>
        </div>
        <button onClick={removeSettings}>重置设置</button>
      </div>
      
      {/* 待办事项 */}
      <div>
        <h3>待办事项 (自动保存)</h3>
        <button onClick={addTodo} style={{ marginBottom: '10px' }}>
          添加待办事项
        </button>
        <div>
          {todos.map(todo => (
            <div 
              key={todo.id} 
              style={{ 
                padding: '8px', 
                margin: '4px 0', 
                backgroundColor: settings.theme === 'dark' ? '#444' : '#f8f9fa',
                borderRadius: '4px'
              }}
            >
              <label>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => toggleTodo(todo.id)}
                />
                <span style={{ 
                  textDecoration: todo.completed ? 'line-through' : 'none',
                  marginLeft: '8px'
                }}>
                  {todo.text}
                </span>
              </label>
            </div>
          ))}
        </div>
        {todos.length > 0 && (
          <button onClick={removeTodos} style={{ marginTop: '10px' }}>
            清除所有待办事项
          </button>
        )}
      </div>
    </div>
  );
}

⚡ 性能优化 Hook

防抖 Hook

jsx
// 自定义 Hook:防抖
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = React.useState(value);
  
  React.useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

// 自定义 Hook:防抖回调
function useDebouncedCallback(callback, delay) {
  const timeoutRef = React.useRef(null);
  
  const debouncedCallback = React.useCallback((...args) => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    
    timeoutRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  }, [callback, delay]);
  
  React.useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);
  
  return debouncedCallback;
}

// 防抖演示
function DebounceExample() {
  const [searchTerm, setSearchTerm] = React.useState('');
  const [searchResults, setSearchResults] = React.useState([]);
  const [searching, setSearching] = React.useState(false);
  
  // 防抖搜索词
  const debouncedSearchTerm = useDebounce(searchTerm, 500);
  
  // 防抖搜索函数
  const debouncedSearch = useDebouncedCallback((term) => {
    if (term) {
      setSearching(true);
      // 模拟搜索 API
      setTimeout(() => {
        const mockResults = [
          `${term} 的结果 1`,
          `${term} 的结果 2`,
          `${term} 的结果 3`
        ];
        setSearchResults(mockResults);
        setSearching(false);
      }, 300);
    } else {
      setSearchResults([]);
    }
  }, 300);
  
  // 使用防抖值触发搜索
  React.useEffect(() => {
    debouncedSearch(debouncedSearchTerm);
  }, [debouncedSearchTerm, debouncedSearch]);
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>防抖搜索演示</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <input
          type="text"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="输入搜索内容..."
          style={{ 
            padding: '10px', 
            width: '300px',
            border: '1px solid #ddd',
            borderRadius: '4px'
          }}
        />
        <div style={{ fontSize: '12px', color: '#666', marginTop: '5px' }}>
          输入会在停止 500ms 后触发搜索
        </div>
      </div>
      
      {searching && (
        <div style={{ color: '#007bff' }}>
          🔍 搜索中...
        </div>
      )}
      
      {searchResults.length > 0 && (
        <div>
          <h3>搜索结果:</h3>
          <ul>
            {searchResults.map((result, index) => (
              <li key={index}>{result}</li>
            ))}
          </ul>
        </div>
      )}
      
      <div style={{ marginTop: '30px', fontSize: '14px', backgroundColor: '#f8f9fa', padding: '15px', borderRadius: '8px' }}>
        <h4>实时状态:</h4>
        <p><strong>输入值:</strong> "{searchTerm}"</p>
        <p><strong>防抖值:</strong> "{debouncedSearchTerm}"</p>
        <p><strong>搜索状态:</strong> {searching ? '搜索中' : '空闲'}</p>
      </div>
    </div>
  );
}

📝 本章小结

通过本章学习,你应该掌握了:

自定义 Hook 核心概念

  • ✅ Hook 的命名规范(use 开头)
  • ✅ 状态逻辑的提取和复用
  • ✅ 副作用的封装和管理
  • ✅ 性能优化技巧

常用 Hook 模式

  • ✅ 状态管理:useCounter、useToggle
  • ✅ 数据获取:useFetch、useAsync
  • ✅ 本地存储:useLocalStorage
  • ✅ 性能优化:useDebounce、useDebouncedCallback

最佳实践

  1. 单一职责:每个 Hook 只处理一种逻辑
  2. 依赖优化:合理使用 useCallback 和 useMemo
  3. 错误处理:包含完整的错误处理逻辑
  4. 清理资源:及时清理副作用和订阅
  5. 类型安全:使用 TypeScript 增强类型安全

继续学习下一章 - React 样式处理

本站内容仅供学习和研究使用。