第 7 章:自訂 Hooks (Custom Hooks)
在前面的章節中,我們學習了 React 內建的 Hooks,如 useState
和 useEffect
。但是,React 最強大的功能之一是允許我們建立自己的 Hooks。自訂 Hooks 讓我們可以提取元件邏輯到可重用的函式中,在不同的元件之間共享狀態邏輯。
7.1 什麼是自訂 Hooks?
自訂 Hook 是一個 JavaScript 函式,它的名稱以 "use" 開頭,並且可以呼叫其他的 Hooks。這不僅僅是一個命名慣例,它還讓 React 知道這個函式遵循 Hooks 的規則。
自訂 Hooks 的特點:
- 函式名稱以 "use" 開頭:這是必須的,讓 React 識別這是一個 Hook
- 可以呼叫其他 Hooks:可以使用
useState
、useEffect
等內建 Hooks - 邏輯重用:可以在多個元件之間共享狀態邏輯
- 抽象複雜性:將複雜的邏輯封裝在一個易於使用的介面中
7.2 建立第一個自訂 Hook
讓我們從一個簡單的例子開始,建立一個管理計數器的自訂 Hook。
// hooks/useCounter.js
import { useState } from 'react';
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
};
}
export default useCounter;
使用自訂 Hook:
// components/Counter.js
import React from 'react';
import useCounter from '../hooks/useCounter';
function Counter() {
const { count, increment, decrement, reset } = useCounter(0);
return (
<div>
<h2>計數器: {count}</h2>
<button onClick={increment}>增加</button>
<button onClick={decrement}>減少</button>
<button onClick={reset}>重設</button>
</div>
);
}
export default Counter;
在另一個元件中重用相同的邏輯:
// components/ScoreBoard.js
import React from 'react';
import useCounter from '../hooks/useCounter';
function ScoreBoard() {
const player1 = useCounter(0);
const player2 = useCounter(0);
return (
<div>
<h2>計分板</h2>
<div>
<h3>玩家 1: {player1.count}</h3>
<button onClick={player1.increment}>得分</button>
<button onClick={player1.reset}>重設</button>
</div>
<div>
<h3>玩家 2: {player2.count}</h3>
<button onClick={player2.increment}>得分</button>
<button onClick={player2.reset}>重設</button>
</div>
</div>
);
}
export default ScoreBoard;
7.3 更複雜的自訂 Hook 範例
7.3.1 useLocalStorage
- 與本地存儲同步
// hooks/useLocalStorage.js
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.log(error);
return initialValue;
}
});
// 設定值的函式
const setValue = (value) => {
try {
// 允許值是一個函式,以便我們有和 useState 相同的 API
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
// 監聽 localStorage 的變化(適用於多個標籤頁)
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key) {
setStoredValue(e.newValue ? JSON.parse(e.newValue) : initialValue);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, initialValue]);
return [storedValue, setValue];
}
export default useLocalStorage;
使用 useLocalStorage
:
// components/Settings.js
import React from 'react';
import useLocalStorage from '../hooks/useLocalStorage';
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<h2>設定</h2>
<div>
<label>主題: </label>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">淺色</option>
<option value="dark">深色</option>
</select>
</div>
<div>
<label>字體大小: </label>
<input
type="range"
min="12"
max="24"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
<span>{fontSize}px</span>
</div>
<div style={{
backgroundColor: theme === 'dark' ? '#333' : '#fff',
color: theme === 'dark' ? '#fff' : '#333',
fontSize: `${fontSize}px`,
padding: '20px',
marginTop: '20px'
}}>
預覽文字 - 設定會自動儲存到本地存儲
</div>
</div>
);
}
export default Settings;
7.3.2 useFetch
- 資料獲取 Hook
// hooks/useFetch.js
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
const signal = abortController.signal;
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url, {
...options,
signal, // 支援取消請求
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (!signal.aborted) {
setData(result);
}
} catch (err) {
if (!signal.aborted) {
setError(err.message);
}
} finally {
if (!signal.aborted) {
setLoading(false);
}
}
};
fetchData();
// 清理函數:取消請求
return () => {
abortController.abort();
};
}, [url]); // 當 URL 改變時重新獲取
// 重新獲取資料的函式
const refetch = () => {
setLoading(true);
setError(null);
// 觸發 useEffect 重新執行
// 注意:這裡可以使用其他方式來觸發重新獲取
};
return { data, loading, error, refetch };
}
export default useFetch;
使用 useFetch
:
// components/PostList.js
import React from 'react';
import useFetch from '../hooks/useFetch';
function PostList() {
const { data: posts, loading, error, refetch } = useFetch('/api/posts');
if (loading) return <div>載入中...</div>;
if (error) return <div>錯誤: {error} <button onClick={refetch}>重試</button></div>;
return (
<div>
<h2>文章列表</h2>
<button onClick={refetch}>重新載入</button>
{posts && posts.map(post => (
<div key={post.id} style={{ border: '1px solid #ccc', margin: '10px', padding: '10px' }}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
);
}
export default PostList;
7.3.3 useDebounce
- 防抖 Hook
// hooks/useDebounce.js
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// 設定定時器來更新 debounced value
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// 清理函數:如果 value 在 delay 期間內再次改變,清除之前的定時器
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;
配合 useFetch
實現防抖搜尋:
// components/SearchPosts.js
import React, { useState } from 'react';
import useDebounce from '../hooks/useDebounce';
import useFetch from '../hooks/useFetch';
function SearchPosts() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
// 只有當 debouncedSearchTerm 不為空時才進行搜尋
const searchUrl = debouncedSearchTerm
? `/api/posts/search?q=${encodeURIComponent(debouncedSearchTerm)}`
: null;
const { data: results, loading, error } = useFetch(searchUrl);
return (
<div>
<h2>搜尋文章</h2>
<input
type="text"
placeholder="輸入搜尋關鍵字..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{loading && <p>搜尋中...</p>}
{error && <p>搜尋錯誤: {error}</p>}
{results && results.length > 0 && (
<div>
<h3>搜尋結果:</h3>
{results.map((result, index) => (
<div key={index} style={{ border: '1px solid #ddd', margin: '5px', padding: '10px' }}>
<h4>{result.title}</h4>
<p>{result.excerpt}</p>
</div>
))}
</div>
)}
{results && results.length === 0 && debouncedSearchTerm && (
<p>沒有找到匹配的結果</p>
)}
</div>
);
}
export default SearchPosts;
7.4 使用其他 React Hooks 的自訂 Hooks
7.4.1 useToggle
- 布林值切換
// hooks/useToggle.js
import { useState, useCallback } from 'react';
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return [value, { toggle, setTrue, setFalse }];
}
export default useToggle;
使用 useToggle
:
// components/Modal.js
import React from 'react';
import useToggle from '../hooks/useToggle';
function Modal() {
const [isOpen, { toggle, setFalse }] = useToggle(false);
return (
<div>
<button onClick={toggle}>
{isOpen ? '關閉' : '開啟'} 模態框
</button>
{isOpen && (
<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'
}}>
<div style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
minWidth: '300px'
}}>
<h3>模態框標題</h3>
<p>這是模態框的內容。</p>
<button onClick={setFalse}>關閉</button>
</div>
</div>
)}
</div>
);
}
export default Modal;
7.4.2 useWindowSize
- 視窗大小追蹤
// hooks/useWindowSize.js
import { useState, useEffect } from 'react';
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// 立即設定當前視窗大小
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
}
export default useWindowSize;
使用 useWindowSize
:
// components/ResponsiveComponent.js
import React from 'react';
import useWindowSize from '../hooks/useWindowSize';
function ResponsiveComponent() {
const { width, height } = useWindowSize();
const isMobile = width < 768;
const isTablet = width >= 768 && width < 1024;
const isDesktop = width >= 1024;
return (
<div>
<h2>響應式元件</h2>
<p>視窗大小: {width} x {height}</p>
<p>
目前裝置類型: {' '}
{isMobile && '手機'}
{isTablet && '平板'}
{isDesktop && '桌面'}
</p>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : isTablet ? '1fr 1fr' : '1fr 1fr 1fr',
gap: '10px',
marginTop: '20px'
}}>
<div style={{ padding: '20px', backgroundColor: '#f0f0f0' }}>項目 1</div>
<div style={{ padding: '20px', backgroundColor: '#e0e0e0' }}>項目 2</div>
<div style={{ padding: '20px', backgroundColor: '#d0d0d0' }}>項目 3</div>
</div>
</div>
);
}
export default ResponsiveComponent;
7.5 自訂 Hooks 的最佳實踐
7.5.1 命名慣例
- 總是以 "use" 開頭:這不僅是慣例,也是 React 識別 Hook 的方式
- 使用描述性名稱:清楚地描述 Hook 的功能,如
useLocalStorage
、useFetch
、useToggle
- 保持一致性:在整個專案中使用一致的命名風格
7.5.2 單一職責原則
每個自訂 Hook 應該專注於一個特定的功能:
// 好的:專注於一個功能
function useCounter(initialValue = 0) {
// 只處理計數器邏輯
}
// 不好的:混合了多個功能
function useCounterAndTimer(initialValue = 0) {
// 既處理計數器又處理計時器,職責過於複雜
}
7.5.3 返回一致的資料結構
為了讓 Hook 易於使用,返回的資料結構應該保持一致:
// 好的:返回物件,清楚地標示每個值的用途
function useAuth() {
return {
user,
isAuthenticated,
login,
logout,
loading,
error
};
}
// 或者返回陣列(適用於簡單的情況)
function useToggle(initialValue = false) {
return [value, toggle];
}
7.5.4 處理依賴項
正確地處理依賴項,避免不必要的重新渲染:
// 使用 useCallback 來穩定函式引用
function useApiCall() {
const [data, setData] = useState(null);
const fetchData = useCallback(async (url) => {
const response = await fetch(url);
const result = await response.json();
setData(result);
}, []); // 沒有依賴項,函式引用穩定
return { data, fetchData };
}
7.6 自訂 Hooks 的測試
自訂 Hooks 可以像普通函式一樣進行測試。React 提供了 @testing-library/react-hooks
來幫助測試 Hooks:
// __tests__/useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from '../hooks/useCounter';
describe('useCounter', () => {
test('應該正確初始化計數器', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
test('應該正確增加計數', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('應該正確重設計數', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
7.7 與第三方庫整合的自訂 Hooks
7.7.1 與 React Router 整合的 useRouter
// hooks/useRouter.js
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { useMemo } from 'react';
function useRouter() {
const history = useHistory();
const location = useLocation();
const params = useParams();
return useMemo(() => ({
// 便捷的導航方法
push: history.push,
replace: history.replace,
go: history.go,
goBack: history.goBack,
goForward: history.goForward,
// 目前路由資訊
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state,
params,
// 便捷的查詢參數處理
query: new URLSearchParams(location.search),
}), [history, location, params]);
}
export default useRouter;
7.8 總結
自訂 Hooks 是 React 中一個極其強大的功能,它們讓我們能夠:
- 重用邏輯:在不同元件之間共享狀態邏輯
- 抽象複雜性:將複雜的邏輯封裝在簡單的介面中
- 分離關注點:將特定的功能邏輯從元件中分離出來
- 提高可測試性:獨立測試邏輯,不依賴於特定的元件
自訂 Hooks 的關鍵原則:
- 遵循 Hooks 規則:只在函式的頂層呼叫 Hooks
- 以 "use" 開頭命名:讓 React 和開發者知道這是一個 Hook
- 單一職責:每個 Hook 專注於一個特定的功能
- 返回一致的介面:使用物件或陣列返回值,保持一致性
- 適當處理依賴項:正確使用依賴陣列,避免不必要的重新執行
透過學習和使用自訂 Hooks,你可以建立更加模組化、可重用和易於維護的 React 應用程式。在下一章中,我們將學習更多高級的 React 概念,如 Context API 和狀態管理模式。