Blog
/
Frontend
Next.js - useEffect
useEffect 不停執行導致無限迴圈?dependency array 缺少一個值頁面資料不同步?WebSocket 不關閉洩露記憶體?useEffect 的陷阱比 useState 還多。執行順序、cleanup、dependency array,搞清楚這三個就能避免 90% 的 bug。
Posted at
2024年10月25日
Posted on
Frontend
useEffect 是用來執行副作用的 Hook。副作用指的是那些不單純只是計算的邏輯,比如 fetch 資料、訂閱事件、改變 DOM、儲存到 localStorage。在 React 的函數型組件裡,這些邏輯都放在 useEffect 裡。
useEffect 接收兩個參數。第一個是一個函數,裡面是你要執行的邏輯。第二個是依賴陣列,用來控制什麼時候執行。
什麼時候用 useEffect?當你需要在組件初始化、或某些值變化時,執行一些邏輯。比如頁面載入時 fetch 資料、輸入框內容變化時驗證、組件卸載時清理計時器。如果沒有先理解 useEffect 的執行時機和 dependency array 的規則,有可能會寫出無限迴圈、資料不同步、重複訂閱的髒 code。
特性 | 說明 | 何時出現 |
|---|---|---|
初始化執行 | 組件首次渲染後執行 | 空 dependency array |
依賴變化執行 | 依賴改變時執行 | 有 dependency 值變化 |
cleanup 函數 | 下一個 effect 前或卸載時執行 | return 一個函數 |
無限迴圈 | effect 更新依賴,導致不停執行 | 缺少 dependency array 或用錯依賴 |
多個 useEffect | 按定義順序執行 | 同一個組件多個 useEffect |
useEffect 的執行順序
要理解 useEffect,必須先搞清楚它什麼時候執行。假設你寫一個組件,需要在初始化時 fetch 資料:
組件在首次渲染後,useEffect 才會去執行,順序是這樣的:
第一步:組件初始化,useState 設定 data = null,loading = true。
第二步:組件渲染到頁面。
第三步:useEffect 執行,開始 fetch。
第四步:fetch 完成後,setData 和 setLoading 觸發重新渲染。
第五步:組件重新渲染,顯示新資料。
為什麼不在組件函數頂層直接 fetch?因為函數型組件可能會重新執行多次,你不希望每次都發起一個新的 fetch。useEffect 讓你控制什麼時候執行一次邏輯。
如果是依賴變化的情況呢?假設你的頁面支援搜尋,搜尋詞變化時要重新 fetch:
這次 dependency array 不是空,而是 [query]。意思是當 query 變化時,useEffect 就執行一次。用戶輸入「apple」時,effect 執行一次。改成「apricot」時,又執行一次。
第一步:組件初始化,query = '',effect 執行但因為 query 是空就直接 return。
第二步:用戶輸入「apple」,query 變成 'apple'。
第三步:因為 query 在 dependency 裡,effect 執行,fetch 'apple' 的結果。
第四步:結果返回,setResults 觸發重新渲染,這樣就實現了搜尋功能。
Cleanup 函數的用途
假設你要建立一個 WebSocket 連接,監聽即時資料。當組件卸載時,你要關閉連接,否則會洩露記憶體,這時候你就可以用 Cleanup 函數:
原本組件初始化,執行 useEffect 後建立 WebSocket 連接。然後當用戶離開頁面或組件卸載,cleanup 函數執行就會關閉連接。
為什麼需要 cleanup?因為如果不關閉連接,WebSocket 在 background 繼續跑就會浪費記憶體和頻寬,這時候 cleanup 就能釋放用不到的資源。
cleanup 函數也可以根據依賴變化時執行。假設你監聽的是某個用戶的資料,userId 變化時要換個連接:
第一步:userId = 1 時,effect 執行,建立連接。
第二步:userId 變成 2,cleanup 執行(關閉舊連接),然後新的 effect 執行(建立新連接)。
第三步:組件卸載,cleanup 執行(關閉連接)。
Dependency Array
dependency array 有許多容易犯的小錯(其實是我犯過的乾),首先像是做一做忘記加 dependency。假設你要監聽 userId 的變化,但寫成了空陣列:
userId 變了,effect 不會重新執行,用戶換帳號,頁面還顯示舊帳號的資料。
正確的做法是把 userId;當加進 dependency userId 變化時,effect 會重新執行拿到新資料。
也有人會不小心把物件或陣列作為依賴,假設你有一個配置物件:
這樣寫的話每次組件重新渲染,config 都是新物件(即使內容一樣)。React 比較的是物件的引用,不是內容。所以 config 被認為「變化」了,effect 會不停執行。
有兩個辦法,一是從物件中提取你真正用到的欄位:
二是確保 config 不會重複建立,把它定義在組件外,或用 useMemo 記憶化。
另一個常見錯誤是沒有 dependency array,如果你根本不寫 dependency array:
這樣 effect 會在每次渲染後都執行,會導致無限迴圈。
無限迴圈的常見原因
無限迴圈是 useEffect 最常見的 bug。組件不停重新渲染,effect 不停執行,導致頁面卡住。最常見的原因是 effect 更新了 dependency 裡的值。比如:
這就是無限迴圈,每次 effect 執行,都會更新 count,count 變化導致 effect 再執行。
解決辦法取決於你想做什麼。如果你只想初始化一次做點事,用空 dependency:
如果你確實需要監聽某個值,但不能在 effect 裡改它,那就不改。或者用 useRef 保存值,useRef 變化不會觸發 effect。
另一個常見原因是物件或函數在 dependency 裡。假設你傳一個物件進去:
這也是無限迴圈,因為 config 每次都是新物件,我們可以用 useMemo 解決:
或者直接用值:
useEffect 和 Server Components 的協調
之前有講過 Server Components 和 Client Components 有不同的執行環境。useEffect 只能在 Client Component 用,因為它需要瀏覽器環境。
Server Component 拿資料很快(0.5 秒),而 Client Component 可以用 useEffect 監聽用戶互動。
多個 useEffect 的執行順序
一個組件可以有多個 useEffect。它們會按定義順序執行。假設你寫了三個:
輸出會是:
但如果是要 cleanup 時反序執行:
為什麼順序是反的?因為第 3 個 effect 可能依賴前面的,清理時要先清理後面的,再清理前面的。
小結
使用 useEffect 時,如果要初始化可以用用空 array,而監聽某個值用 [value]。再來如果要考慮 cleanup 的問題時,fetch 不用,WebSocket 要用。最吼也要注意 dependency 對不對,用到的所有外部值都要加進去。另外 eslint-plugin-react-hooks 是一個不錯的外掛,可以協助判斷錯誤 :)
下次講講 useCallback 和 useMemo,會涉及性能優化的問題



