# [Day 21] useEffect 其實不是 function component 的生命週期 API ~ [Day 26] Effects & cleanups 常見情境的設計技巧 ###### tags: `閱讀筆記` `iT 邦` `一次打破 React 常見的學習門檻與觀念誤解` > Each Render Has Its Own… Everything. *[ref.](https://overreacted.io/a-complete-guide-to-useeffect/)* > 每一次 render 元件都擁有自己的資料(state, props, effect functions, cleanups, functions...) => 每一次 render 就是自己一個 scope ## useEffect 執行的時機點 避免畫面渲染阻塞,React 會在當前元件載入(render)完畢後(瀏覽器完成 DOM 的繪製後)才會執行 effect functions。如有 cleanup 時則是會先叫用 cleanup 才會執行 effect functions。 - 所以預設情況下(沒有給 dependency array 的情況下,只要元件重新被叫用後及 DOM 繪製完畢後,就一定會叫用 (cleanup ->) effect functions ## dependency array 應以效能優化的手段出發,而不是以「什麼時候可以叫用」effect functions 為導向 因為在預設情況下,只要每次元件載入後就會執行 effect functions,但在某些時刻就會造成資源浪費: ```javascript! // ref. https://overreacted.io/a-complete-guide-to-useeffect/ function Greeting({ name }) { const [counter, setCounter] = useState(0); /* 每次元件渲染完畢後就會叫用這個 effect function */ useEffect(() => { document.title = 'Hello, ' + name; }); return ( <h1 className="Greeting"> Hello, {name} <button onClick={() => setCounter(count + 1)}> Increment </button> </h1> ); } ``` ```javascript! // ref. https://overreacted.io/a-complete-guide-to-useeffect/ function Greeting({ name }) { /* 點擊按鈕後元件重新叫用渲染得到 counter = 2 */ const counter = 2 /* 這裡的 effect function 還是會被叫用 */ /* 每次元件渲染完畢後就會叫用這 document.title = 'Hello, ' + name; }); return (...); } ``` 這時候就可以使用 dependency array,提供 React 「優化效能的手段」,讓 effect functions 不會每次在元件載入後就叫用: ```javascript! useEffect(() => { document.title = 'Hello, ' + name; /* * 提供 React 優化效能的手段,在元件載入完畢後 React 會比對 dependency array 有無不同, * 有的話才會叫用 effect functions,沒有的話就直接省略不執行 effect functions * */ }, [name]); ``` > 比對的方式與 `useState` 相同,是使用 `Object.is()` 比對是否需要執行 effect functions。 > 應該是以「放入 `[name]` 是優化不必要叫用 effect function 的手段,而不是將 dependency array 當作是是否要叫用 effect function 的邏輯閘門(認作當 `[name]` 變動時才執行 effect function)。 ## 對 dependency array 誠實 在非 `useEffect` 透過 scope 的原理向外找到的資料,記得就放進 dependency array 之內。 ### 函示定義在 `useEffect` 之內 依靠 `useEffect` 是透過 dependency array 達到優化的手段,可以反推回來:如果一個函式只需叫用一次 -> 定義在 `useEffect()` 可以省略因為 render 而重複地被建立: ```javascript! // ... const [data, setData] = useState() useEffect(() => { // 這裡的函式會因為給予 [] 而優化成只會建立一次及叫用一次 async processGetSomeApiRequest = () => { const response = await fetch('...') const data = await response.json() useState(data) } processGetSomeApiRequest() }, []) ``` ### pure function 可以定義在元件之外,以達到共用的效果 ### `useCallback` 為 `useEffect` 的效能優化提供很大的幫助 > 注意:`useCallback` 本身並不是省略建立重複的 inline function,而是我們可以直接拿到當初被建立的函式。==所以還是會建立,只是我們拿不到該次 render 時建立的函式而已。== ## 需要被清理的 effect functions -> clean up > clean up 是 `useEffect` 回傳的函式。 > 當 component unmounted 及 render 後要執行下一次 effect functions 時,都會叫用上一次 rende(scope) 的 clean up。 ### 取消事件 取消建立的事件,避免 component unmounted 時事件還繼續存在,造成效能浪費: ```javascript! useEffect(() => { function handleScroll(e) { console.log(window.scrollX, window.scrollY); } window.addEventListener('scroll', handleScroll); // 當 component unmounted 時取消 window scroll 事件 return () => window.removeEventListener('scroll', handleScroll); // 因為沒有 dependency 才給空陣列,並不是以「限定叫用一次 effect function」才給空陣列 }, []); ``` ### 發起請求的順序並不代表接收到回應的順序 發起請求的順序並不代表收到回應的順序,當取得資料的順序不如預期時,就會造成 UI 顯示錯誤的資訊: ```javascript! // ref. https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development const [person, setPerson] = useState('Alice'); const [bio, setBio] = useState(null); useEffect(() => { setBio(null); fetchBio(person).then(result => { setBio(result); }); }, [person]); // UI 有對應事件讓 person 可以隨著使用者選擇時改變 const handleChangePerson = (event) => { setPerson(event.target.value) } ``` ```javascript! // ----- 元件第一次 render 時 ----- person('Alice') bio(null) // DOM 繪製完畢執行 effect functions bio(null) // 發起請求(收到回應後) bio(result) // 使用者透過 UI 變更 person person('Bob') // ----- 元件 re-render(因為 Object.is('Alice', 'Bob') => false,所以觸發元件 re-render ----- person('Bob') // DOM 繪製完畢執行 effect functions bio(null) // 使用者再次透過 UI 變更 person person('Tom') // ----- 元件 re-render(因為 Object.is('Tom', 'Bob') => false,所以觸發元件 re-render ----- person('Tom') // DOM 繪製完畢執行 effect functions bio(null) // 發起請求(收到回應後),因為發起請求的順序 !== 取得結果的順序,有可能 Tom 的 bio 先來 bio(result) // 發起請求(收到回應後),之後再取得 Bob 的結果,造成 UI 上顯示錯誤(因為現在 UI 是選擇 Tom,但是因為取得 Bob 的回應比 Tom 慢) bio(result) ``` 這時候可以透過 `flag` + functional scope + clean up functions 的執行順序,避免等待取得 API 資料時造成的 UI 錯誤: ```javascript! // ref. https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development const [person, setPerson] = useState('Alice'); const [bio, setBio] = useState(null); useEffect(() => { // 增加 flag 判斷是否需要重新洗掉 state let shouldBeIgnored = false setBio(null); fetchBio(person).then(result => { if (!shouldBeIgnored) { setBio(result); } }); // 透過 clean up function 更新 flag,避免 UI 顯示錯誤 // 每一次 render 都有自己的 state, event handler, effect functions and clean up functions return () => shouldBeIgnored = true }, [person]); // UI 有對應事件讓 person 可以隨著使用者選擇時改變 const handleChangePerson = (event) => { setPerson(event.target.value) } ``` ```javascript! // ----- 元件第一次 render 時 ----- person('Alice') bio(null) // DOM 繪製完畢執行 effect functions shouldBeIgnored = false // (scope 1) bio(null) // 發起請求(收到回應後) bio(result) // 使用者透過 UI 變更 person person('Bob') // ----- 元件 re-render(因為 Object.is('Alice', 'Bob') => false,所以觸發元件 re-render ----- person('Bob') // DOM 繪製完畢要執行 effect functions 前會先執行上一次 render 時的 clean up function shouldBeIgnored = true // (scope 1) // 上一次 render 時的 clean up function 完畢執行 effect functions shouldBeIgnored = false // (scope 2) bio(null) // 使用者再次透過 UI 變更 person person('Tom') // ----- 元件 re-render(因為 Object.is('Tom', 'Bob') => false,所以觸發元件 re-render ----- person('Tom') // DOM 繪製完畢要執行 effect functions 前會先執行上一次 render 時的 clean up function shouldBeIgnored = true // (scope 2) // 上一次 render 時的 clean up function 完畢執行 effect functions shouldBeIgnored = false // (scope 3) bio(null) // 發起請求(收到回應後)(Tom 先取得回覆) // 因為當前 effect 中 if(!shouldBeIgnored) 成立,因此會執行 bio(result),進而再導致元件因為 state 與歷史不同而重新 re-render bio(result) // 發起請求(收到回應後)(Bob 才取得回覆)(因為 Bob 的 clean up 已經在執行 Tom effect functions 前變更 flag,所以不會滿足 if(!shouldBeIgnore) 的條件,因此不會重新叫用 bio(result),因此 UI 並不會顯示錯誤 bio(result) ``` #### 初始化第三方套件的實例 有時候使用其他第三方套件需要先建立其實例,之後開發者就可以針對該實例操作。可以透過 `useEffect` + clean up functions 處理實例的建立及銷毀: ```javascript! // ref. https://ithelp.ithome.com.tw/articles/10307558 useEffect( () => { if (!mapRef.current) { mapRef.current = new FooMap(); } return () => { if (mapRef.current) { mapRef.current.destory() } } }, // 這裡是因為沒有任何依賴才填空陣列, // 而不是為了控制 effect 只執行一次 [] ); ``` 如果知道只需要一個實例 + 橫跨整個 APP 使用的話,最好的方式就是移至最上層(根層元件),或者跳脫元件外初始化,避免因為 re-render 的問題重新叫用元件進而導致重複建立實例,造成設定上因為實例不同而產生預期之外的 bugs。 ## 需要靠使用者觸發的事情不屬於 effect functions ```javascript! // ref. https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development // 這樣子當 DOM 渲染完就一定會叫用 buy api useEffect(() => { fetch('/api/buy', { method: 'POST' }); }, []); ``` 這種需要靠使用者觸發的行為不需要放在 effect functions 中,而是放在 event handler 內,由使用者觸發對應的行為。 ## 小考題 ### 1. 請回答下列的 `console.log()` 印出的順序(未使用 `Strict Mode`) [[Note] clean up 也是等到 DOM 渲染後才會觸發](https://codesandbox.io/p/sandbox/note-clean-up-ye-shi-deng-dao-dom-xuan-ran-hou-cai-hui-chu-fa-sgfdfx?file=%2Fsrc%2FApp.js) ```javascript= import React, { useState, useEffect } from "react"; function App() { const [count, setCount] = useState(0); console.log("A"); useEffect(() => { console.log("B"); return () => { console.log("C"); }; }, [count]); console.log("D"); useEffect(() => { console.log("E"); }); useEffect(() => { console.log("F"); }, [count]); const increment = () => { setCount(count + 1); }; return ( <div> <TestC /> <h1>Count: {count}</h1> <button onClick={increment}>Increment</button> </div> ); } ``` #### 第一次元件載入 為了避免頁面渲染堵塞,所以 effects 會等到該 scope 渲染完畢才會執行,所以印出的順序為 `A, D, B, E, F`(同一 scope 的 effects 按照定義的順序執行)。 #### 使用者透過 UI 更新 state ==為避免頁面渲染堵塞==,所以即便有 clean up,還是會先讓頁面渲染完,才會執行上一 scope 的 clean up,才會執行新 scope 的 effect,所以印出的順序為 `A, D, C, B, E, F`(`C` 為上一次 scope 的 clean up)。 ## Recap - effect functions 預設是隨著每次 render 後(DOM 繪製完畢後)觸發 - useEffect 的 dependency 是優化效能的手段為思考點,而不是以什麼時候可以叫用的邏輯判斷 - 如果 dependency 經過 `Object.is()` 比對與上一次歷史的 dependency 相同時,React 就會自動跳過該次 effect function 的執行 - 每一次 render 都是自己的 scope,因此有自己全部的東西(props, event handlers, effect functions, clean up function, variables 等等) - 再執行該次的 effect functions 之前,會先叫用上次 render 時建立的 clean up function ```javascript! 執行 clean up (scope 1) => 執行 effect functions(scope 2) => 執行 clean up functions (scope 1) => 執行 effeact functions (scope 2) .... ``` - 對 dependency array 保持誠實,不屬於 effect functions scope 的東西需要放進 dependency array 內 ## 參考資料 1. [[Day 21] useEffect 其實不是 function component 的生命週期 API](https://ithelp.ithome.com.tw/articles/10305220) 2. [[Day 22] 保持資料流 — 不要欺騙 hooks 的 dependencies(上)](https://ithelp.ithome.com.tw/articles/10305701) 3. [[Day 23] 保持資料流 — 不要欺騙 hooks 的 dependencies(下)](https://ithelp.ithome.com.tw/articles/10306185) 4. [[Day 24] useEffect dependencies 的經典錯誤用法](https://ithelp.ithome.com.tw/articles/10306703) 5. [[Day 25] Reusable state — React 18 的 useEffect 在 mount 時為何會執行兩次?](https://ithelp.ithome.com.tw/articles/10307083) 6. [[Day 26] Effects & cleanups 常見情境的設計技巧](https://ithelp.ithome.com.tw/articles/10307558) 7. [A Complete Guide to useEffect](https://overreacted.io/a-complete-guide-to-useeffect/) 8. [30天React練功坊-攻克常見實務/面試問題 Day21: React render logic interview question](https://ithelp.ithome.com.tw/articles/10335648)