魯斯前端布魯斯前端

文章中英模式

布魯斯前端React面試題目 - useEffect的執行時機

深入了解React useEffect執行時機、執行順序與常見問題。掌握useEffect在元件生命週期中的運作原理,清理函數的執行時機,以及依賴陣列的設計,避免無限循環與跳過執行等常見錯誤。

影片縮圖

懶得看文章?那就來看影片吧

useEffect 的基本執行時機

useEffect 是React的一個核心Hook,用於處理有副作用的邏輯。其執行時機是React渲染流程中的關鍵點,理解它何時運行對於正確使用它非常重要。

基本執行時機如下:

  • 1. 首次渲染後:元件掛載完成並出現在畫面上後執行
  • 2. 依賴項變更的渲染後:當指定的依賴項變更導致重新渲染後執行
  • 3. 元件卸載前:清理函數(return的函數)會在元件卸載前執行
function ExampleComponent() {
  const [count, setCount] = useState(0);
  
  // 基本用法示範
  useEffect(() => {
    console.log('Effect 執行:元件掛載或count變更');
    document.title = `計數: ${count}`;
    
    // 清理函數
    return () => {
      console.log('清理執行:下一次effect執行前或卸載時');
    };
  }, [count]); // 依賴count變更
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      增加 ({count})
    </button>
  );
}

React 渲染週期與 useEffect 的關係

useEffect 總是在畫面更新後才執行,這是它的關鍵特性:

React渲染流程

1. 執行元件函式,計算虛擬DOM

2. 更新實際DOM,畫面重繪

3. 執行useEffect函數

這種設計有重要好處:

  • 1. 不阻塞UI渲染,用戶能更快看到畫面
  • 2. 可以安全訪問已更新的DOM元素
// 簡單示例
function App() {
  const [name, setName] = useState('');
  
  // 在DOM更新後執行
  useEffect(() => {
    // 此時畫面已更新,用戶已看到新內容
    document.title = name || 'React App';
  }, [name]);
  
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

⚠️ 重要: useEffect 總是非同步執行的,它會在瀏覽器完成繪製之後才觸發,不會阻塞UI更新。

多個 useEffect 的執行順序

當元件中有多個useEffect時,它們的執行順序遵循以下規則:

  • 1. 按照元件中聲明的順序,從上到下依次執行
  • 2. 每個useEffect的清理函數在下一次該effect執行前運行
  • 3. 卸載時,清理函數按照useEffect的相反順序執行
function MultipleEffectsExample() {
  // 以下效果將按順序執行 (掛載後)
  useEffect(() => {
    console.log('第一個效果');
    return () => console.log('清理第一個效果');
  }, []);
  
  useEffect(() => {
    console.log('第二個效果');
    return () => console.log('清理第二個效果');
  }, []);
  
  useEffect(() => {
    console.log('第三個效果');
    return () => console.log('清理第三個效果');
  }, []);
  
  // 元件卸載時的日誌順序:
  // "清理第三個效果"
  // "清理第二個效果"
  // "清理第一個效果"
}

清理函數的執行時機

清理函數(effect return的函數)的執行時機有兩種情況:

  1. 1. 下一次effect執行前:當依賴項變更,重新執行effect前,會先執行上一次effect的清理函數
  2. 2. 元件卸載時:元件從DOM中移除前,會執行最後一次effect的清理函數

清理函數的主要目的是防止記憶體洩漏和取消不再需要的訂閱或操作:

function WindowWidthObserver() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    // 設置事件監聽
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    
    // 清理函數 - 防止記憶體洩漏
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 空依賴陣列,只在掛載和卸載時執行
  
  return <div>視窗寬度: {width}px</div>;
}

清理函數在多次渲染中的執行順序

首次渲染

元件渲染

執行 Effect A

依賴項變更

元件重新渲染

執行 Effect A 的清理函數

執行新的 Effect A

元件卸載

元件即將移除

執行 Effect A 的清理函數

元件從DOM移除

依賴陣列與執行時機

依賴陣列(第二個參數)決定了useEffect何時重新執行:

依賴陣列執行時機使用場景
未提供每次渲染後都執行需要在每次渲染後同步某些內容
[]只在掛載後和卸載前執行設置/清理只需執行一次的操作
[a, b]掛載後和a或b變更後執行響應特定數據變化的副作用
function DependencyExample({ userId, filter }) {
  // 1. 每次渲染後都執行 (沒有依賴陣列)
  useEffect(() => {
    console.log('元件已渲染');
  });
  
  // 2. 只在掛載和卸載時執行 (空依賴陣列)
  useEffect(() => {
    console.log('元件已掛載');
    return () => console.log('元件即將卸載');
  }, []);
  
  // 3. 當userId變更時執行 (指定依賴)
  useEffect(() => {
    console.log(`正在獲取用戶 ${userId} 的數據`);
    fetchUserData(userId);
  }, [userId]);
  
  // 4. 當userId或filter變更時執行 (多個依賴)
  useEffect(() => {
    console.log(`正在為用戶 ${userId} 應用過濾器 ${filter}`);
    fetchFilteredData(userId, filter);
  }, [userId, filter]);
}

useEffect 常見錯誤及解決方案

1. 無限循環

最常見的錯誤之一是在useEffect中更新state,但未正確設置依賴陣列:

// ❌ 錯誤示例 - 無限循環
function InfiniteLoopExample() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // effect更新state,導致重新渲染,再次觸發effect
    setCount(count + 1);
  }, [count]); // count變更時重新執行
  
  return <div>{count}</div>;
}

// ✅ 修正方法1 - 使用函數式更新,移除依賴
function FixedExample1() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(c => c + 1); // 函數式更新不需要依賴count
  }, []); // 只執行一次
  
  return <div>{count}</div>;
}

// ✅ 修正方法2 - 條件控制
function FixedExample2() {
  const [count, setCount] = useState(0);
  const [hasIncremented, setHasIncremented] = useState(false);
  
  useEffect(() => {
    if (!hasIncremented) {
      setCount(count + 1);
      setHasIncremented(true);
    }
  }, [count, hasIncremented]);
  
  return <div>{count}</div>;
}

2. 遺漏依賴

另一個常見錯誤是沒有包含effect中使用的所有變數作為依賴:

// ❌ 錯誤示例 - 遺漏依賴
function MissingDependencyExample({ productId, userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // effect使用了productId,但依賴陣列中缺少它
    fetchProductData(productId, userId).then(setData);
  }, [userId]); // 僅依賴userId
  
  return <div>{/* 顯示數據 */}</div>;
}

// ✅ 修正方法 - 包含所有依賴
function FixedDependencyExample({ productId, userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetchProductData(productId, userId).then(setData);
  }, [productId, userId]); // 包含所有依賴
  
  return <div>{/* 顯示數據 */}</div>;
}

3. 副作用中的非同步問題

在處理非同步操作時,可能出現競態條件(Race Condition)問題:

競態條件問題示意圖

用戶快速輸入搜尋字串:

輸入 "a"
發出請求A(較慢)
輸入 "ab"
發出請求B(較快)
請求B完成
顯示"ab"的結果 ✓
請求A完成
覆蓋為"a"的結果 ❌

問題:舊的請求結果覆蓋了新的請求結果,導致UI顯示與用戶最新輸入不符。

// ❌ 有潛在問題的示例 - 競態條件 
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    fetchResults(query).then(data => {
      // ⚠️ 這裡沒有檢查 query 是否還是最新的
      setResults(data);
    });
  }, [query]);
  
  return <ResultsList results={results} />;
}

使用 AbortController 解決競態條件

AbortController 工作原理:

輸入 "a" 時

建立 controller A

發送請求 A

輸入 "ab" 時

執行清理函數

controller A.abort()

→ 請求 A 被取消 ✓

建立 controller B

發送請求 B

結果

只有請求 B 完成

顯示 "ab" 的結果

→ 符合最新輸入 ✓

優點:自動取消過時的請求,避免記憶體洩漏,並確保UI始終顯示最新請求的結果。

// ✅ 方法:使用 AbortController(更乾淨)
function FixedSearchResults2({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetchResults(query, { signal: controller.signal })
      .then(data => {
        setResults(data);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('獲取失敗:', err);
        }
      });
    
    return () => {
      controller.abort(); // 中止前一個請求
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

避免記憶體洩漏的 useEffect 處理

不正確的 useEffect 清理可能導致記憶體洩漏,特別是在處理訂閱、計時器、事件監聽器等資源時。以下是常見的記憶體洩漏情境與解決方案:

常見記憶體洩漏來源

  • 未清理的事件監聽器
  • 未取消的計時器 (setTimeout/setInterval)
  • 未關閉的網路連接或訂閱
  • 未釋放的DOM引用

正確的清理模式如下:

// 計時器清理示例
useEffect(() => {
  const timer = setTimeout(() => {
    console.log('這是一個計時器');
  }, 1000);
  
  // 清理函數
  return () => {
    clearTimeout(timer); // 清理計時器
  };
}, []);

// 事件監聽器清理示例
useEffect(() => {
  const handleResize = () => {
    console.log('視窗大小改變');
  };
  
  window.addEventListener('resize', handleResize);
  
  // 清理函數
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

記憶體洩漏的影響

未清理的元件: 即使元件已卸載,仍保持活動連接

元件已卸載

監聽器/計時器仍在運行

記憶體無法釋放

可能導致應用崩潰

正確清理的元件: 元件卸載時釋放所有資源

元件即將卸載

執行清理函數

釋放所有資源

記憶體正確回收

一個實用的記憶體洩漏檢測技巧是在開發時添加警告日誌:

function DataFetcher() {
  useEffect(() => {
    let isMounted = true;
    
    fetchData().then(data => {
      // 防止在已卸載元件上設置狀態
      if (isMounted) {
        setData(data);
      }
    });
    
    return () => {
      isMounted = false;
      console.log('DataFetcher 已清理資源'); // 開發時的檢查日誌
    };
  }, []);
  
  // ...
}

💡 提示: 使用 React 18 的嚴格模式可以幫助發現潛在的記憶體洩漏問題,因為它會故意重複掛載和卸載元件,使清理問題更容易被發現。

🔥 常見面試題目

(一) useEffect的執行時機,useEffect的清理函數何時執行?它有什麼用途?

解答: useEffect在每次渲染完成後(DOM已更新)執行。根據依賴陣列設置:

  • 沒有依賴陣列:每次渲染後執行
  • 空依賴陣列 []:只在首次渲染後執行
  • 有依賴的陣列:首次渲染後和依賴變更時執行

useEffect執行流程

1. 元件渲染

2. 畫面更新

3. 執行useEffect

清理函數執行時機:

  1. 在下一次effect執行<strong>之前</strong>(依賴變更時)
  2. 在元件<strong>卸載時</strong>(從DOM移除前)

清理函數用途:

  • 防止記憶體洩漏(取消訂閱、清除計時器)
  • 取消進行中的API請求
  • 移除事件監聽器
  • 避免在已卸載元件上更新狀態
useEffect(() => {
  const timer = setTimeout(() => {...}, 1000);
  
  return () => {
    clearTimeout(timer); // 清理計時器
  };
}, [dependency]);

(二) 如何處理useEffect中的非同步操作?

解答: 處理非同步操作的關鍵點:

  1. 使用標記變數避免在已卸載元件上更新狀態
  2. 妥善處理錯誤
  3. 處理競態條件(多個請求時只使用最新結果)
function FixedSearchResults2({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetchResults(query, { signal: controller.signal })
      .then(data => {
        setResults(data);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('獲取失敗:', err);
        }
      });
    
    return () => {
      controller.abort(); // 中止前一個請求
    };
  }, [query]);
  
  return <ResultsList results={results} />;
}

處理競態條件

1. 用戶搜尋'A'

→ 發送請求A

2. 用戶快速搜尋'B'

→ 取消請求A

→ 發送請求B

3. 只顯示B的結果

→ 避免舊數據覆蓋

(三) 如何處理useEffect中的記憶體洩漏問題

解答: 防止記憶體洩漏的主要方法:

  1. 使用清理函數釋放資源
  2. 取消未完成的非同步操作
  3. 移除事件監聽器和訂閱
  4. 使用AbortController取消fetch請求
useEffect(() => {
  // 1. Event listener
  window.addEventListener('resize', handleResize);
  
  // 2. Timer
  const interval = setInterval(tick, 1000);
  
  // 3. Use AbortController to cancel fetch
  const controller = new AbortController();
  fetch(url, { signal: controller.signal });
  
  // Cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
    clearInterval(interval);
    controller.abort();
  };
}, []);

常見記憶體洩漏來源

未清理的計時器

setTimeout, setInterval

未移除的事件監聽器

addEventListener

未取消的網路請求

fetch, axios

未取消的訂閱

WebSocket, Observable

使用React 18嚴格模式可以幫助發現記憶體洩漏,因為它會重複掛載和卸載元件,使清理問題更容易被發現。