第 6 章:元件生命週期與 useEffect Hook

在前面的章節中,我們學習了如何使用 Props 傳遞資料以及如何使用 State 管理元件內部的動態資料。但是,當我們需要在元件的特定時機執行某些操作時,例如當元件首次載入時獲取資料,或當元件被移除時清理資源,我們就需要了解 React 元件的「生命週期」(Component Lifecycle)。

在現代 React 中,我們主要使用 useEffect Hook 來處理這些生命週期相關的邏輯。

6.1 什麼是元件生命週期?

React 元件從被建立到被銷毀會經歷一系列的階段,這些階段被稱為元件的「生命週期」。主要的生命週期階段包括:

  1. 掛載 (Mounting):元件被建立並插入到 DOM 中
  2. 更新 (Updating):元件的 Props 或 State 發生變化,需要重新渲染
  3. 卸載 (Unmounting):元件從 DOM 中被移除

在每個階段,我們可能需要執行不同的操作:

  • 掛載時:獲取資料、設定訂閱、初始化計時器
  • 更新時:響應 Props 或 State 的變化、重新計算值
  • 卸載時:清理訂閱、取消計時器、釋放資源

6.2 useEffect Hook 簡介

useEffect 是 React 提供的一個 Hook,讓我們可以在函式元件中執行「副作用」(side effects)。副作用是指那些不直接與元件渲染相關的操作,例如:

  • API 呼叫
  • 訂閱事件
  • 手動修改 DOM
  • 設定計時器
  • 記錄日誌

基本語法:

import React, { useEffect } from 'react';

useEffect(() => {
  // 副作用程式碼
});

6.3 useEffect 的基本使用

1. 在每次渲染後執行:

最簡單的 useEffect 用法是不提供第二個參數,這樣 effect 會在每次元件渲染後執行。

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

function Counter() {
  const [count, setCount] = useState(0);

  // 在每次渲染後執行
  useEffect(() => {
    document.title = `計數: ${count}`;
    console.log('元件已渲染,目前計數:', count);
  });

  return (
    <div>
      <p>目前計數: {count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
}

export default Counter;

在這個例子中,每當 count 狀態改變導致元件重新渲染時,useEffect 中的程式碼都會執行,更新瀏覽器標籤頁的標題。

2. 只在元件掛載時執行(模擬 componentDidMount):

如果我們只想在元件首次掛載時執行某些操作,可以提供一個空的依賴陣列 []

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

function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // 只在元件掛載時執行一次
  useEffect(() => {
    console.log('元件已掛載,開始獲取使用者資料...');

    // 模擬 API 呼叫
    setTimeout(() => {
      setUser({ name: 'React 使用者', email: 'user@react.dev' });
      setLoading(false);
    }, 2000);
  }, []); // 空的依賴陣列表示這個 effect 只會執行一次

  if (loading) {
    return <div>載入中...</div>;
  }

  return (
    <div>
      <h2>使用者資料</h2>
      <p>姓名: {user.name}</p>
      <p>信箱: {user.email}</p>
    </div>
  );
}

export default UserProfile;

6.4 依賴陣列 (Dependency Array)

useEffect 的第二個參數是「依賴陣列」,它決定了 effect 何時重新執行。

語法:

useEffect(() => {
  // effect 程式碼
}, [dependency1, dependency2, ...]); // 依賴陣列

不同的依賴陣列行為:

  1. 沒有依賴陣列:effect 在每次渲染後執行

    useEffect(() => {
      console.log('每次渲染後執行');
    });
    
  2. 空的依賴陣列:effect 只在掛載時執行一次

    useEffect(() => {
      console.log('只在掛載時執行一次');
    }, []);
    
  3. 有依賴的陣列:effect 在依賴值改變時執行

    useEffect(() => {
      console.log('當 count 改變時執行');
    }, [count]);
    

範例:監聽特定狀態的變化

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

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);

  // 只有當 searchTerm 改變時才執行搜尋
  useEffect(() => {
    if (searchTerm.length > 0) {
      console.log(`搜尋: ${searchTerm}`);
      // 模擬搜尋 API 呼叫
      setTimeout(() => {
        setResults([`結果 1 for ${searchTerm}`, `結果 2 for ${searchTerm}`]);
      }, 500);
    } else {
      setResults([]);
    }
  }, [searchTerm]); // 只有當 searchTerm 改變時才重新執行

  return (
    <div>
      <input
        type="text"
        placeholder="輸入搜尋詞..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      <ul>
        {results.map((result, index) => (
          <li key={index}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchComponent;

6.5 清理函數 (Cleanup Function)

有時候,我們需要在元件卸載或 effect 重新執行之前清理一些資源,例如取消訂閱、清除計時器等。我們可以從 useEffect 中返回一個「清理函數」。

語法:

useEffect(() => {
  // 設定副作用

  return () => {
    // 清理函數
  };
}, [dependencies]);

範例 1:清理計時器

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

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    console.log('設定計時器');
    const interval = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    // 清理函數:在元件卸載或依賴改變時清除計時器
    return () => {
      console.log('清除計時器');
      clearInterval(interval);
    };
  }, []); // 空依賴陣列,計時器只設定一次

  return (
    <div>
      <h2>計時器: {seconds} 秒</h2>
    </div>
  );
}

function App() {
  const [showTimer, setShowTimer] = useState(true);

  return (
    <div>
      <button onClick={() => setShowTimer(!showTimer)}>
        {showTimer ? '隱藏' : '顯示'} 計時器
      </button>
      {showTimer && <Timer />}
    </div>
  );
}

export default App;

範例 2:清理事件監聽器

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

function WindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    console.log('添加 resize 事件監聽器');
    window.addEventListener('resize', handleResize);

    // 清理函數:移除事件監聽器
    return () => {
      console.log('移除 resize 事件監聽器');
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 只在掛載時添加監聽器,卸載時移除

  return (
    <div>
      <h2>視窗大小</h2>
      <p>寬度: {windowSize.width}px</p>
      <p>高度: {windowSize.height}px</p>
    </div>
  );
}

export default WindowSize;

6.6 使用多個 useEffect

一個元件可以擁有多個 useEffect,用於處理不同的副作用。這有助於將相關的邏輯分組,使程式碼更容易理解和維護。

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

function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [onlineStatus, setOnlineStatus] = useState(false);

  // Effect 1: 獲取使用者資料
  useEffect(() => {
    console.log('獲取使用者資料 for userId:', userId);
    // 模擬 API 呼叫
    setTimeout(() => {
      setUser({ id: userId, name: `使用者 ${userId}` });
    }, 1000);
  }, [userId]); // 當 userId 改變時重新獲取

  // Effect 2: 獲取使用者的文章
  useEffect(() => {
    if (userId) {
      console.log('獲取使用者文章 for userId:', userId);
      // 模擬 API 呼叫
      setTimeout(() => {
        setPosts([`文章 1 by ${userId}`, `文章 2 by ${userId}`]);
      }, 1500);
    }
  }, [userId]); // 當 userId 改變時重新獲取

  // Effect 3: 訂閱線上狀態
  useEffect(() => {
    console.log('訂閱線上狀態');
    const subscription = subscribeToOnlineStatus(setOnlineStatus);

    return () => {
      console.log('取消訂閱線上狀態');
      subscription.unsubscribe();
    };
  }, []); // 只訂閱一次

  return (
    <div>
      <h2>使用者儀表板</h2>
      {user ? (
        <div>
          <p>使用者: {user.name} {onlineStatus ? '(線上)' : '(離線)'}</p>
          <h3>文章列表:</h3>
          <ul>
            {posts.map((post, index) => (
              <li key={index}>{post}</li>
            ))}
          </ul>
        </div>
      ) : (
        <p>載入中...</p>
      )}
    </div>
  );
}

// 模擬線上狀態訂閱
function subscribeToOnlineStatus(callback) {
  const interval = setInterval(() => {
    callback(Math.random() > 0.5); // 隨機線上/離線狀態
  }, 3000);

  return {
    unsubscribe: () => clearInterval(interval)
  };
}

export default UserDashboard;

6.7 常見的 useEffect 使用模式

1. 資料獲取 (Data Fetching)

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

function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        setLoading(true);
        const response = await fetch('/api/posts');
        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }
        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchPosts();
  }, []); // 只在元件掛載時獲取一次

  if (loading) return <div>載入中...</div>;
  if (error) return <div>錯誤: {error}</div>;

  return (
    <div>
      <h2>文章列表</h2>
      {posts.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  );
}

export default PostList;

2. 響應式搜尋 (Debounced Search)

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

function Search() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!searchTerm) {
      setResults([]);
      return;
    }

    setLoading(true);

    // 使用 setTimeout 實現防抖 (debounce)
    const timeoutId = setTimeout(async () => {
      try {
        // 模擬 API 呼叫
        const response = await fetch(`/api/search?q=${searchTerm}`);
        const data = await response.json();
        setResults(data);
      } catch (error) {
        console.error('搜尋失敗:', error);
      } finally {
        setLoading(false);
      }
    }, 500); // 500ms 延遲

    // 清理函數:如果 searchTerm 在 500ms 內再次改變,清除之前的計時器
    return () => {
      clearTimeout(timeoutId);
      setLoading(false);
    };
  }, [searchTerm]);

  return (
    <div>
      <input
        type="text"
        placeholder="搜尋..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      {loading && <p>搜尋中...</p>}
      <ul>
        {results.map((result, index) => (
          <li key={index}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

export default Search;

6.8 useEffect 與類別元件生命週期的對應

對於熟悉類別元件的開發者,以下是 useEffect 與類別元件生命週期方法的對應關係:

類別元件生命週期 useEffect 等價寫法
componentDidMount useEffect(() => { ... }, [])
componentDidUpdate useEffect(() => { ... })
componentWillUnmount useEffect(() => { return () => { ... } }, []) 中的清理函數
componentDidMount + componentDidUpdate useEffect(() => { ... }, [dependency])

類別元件範例:

class UserProfile extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: null };
  }

  componentDidMount() {
    this.fetchUser(this.props.userId);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.fetchUser(this.props.userId);
    }
  }

  componentWillUnmount() {
    // 清理操作
  }

  fetchUser(userId) {
    // 獲取使用者資料
  }

  render() {
    return <div>{/* 渲染邏輯 */}</div>;
  }
}

等價的函式元件與 useEffect

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      // 獲取使用者資料
      const userData = await api.getUser(userId);
      setUser(userData);
    }

    fetchUser();

    // 可選的清理函數
    return () => {
      // 清理操作
    };
  }, [userId]); // 當 userId 改變時重新執行

  return <div>{/* 渲染邏輯 */}</div>;
}

6.9 useEffect 的最佳實踐

  1. 總是包含依賴項:如果你在 effect 中使用了元件作用域內的變數(state, props, 或由它們衍生的值),請將它們加入依賴陣列。

  2. 分離關注點:將不相關的邏輯分到不同的 useEffect 中。

  3. 避免無限迴圈:確保依賴陣列正確,避免 effect 無限重複執行。

  4. 適當使用清理函數:對於訂閱、計時器、手動 DOM 操作等,總是記得清理。

  5. 條件執行:如果 effect 只在特定條件下執行,可以在 effect 內部加入條件判斷。

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

6.10 總結

useEffect 是現代 React 中處理副作用的主要方式。

  • useEffect 讓我們在函式元件中執行副作用操作。
  • 依賴陣列控制 effect 的執行時機。
  • 清理函數用於清理資源,避免記憶體洩漏。
  • 一個元件可以有多個 useEffect,用於分離不同的關注點。
  • useEffect 可以模擬類別元件的所有生命週期方法。

掌握 useEffect 是使用現代 React 開發應用程式的關鍵技能。在下一章中,我們將學習其他重要的 Hook,如 useContextuseReducer 等。

results matching ""

    No results matching ""