引言
React Hooks 已成為現代 React 開發的核心范式,而自定義 Hook 則為我們提供了強大的代碼復用機制。
自定義 Hook 的基礎原理
自定義 Hook 本質上是一種函數復用機制,它允許我們將組件邏輯提取到可重用的函數中。與傳統的高階組件(HOC)和 render props 模式相比,Hook 提供了更直接的狀態共享方式,不會引入額外的組件嵌套。
自定義 Hook 的核心規則
- 命名必須以
use
開頭:這不僅是約定,也使 React 能夠識別 Hook 函數 - 可以調用其他 Hook:自定義 Hook 內部可以調用 React 內置 Hook 或其他自定義 Hook
- 狀態是隔離的:不同組件調用同一個 Hook 不會共享狀態
// 基礎自定義 Hook 示例
function useCounter(initialValue = 0, step = 1) {const [count, setCount] = useState(initialValue);const increment = useCallback(() => {setCount(prevCount => prevCount + step);}, [step]);const decrement = useCallback(() => {setCount(prevCount => prevCount - step);}, [step]);const reset = useCallback(() => {setCount(initialValue);}, [initialValue]);return { count, increment, decrement, reset };
}// 使用示例
function CounterComponent() {const { count, increment, decrement, reset } = useCounter(10, 2);return (<div><p>當前計數: {count}</p><button onClick={increment}>增加</button><button onClick={decrement}>減少</button><button onClick={reset}>重置</button></div>);
}
自定義 Hook 設計模式
1. 資源管理型 Hook
這類 Hook 負責管理外部資源的生命周期,如網絡請求、事件監聽等。
function useFetch(url, options = {}) {const [data, setData] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null);const optionsRef = useRef(options);useEffect(() => {let isMounted = true;const controller = new AbortController();const signal = controller.signal;const fetchData = async () => {setLoading(true);try {const response = await fetch(url, {...optionsRef.current,signal});if (!response.ok) {throw new Error(`HTTP error! Status: ${response.status}`);}const result = await response.json();if (isMounted) {setData(result);setError(null);}} catch (err) {if (isMounted && err.name !== 'AbortError') {setError(err.message);setData(null);}} finally {if (isMounted) {setLoading(false);}}};fetchData();return () => {isMounted = false;controller.abort();};}, [url]);return { data, loading, error };
}// 使用示例
function UserProfile({ userId }) {const { data: user, loading, error } = 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>Email: {user.email}</p></div>);
}
2. 狀態邏輯型 Hook
封裝復雜狀態邏輯,提供簡潔的狀態管理接口。
function useForm(initialValues = {}) {const [values, setValues] = useState(initialValues);const [errors, setErrors] = useState({});const [touched, setTouched] = useState({});const [isSubmitting, setIsSubmitting] = useState(false);const handleChange = useCallback((e) => {const { name, value } = e.target;setValues(prev => ({...prev,[name]: value}));}, []);const handleBlur = useCallback((e) => {const { name } = e.target;setTouched(prev => ({...prev,[name]: true}));}, []);const reset = useCallback(() => {setValues(initialValues);setErrors({});setTouched({});setIsSubmitting(false);}, [initialValues]);return {values,errors,touched,isSubmitting,handleChange,handleBlur,setValues,setErrors,setIsSubmitting,reset};
}// 使用示例
function LoginForm() {const { values, errors, touched, isSubmitting, handleChange, handleBlur, setErrors, setIsSubmitting } = useForm({ email: '', password: '' });const validate = () => {const newErrors = {};if (!values.email) newErrors.email = '郵箱不能為空';if (!values.password) newErrors.password = '密碼不能為空';setErrors(newErrors);return Object.keys(newErrors).length === 0;};const handleSubmit = async (e) => {e.preventDefault();if (!validate()) return;setIsSubmitting(true);try {// 登錄邏輯await loginUser(values);alert('登錄成功');} catch (err) {setErrors({ form: err.message });} finally {setIsSubmitting(false);}};return (<form onSubmit={handleSubmit}><div><label htmlFor="email">郵箱</label><inputid="email"name="email"type="email"value={values.email}onChange={handleChange}onBlur={handleBlur}/>{touched.email && errors.email && <div className="error">{errors.email}</div>}</div><div><label htmlFor="password">密碼</label><inputid="password"name="password"type="password"value={values.password}onChange={handleChange}onBlur={handleBlur}/>{touched.password && errors.password && <div className="error">{errors.password}</div>}</div>{errors.form && <div className="error">{errors.form}</div>}<button type="submit" disabled={isSubmitting}>{isSubmitting ? '登錄中...' : '登錄'}</button></form>);
}
3. 行為型 Hook
封裝特定用戶交互行為的邏輯,如拖拽、虛擬滾動等。
function useDrag(ref, options = {}) {const {onDragStart,onDrag,onDragEnd,disabled = false} = options;const [isDragging, setIsDragging] = useState(false);const [position, setPosition] = useState({ x: 0, y: 0 });const startPosRef = useRef({ x: 0, y: 0 });const currentPosRef = useRef({ x: 0, y: 0 });useEffect(() => {if (!ref.current || disabled) return;const element = ref.current;const handleMouseDown = (e) => {// 避免與點擊事件沖突if (e.button !== 0) return;setIsDragging(true);startPosRef.current = {x: e.clientX - currentPosRef.current.x,y: e.clientY - currentPosRef.current.y};if (onDragStart) {onDragStart({x: currentPosRef.current.x,y: currentPosRef.current.y});}document.addEventListener('mousemove', handleMouseMove);document.addEventListener('mouseup', handleMouseUp);e.preventDefault();};const handleMouseMove = (e) => {if (!isDragging) return;const newPos = {x: e.clientX - startPosRef.current.x,y: e.clientY - startPosRef.current.y};currentPosRef.current = newPos;setPosition(newPos);if (onDrag) {onDrag(newPos);}};const handleMouseUp = () => {setIsDragging(false);document.removeEventListener('mousemove', handleMouseMove);document.removeEventListener('mouseup', handleMouseUp);if (onDragEnd) {onDragEnd({x: currentPosRef.current.x,y: currentPosRef.current.y});}};element.addEventListener('mousedown', handleMouseDown);return () => {element.removeEventListener('mousedown', handleMouseDown);document.removeEventListener('mousemove', handleMouseMove);document.removeEventListener('mouseup', handleMouseUp);};}, [ref, disabled, isDragging, onDragStart, onDrag, onDragEnd]);return { isDragging, position, setPosition };
}// 使用示例
function DraggableBox() {const boxRef = useRef(null);const { isDragging, position } = useDrag(boxRef, {onDragStart: (pos) => console.log('開始拖動', pos),onDragEnd: (pos) => console.log('結束拖動', pos)});return (<divref={boxRef}style={{position: 'absolute',left: `${position.x}px`,top: `${position.y}px`,width: '100px',height: '100px',background: isDragging ? '#5c7cfa' : '#339af0',cursor: 'grab',userSelect: 'none',boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.2)' : '0 2px 4px rgba(0,0,0,0.1)',transition: isDragging ? 'none' : 'box-shadow 0.3s, background 0.3s'}}>拖拽我</div>);
}
高級技巧與優化
1. 依賴收集與性能優化
自定義 Hook 的性能優化主要關注兩個方面:減少不必要的重渲染和優化內部邏輯執行效率。
function useSearch(initialQuery = '') {const [query, setQuery] = useState(initialQuery);const [results, setResults] = useState([]);const [loading, setLoading] = useState(false);// 使用 useRef 保存最新值,避免 useCallback 和 useEffect 依賴過多const stateRef = useRef({ query });stateRef.current.query = query;// 使用 useCallback 緩存函數引用const search = useCallback(debounce(async () => {const currentQuery = stateRef.current.query;if (!currentQuery.trim()) {setResults([]);return;}setLoading(true);try {const response = await fetch(`https://api.example.com/search?q=${encodeURIComponent(currentQuery)}`);const data = await response.json();setResults(data);} catch (error) {console.error('搜索出錯:', error);setResults([]);} finally {setLoading(false);}}, 300), []);// 查詢變化時觸發搜索useEffect(() => {search();// 返回清理函數,取消正在進行的請求return () => {search.cancel();};}, [query, search]);return {query,setQuery,results,loading};
}// 節流/防抖輔助函數
function debounce(fn, delay) {let timer = null;const debounced = function(...args) {if (timer) clearTimeout(timer);timer = setTimeout(() => {fn.apply(this, args);}, delay);};debounced.cancel = function() {if (timer) {clearTimeout(timer);timer = null;}};return debounced;
}
2. 組合 Hooks 實現復雜功能
通過組合多個基礎 Hook 實現更復雜的功能,遵循單一職責原則。
// 基礎 Hook: 管理分頁狀態
function usePagination(initialPage = 1, initialPageSize = 10) {const [page, setPage] = useState(initialPage);const [pageSize, setPageSize] = useState(initialPageSize);const reset = useCallback(() => {setPage(initialPage);setPageSize(initialPageSize);}, [initialPage, initialPageSize]);return {page,pageSize,setPage,setPageSize,reset};
}// 基礎 Hook: 管理排序狀態
function useSorting(initialSortField = '', initialSortDirection = 'asc') {const [sortField, setSortField] = useState(initialSortField);const [sortDirection, setSortDirection] = useState(initialSortDirection);const toggleSort = useCallback((field) => {if (field === sortField) {setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');} else {setSortField(field);setSortDirection('asc');}}, [sortField]);return {sortField,sortDirection,toggleSort};
}// 組合 Hook: 實現數據表格功能
function useDataTable(fetchFn, initialFilters = {}) {const [filters, setFilters] = useState(initialFilters);const [data, setData] = useState([]);const [total, setTotal] = useState(0);const [loading, setLoading] = useState(false);const [error, setError] = useState(null);// 組合分頁 Hookconst pagination = usePagination();// 組合排序 Hookconst sorting = useSorting();// 加載數據的函數const loadData = useCallback(async () => {setLoading(true);setError(null);try {const params = {page: pagination.page,pageSize: pagination.pageSize,sortField: sorting.sortField,sortDirection: sorting.sortDirection,...filters};const result = await fetchFn(params);setData(result.data);setTotal(result.total);} catch (err) {setError(err.message);} finally {setLoading(false);}}, [fetchFn,pagination.page, pagination.pageSize, sorting.sortField, sorting.sortDirection, filters]);// 過濾條件、分頁或排序變化時重新加載數據useEffect(() => {loadData();}, [loadData]);// 更新過濾條件的函數const updateFilters = useCallback((newFilters) => {setFilters(prev => ({...prev,...newFilters}));// 重置到第一頁pagination.setPage(1);}, [pagination]);// 重置所有狀態const reset = useCallback(() => {pagination.reset();setFilters(initialFilters);}, [pagination, initialFilters]);return {// 數據狀態data,total,loading,error,// 分頁相關page: pagination.page,pageSize: pagination.pageSize,setPage: pagination.setPage,setPageSize: pagination.setPageSize,// 排序相關sortField: sorting.sortField,sortDirection: sorting.sortDirection,toggleSort: sorting.toggleSort,// 過濾相關filters,updateFilters,// 操作方法reload: loadData,reset};
}// 使用示例
function UsersTable() {const fetchUsers = async (params) => {const queryString = new URLSearchParams(params).toString();const response = await fetch(`https://api.example.com/users?${queryString}`);return await response.json();};const {data: users,total,loading,page,pageSize,setPage,setPageSize,sortField,sortDirection,toggleSort,filters,updateFilters} = useDataTable(fetchUsers, { status: 'active' });return (<div><div className="filters"><inputplaceholder="搜索用戶"value={filters.keyword || ''}onChange={e => updateFilters({ keyword: e.target.value })}/><selectvalue={filters.status}onChange={e => updateFilters({ status: e.target.value })}><option value="active">活躍</option><option value="inactive">非活躍</option><option value="all">全部</option></select></div>{loading ? (<div>加載中...</div>) : (<table><thead><tr><th onClick={() => toggleSort('name')}>姓名 {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}</th><th onClick={() => toggleSort('email')}>郵箱 {sortField === 'email' && (sortDirection === 'asc' ? '↑' : '↓')}</th><th onClick={() => toggleSort('lastLogin')}>最近登錄 {sortField === 'lastLogin' && (sortDirection === 'asc' ? '↑' : '↓')}</th></tr></thead><tbody>{users.map(user => (<tr key={user.id}><td>{user.name}</td><td>{user.email}</td><td>{new Date(user.lastLogin).toLocaleString()}</td></tr>))}</tbody></table>)}<div className="pagination"><button disabled={page === 1} onClick={() => setPage(page - 1)}>上一頁</button><span>第 {page} 頁,共 {Math.ceil(total / pageSize)} 頁</span><button disabled={page >= Math.ceil(total / pageSize)}onClick={() => setPage(page + 1)}>下一頁</button><selectvalue={pageSize}onChange={e => setPageSize(Number(e.target.value))}><option value={10}>10條/頁</option><option value={20}>20條/頁</option><option value={50}>50條/頁</option></select></div></div>);
}
3. 利用 Context 優化 Hook 共享狀態
當多個組件需要共享同一個 Hook 的狀態時,可以結合 Context API 實現。
// 創建一個主題上下文
const ThemeContext = createContext(null);// 主題 Provider 組件
function ThemeProvider({ children, initialTheme = 'light' }) {const [theme, setTheme] = useState(initialTheme);// 在 localStorage 中保存主題偏好useEffect(() => {localStorage.setItem('theme', theme);}, [theme]);const toggleTheme = useCallback(() => {setTheme(prev => prev === 'light' ? 'dark' : 'light');}, []);// 創建主題值對象const themeValue = useMemo(() => ({theme,setTheme,toggleTheme,isDark: theme === 'dark'}), [theme, toggleTheme]);return (<ThemeContext.Provider value={themeValue}>{children}</ThemeContext.Provider>);
}// 自定義 Hook 用于訪問主題上下文
function useTheme() {const context = useContext(ThemeContext);if (context === null) {throw new Error('useTheme 必須在 ThemeProvider 內部使用');}return context;
}// 使用示例
function App() {return (<ThemeProvider initialTheme="light"><MainLayout /></ThemeProvider>);
}function MainLayout() {const { theme, toggleTheme } = useTheme();return (<div className={`app ${theme}`}><header><h1>我的應用</h1><button onClick={toggleTheme}>切換到{theme === 'light' ? '暗色' : '亮色'}主題</button></header><main><Content /></main></div>);
}function Content() {const { isDark } = useTheme();return (<section className="content"><h2>內容區域</h2><p>當前使用的是{isDark ? '暗色' : '亮色'}主題</p></section>);
}
自定義 Hook 與現代前端架構
1. 與狀態管理的整合
自定義 Hook 可以與 Redux、Zustand 等狀態管理庫無縫集成,提供更集中的狀態管理方案。
// 集成 Redux 的自定義 Hook
function useReduxActions(slice) {const dispatch = useDispatch();const state = useSelector(state => state[slice]);// 使用 useMemo 緩存創建的 actions 對象const actions = useMemo(() => {// 示例:為一個用戶模塊創建actionsif (slice === 'users') {return {fetchUsers: (params) => {dispatch({ type: 'users/fetchUsersStart', payload: params });return fetch(`/api/users?${new URLSearchParams(params)}`).then(res => res.json()).then(data => {dispatch({ type: 'users/fetchUsersSuccess', payload: data });return data;}).catch(error => {dispatch({ type: 'users/fetchUsersFailure', payload: error.message });throw error;});},createUser: (userData) => {dispatch({ type: 'users/createUserStart', payload: userData });return fetch('/api/users', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(userData)}).then(res => res.json()).then(data => {dispatch({ type: 'users/createUserSuccess', payload: data });return data;}).catch(error => {dispatch({ type: 'users/createUserFailure', payload: error.message });throw error;});}};}return {};}, [dispatch, slice]);return { state, ...actions };
}// 使用示例
function UserList() {const { state: usersState, fetchUsers } = useReduxActions('users');const { data, loading, error } = usersState;useEffect(() => {fetchUsers({ page: 1, limit: 10 });}, [fetchUsers]);if (loading) return <div>加載中...</div>;if (error) return <div>錯誤: {error}</div>;return (<ul>{data.map(user => (<li key={user.id}>{user.name}</li>))}</ul>);
}
2. 與組件庫的協同設計
自定義 Hook 可以成為組件庫的強大輔助工具,為復雜組件提供邏輯層抽象。
// 自定義 Hook 和組件協同
function useMenuControl(initialOpenKeys = []) {const [openKeys, setOpenKeys] = useState(initialOpenKeys);const [selectedKey, setSelectedKey] = useState(null);const onOpenChange = useCallback((key) => {setOpenKeys(prev => {const keyIndex = prev.indexOf(key);if (keyIndex >= 0) {// 已打開,則關閉const newKeys = [...prev];newKeys.splice(keyIndex, 1);return newKeys;} else {// 未打開,則添加return [...prev, key];}});}, []);const isOpen = useCallback((key) => {return openKeys.includes(key);}, [openKeys]);return {openKeys,selectedKey,setSelectedKey,onOpenChange,isOpen};
}// 菜單組件
function Menu({ items, defaultOpenKeys = [] }) {const {openKeys,selectedKey,setSelectedKey,onOpenChange,isOpen} = useMenuControl(defaultOpenKeys);return (<nav className="menu">{items.map(item => {if (item.children) {return (<div key={item.key} className="submenu"><divclassName="submenu-title"onClick={() => onOpenChange(item.key)}>{item.icon && <span className="icon">{item.icon}</span>}<span>{item.label}</span><span className={`arrow ${isOpen(item.key) ? 'open' : ''}`}>?</span></div>{isOpen(item.key) && (<div className="submenu-items">{item.children.map(child => (<divkey={child.key}className={`menu-item ${selectedKey === child.key ? 'active' : ''}`}onClick={() => setSelectedKey(child.key)}>{child.icon && <span className="icon">{child.icon}</span>}<span>{child.label}</span></div>))}</div>)}</div>);}return (<divkey={item.key}className={`menu-item ${selectedKey === item.key ? 'active' : ''}`}onClick={() => setSelectedKey(item.key)}>{item.icon && <span className="icon">{item.icon}</span>}<span>{item.label}</span></div>);})}</nav>);
}// 使用示例
function SidebarNavigation() {const menuItems = [{key: 'dashboard',label: '儀表盤',icon: '📊'},{key: 'users',label: '用戶管理',icon: '👥',children: [{key: 'user-list',label: '用戶列表'},{key: 'user-groups',label: '用戶組'}]},{key: 'settings',label: '系統設置',icon: '??',children: [{key: 'profile',label: '個人資料'},{key: 'security',label: '安全設置'}]}];return (<div className="sidebar"><div className="logo">應用名稱</div><Menu items={menuItems} defaultOpenKeys={['users']} /></div>);
}
自定義 Hook 測試最佳實踐
測試自定義 Hook 是確保其可靠性和可維護性的關鍵環節。以下是針對自定義 Hook 的測試策略:
// 示例:使用 @testing-library/react-hooks 測試自定義 Hook// useCounter.js
import { useState, useCallback } from 'react';export function useCounter(initialValue = 0, step = 1) {const [count, setCount] = useState(initialValue);const increment = useCallback(() => {setCount(prevCount => prevCount + step);}, [step]);const decrement = useCallback(() => {setCount(prevCount => prevCount - step);}, [step]);const reset = useCallback(() => {setCount(initialValue);}, [initialValue]);return { count, increment, decrement, reset };
}// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';describe('useCounter', () => {test('應該使用默認初始值', () => {const { result } = renderHook(() => useCounter());expect(result.current.count).toBe(0);});test('應該使用提供的初始值', () => {const { result } = renderHook(() => useCounter(10));expect(result.current.count).toBe(10);});test('應該遞增計數', () => {const { result } = renderHook(() => useCounter(0, 2));act(() => {result.current.increment();});expect(result.current.count).toBe(2);});test('應該遞減計數', () => {const { result } = renderHook(() => useCounter(10, 5));act(() => {result.current.decrement();});expect(result.current.count).toBe(5);});test('應該重置計數', () => {const { result } = renderHook(() => useCounter(10));act(() => {result.current.increment();result.current.reset();});expect(result.current.count).toBe(10);});test('當步長變化時應該更新遞增/遞減行為', () => {const { result, rerender } = renderHook(({ initialValue, step }) => useCounter(initialValue, step),{ initialProps: { initialValue: 0, step: 1 } });act(() => {result.current.increment();});expect(result.current.count).toBe(1);// 更新 step 參數rerender({ initialValue: 0, step: 3 });act(() => {result.current.increment();});expect(result.current.count).toBe(4); // 1 + 3});
});
實際項目應用
在實際項目中應用自定義 Hook 時,應采取以下建議:
- 職責單一:每個 Hook 應專注于單一功能,避免過度復雜
- 明確的命名:使用描述性的名稱清晰表達 Hook 的用途
- 文檔完善:為每個 Hook 編寫詳細文檔,包括參數、返回值和使用示例
- 版本控制:隨著 API 的演進,保持版本兼容性并提供遷移路徑
- 優先考慮性能:使用 useCallback、useMemo 優化 Hook 內部邏輯
結語
自定義 Hook 是 React 應用開發中強大的抽象工具,能夠顯著提升代碼復用性和可維護性。
未來,隨著 React 生態的不斷發展,自定義 Hook 的設計模式也將繼續演進。保持學習新的模式和技術,才能幫助我們在前端開發領域保持競爭力。
參考資源
官方文檔
- React 官方文檔 - Hooks 介紹
- React 官方文檔 - 自定義 Hook
- React API 參考 - Hooks
技術博客和文章
- Dan Abramov: Making Sense of React Hooks
- Kent C. Dodds: The State Reducer Pattern with React Hooks
- Robin Wieruch: React Hooks Tutorial
- Tanner Linsley: React Query - 重新思考數據獲取的自定義 Hook
測試資源
- React Testing Library 官方文檔
- Testing React Hooks
- @testing-library/react-hooks 使用指南
社區討論
- React Hooks RFC 討論
- StackOverflow: React Hooks 問答
- React Subreddit
高級模式和實踐
- React Patterns: 組合與自定義 Hooks
- React Recipes: 常用自定義 Hook 實現
- Josh W. Comeau: 使用 useSound Hook 增強用戶體驗
如果你覺得這篇文章有幫助,歡迎點贊收藏,也期待在評論區看到你的想法和建議!👇
終身學習,共同成長。
咱們下一期見
💻