第 7 章:自訂 Hooks (Custom Hooks)

在前面的章節中,我們學習了 React 內建的 Hooks,如 useStateuseEffect。但是,React 最強大的功能之一是允許我們建立自己的 Hooks。自訂 Hooks 讓我們可以提取元件邏輯到可重用的函式中,在不同的元件之間共享狀態邏輯。

7.1 什麼是自訂 Hooks?

自訂 Hook 是一個 JavaScript 函式,它的名稱以 "use" 開頭,並且可以呼叫其他的 Hooks。這不僅僅是一個命名慣例,它還讓 React 知道這個函式遵循 Hooks 的規則。

自訂 Hooks 的特點:

  • 函式名稱以 "use" 開頭:這是必須的,讓 React 識別這是一個 Hook
  • 可以呼叫其他 Hooks:可以使用 useStateuseEffect 等內建 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 的功能,如 useLocalStorageuseFetchuseToggle
  • 保持一致性:在整個專案中使用一致的命名風格

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 的關鍵原則:

  1. 遵循 Hooks 規則:只在函式的頂層呼叫 Hooks
  2. 以 "use" 開頭命名:讓 React 和開發者知道這是一個 Hook
  3. 單一職責:每個 Hook 專注於一個特定的功能
  4. 返回一致的介面:使用物件或陣列返回值,保持一致性
  5. 適當處理依賴項:正確使用依賴陣列,避免不必要的重新執行

透過學習和使用自訂 Hooks,你可以建立更加模組化、可重用和易於維護的 React 應用程式。在下一章中,我們將學習更多高級的 React 概念,如 Context API 和狀態管理模式。

results matching ""

    No results matching ""