Try   HackMD
tags: React useEffect hooks

[week 21] React Hooks API:useEffect & 實作一個自己的鉤子

本篇為 [FE302] React 基礎 - hooks 版本 這門課程的學習筆記。如有錯誤歡迎指正!

在 React Hooks 當中,最重要的就是 useState 和 useEffect,若能學會如何使用這兩個 hook,對於 React 應用也會更容易上手。


初探 useEffect

詳細可參考官方文件:使用 Effect Hook

簡單來說,就是透過 useEffect 這個 hook,告訴 React「component 在 render 之後要做的事情」。

有別於一般的 hook 是傳值進去,userEffect 傳入的是 function,使用方法如下:

// 從 react 引入使用useEffect import { useEffect } from "react"; function App() { useEffect(() => { alert("執行完畢!"); }); }

就會在每次畫面 render 結束後執行 useEffect 傳入的 function:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

但通常我們不會想要在每次 render 後都執行 function,像是設定在某些 state 改變時才會執行。

範例:把資料同步到 LocalStorage

以把 todo APP 同步到 LocalStorage 這個功能為例:

function writeTodosToLocalStorage(todos) { // localStorage 只能存字串 window.localStorage.setItem("todos", JSON.stringify(todos)); }

setState():非同步更新狀態

此外,還有很重要的一點,就是之前實作的 setTodos() 功能其實是非同步行為。

如果在新增 todo 的同時進行 console.log(todos),會發現畫面 render 了,todos 卻還沒有更新:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

因此不能直接在 function 中寫入 todos,而是要直接寫入更新過的狀態,其他功能也以此類推,在每次改變 todo 時都要執行 writeTodosToLocalStorage():

const handleButtonClick = () => { setTodos([ { id: id.current, content: value, }, ...todos, ]); // 因為 setTodos 非同步,不能直接傳入 todos writeTodosToLocalStorage([ { id: id.current, content: value, }, ...todos, ]); setValue(""); id.current++; };

上述這種做法,其實是我們過去利用 jQury 實作的想法,在變動資料的同時進行其他動作。

但其實進行新增、編輯、刪除 todo 時有個共通點,就是會「todos 會改變」,接著就是 useEffect 登場的時候了!

因為 useEffect() 會在每次 render 後執行,有 render 就代表 state 有變動。一旦有變動就執行同步 function,可把程式碼改寫如下:

// 每次 render 後會執行 useEffect 中的 function useEffect(() => { writeTodosToLocalStorage(todos); console.log(JSON.stringify(todos)); });

這樣就成功在每次 render 後,都把最新的 todos 狀態同步到 localStorage:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

但這樣做其實有個缺點,透過執行的 console.log(),可發現連在輸入 input 時也會執行 render,應該只需要在 todos 有改變時才進行 render。

useEffect():可接收兩個參數

而 useEffect 的第二個參數可以解決這個問題,需傳入一個陣列,用來放想要關注的資料,當變數改變時才會執行 useEffect:

useEffect(() => { code }, [array]); // 第一個參數:一個函式,表示要做什麼事 // 第二個參數:一個陣列,定義哪寫變數改變時,才會重新執行 useEffect

可改寫如下,代表在 todos 改變時才會重新執行 useEffect():

useEffect(() => { writeTodosToLocalStorage(todos); // 傳入第二個參數 [todos] }, [todos]);

透過 localStorage 的記憶功能,我們就能在頁面第一次 render 結束後,把 localStorage 中的 todos 同步到頁面上。

第二個參數是空陣列:不會重新執行

在第二個參數傳入空陣列,就只有第一次 render 會執行這個 useEffect,可用來進行初始化:

// 進行初始化: setTodos 或是拿 API useEffect(() => { // 拿取資料,沒有資料的話就是空字串(進行錯誤處理) const todoData = window.localStorage.getItem("todos") || ""; if (todoData) { // 把 todoData 放回 state setTodos(JSON.parse(todoData)); } // 傳入空陣列: 代表只有第一次 render 才會執行這個 useEffect }, []);

useEffect 會遇到的問題

但是在重整頁面瞬間,會發現畫面閃了一下,這是因為第一次 render 畫面顯示的是 useState 初始設定,第二次 render 才是放入 todoDate:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

那麼該如何解決 useEffect 這個問題呢?接下來會繼續介紹其他功能來改善。

useLayoutEffect:render 時同步執行

我們在開頭提到,可透過 useEffect 這個 hook,告訴 React「component 在 render 之後要做的事情」。

但其實更精確的,應該是「在 render 完,瀏覽器 paint 以後要做的事情」,所以才會有 render 後畫面閃一下的情況發生。

而 useLayoutEffect 這個 hook,則是「在 render 完,瀏覽器 paint 以前要做的事情」。

也就是說,和 useEffect 功能其實很類似,差別在於同步與非同步:

  • useEffect:非同步函式,等 UI 渲染完才會執行
  • useLayoutEffect:同步函式,UI 會等 useLayoutEffect 中做的事情結束才會渲染

實際修改剛才的程式碼:

// 從 react 引入 hook import { useState, useRef, useEffect, useLayoutEffect } from "react"; // 把讀取 todoData 的 useEffect 改用 useLayoutEffect useLayoutEffect(() => { const todoData = window.localStorage.getItem("todos") || ""; if (todoData) { setTodos(JSON.parse(todoData)); } }, []);

如此畫面就不會再閃一次初始的資料了:

至於為什麼會產生這個情況,可從 React 的 Hook Flow 談起。

Hook Flow 流程圖

(圖片來源:https://github.com/donavon/hook-flow/blob/master/README.md)

Hook 執行流程可分為三個部分:

  • Mount:把 component 放到畫面上
  • Update:更新 state 流程
  • Unmount:清除 effect

原本是在瀏覽器 paint 之後才 run effects,若能提早改變 state 並更新畫面,就會直接顯示最新的 state,而不會出現初始 state。

除了透過 useLayoutEffect,還有另一種做法,同樣能解決畫面閃一下的問題,也就是接下來要介紹的 lazy initializer。

因為 useState 可以傳入初始值,那就直接把要更新的 todoDate 作為 state 初始值:

function App() { // 從 localStorage 拿取資料 const todoData = window.localStorage.getItem("todos") || ""; // 直接把 todoData 設為 state 初始值,沒有資料就設為空陣列 const [todos, setTodos] = useState(JSON.parse(todoData) || []); // 略

但這麼會產生另一個問題,就是只有第一次 render 才會執行 useState 初始值,但後續 render 還是會進行撈取 todoData 的動作,又因為 useState 已經有值了,React 就會忽略裡面的東西,這其實會造成效能上的浪費。

lazy initializer

useState 除了設定初始值,其實可以傳入一個 function,經由 function return 的值就會是 state 的初始值:

function App() { // 在 useState 傳入 function,會把回傳值設為初始值 const [todos, setTodos] = useState(() => { // 用來檢測 useState 是否只執行一次 console.log("init"); const todoData = window.localStorage.getItem("todos") || ""; return JSON.parse(todoData) || []; }); // 略

又因為初始值改變了,也要重新設定 todo id,修改後如下:

  • JSON.stringify():將資料轉為 JSON 格式的字串
  • JSON.parse():將資料由 JSON 格式字串轉回原本的資料型別
function App() { // 因為初始值改變了,也要重新設定 todo id const id = useRef(1); const [todos, setTodos] = useState(() => { // 把 todos 轉回陣列型態 let todoData = JSON.parse(window.localStorage.getItem("todos")) || ""; // 改由陣列長度判斷是否為空陣列 if (todoData.length) { id.current = todoData[0].id + 1; } else { todoData = []; } // 把 return 的值設定為初始值 return todoData; });

像這樣在 useState 透過傳入 function 來設定初始值,就是 run lazy initializer 的過程。因為只有第一次會執行,適合用於一些複雜的運算,這樣 function 就只會被執行一次,避免每次 render 產生的效能問題。

再探 useEffect:cleanup effect

在 Hook Flow 中,有個步驟其實是先 cleanup effect,然後再 run effect,這是什麼意思呢?

繼續用剛才的 todos 為範例,以下程式碼代表「每當 todos 改變,就會執行 useEffect 中的 function」:

useEffect(() => { writeTodosToLocalStorage(todos); }, [todos]);

但其實在這個 function 中可以 return 另一個 function,又稱為 cleanup function,代表「在這個 effect 被清掉之前要做的事情」:

useEffect(() => { // 每當 todos 改變,effect 要做的事 writeTodosToLocalStorage(todos); return () => { // effect 被清掉前要做的事 } }, [todos]);

每次畫面渲染時,其實就是執行一次 APP() 這個 function,可透過這段程式碼來模擬流程:

function APP() { // ... useEffect(() => { writeTodosToLocalStorage(todos); console.log("useEffect: todos", JSON.stringify(todos)); // clean up return () => { console.log("clearEffect: todos", JSON.stringify(todos)); }; }, [todos]); // ...

1. 進行第一次 render,執行 APP(),呼叫 useEffect()

useEffect(() => { writeTodosToLocalStorage(todos); console.log("useEffect: todos", JSON.stringify(todos)); // useEffect: todos [{"id":2,"content":"render!"}]

2. 點擊已完成,進行第二次 render,執行 APP(),先清除上一個 effect,再執行第二次 useEffect

1. 先清除上一個 effect // clearEffect: todos [{"id":2,"content":"render!"}] 2. 再進行第二次 useEffect // useEffect: todos [{"id":2,"content":"render!","isDone":true}]

cleanup function 執行時機

結合上述範例,cleanup function 執行的時間點有兩個:

  • 要執行下一個 useEffect 的時候,要先清除上一個 effect
  • component unmount 的時候,會清除 effect

那我們可以透過 useEffect 的 cleanup function 做什麼呢?例如:

  • 用來清除訂閱操作,避免記憶體洩漏,可參考官網範例
  • 當 component 被 unmount 時要執行的事情

以下方範例來說,代表「只有在這個 component 被 unmount 會執行 cleanup function」,又因為第二個參數是空陣列,所以這個 useEffect 只會執行一次:

useEffect(() => { console.log("mount"); return () => { console.log("unmount"); }; }, []);

實作一個自己的鉤子

接下來要談談 hooks 最強大的地方,就是我們其實能寫一個自己 hook,又稱作 custom hook,命名開頭必須是 use 開頭,詳細內容可參考官方文件

實作一個 useInput

以 input 元素為例,我們可以把 value 和 handleInputChange 等行為包在 useInput.js 檔案,寫法和之前的 APP.js 很類似:

// 從 react 引入 useState import { useState } from "react"; // 匯出 useInput() export default function useInput() { const [value, setValue] = useState(""); const handleChange = (e) => { setValue(e.target.value); }; return { value, setValue, handleChange, }; }

就可以用從 useInput.js 讀取到的 handleChange,取代原本的 handleInputChange:

// APP.js import useInput from "./useInput"; function APP { // ... // 從 useInput 讀取 value 資料 const { value, setValue, handleChange } = useInput(); // ... return ( <div className="App"> <input type="text" placeholder="Add todo..." value={value} // 改為 handleChange onChange={handleChange} onKeyDown={handleKeyDown} />

修改完程式也能正常運行,這樣寫的好處就是,如果有第二個 input 時,也能使用共通的邏輯,例如:

// 第一個 input const { value, setValue, handleChange } = useInput(); // 第二個 input const { value: todoName, setValue:setTodoName , handleChange: handleTodoName } = useInput();

實作一個 useTodos

我們也可以把 todos 的邏輯獨立成一個 hook,也就是 useTodos.js:

// useTodo.js import { useState, useEffect, useRef } from "react"; function writeTodosToLocalStorage(todos) { window.localStorage.setItem("todos", JSON.stringify(todos)); } export default function useTodos() { const id = useRef(1); const [todos, setTodos] = useState(() => { // 把 todos 轉回陣列型態 let todoData = JSON.parse(window.localStorage.getItem("todos")) || ""; // 改由陣列長度判斷是否為空陣列 if (todoData.length) { id.current = todoData[0].id + 1; } else { todoData = []; } return todoData; }); useEffect(() => { writeTodosToLocalStorage(todos); }, [todos]); return { todos, setTodos, id, }; }

並引入 APP.js 使用:

import useInput from "./useInput"; import useTodos from "./useTodos"; function App() { // 從 useTodos 讀取 todos 資料 const { todos, setTodos, id } = useTodos(); // 從 useInput 讀取 value 資料 const { value, setValue, handleChange } = useInput(); // ...

將 UI 與邏輯分開寫

若再繼續細分功能,甚至可以做到把 UI 和 todos 邏輯完全分開,改寫如下:

  • App.js
import TodoItem from "./TodoItem"; import useTodos from "./useTodos"; function App() { // 從 useTodos 讀取 todos 資料 const { todos, setTodos, id, handleButtonClick, handleKeyDown, handleTogglerIsDone, handleDeleteTodo, value, setValue, handleChange, } = useTodos(); // 剩下 UI 畫面 return ( <div className="App"> <input type="text" placeholder="Add todo..." value={value} onChange={handleChange} onKeyDown={handleKeyDown} /> <button onClick={handleButtonClick}>Add Todo</button> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} handleTogglerIsDone={handleTogglerIsDone} /> ))} </div> ); } export default App;
  • useTodo.js
import { useState, useEffect, useRef } from "react"; import useInput from "./useInput"; function writeTodosToLocalStorage(todos) { window.localStorage.setItem("todos", JSON.stringify(todos)); } export default function useTodos() { const id = useRef(1); // 從 useInput 讀取 value 資料 const { value, setValue, handleChange } = useInput(); const [todos, setTodos] = useState(() => { let todoData = JSON.parse(window.localStorage.getItem("todos")) || ""; if (todoData.length) { id.current = todoData[0].id + 1; } else { todoData = []; } return todoData; }); // 點擊按鈕新增 todo const handleButtonClick = () => { addTodo(); }; // enter 新增 todo const handleKeyDown = (e) => { if (e.keyCode !== 13) return; addTodo(); }; const addTodo = () => { // 檢查輸入欄位是否為空值,trim() 可清除字串前後空白 if (value.trim().length === 0) return; setTodos([ { id: id.current, content: value, }, ...todos, ]); setValue(""); id.current++; }; const handleTogglerIsDone = (id) => { setTodos( todos.map((todo) => { if (todo.id !== id) return todo; return { ...todo, isDone: !todo.isDone, }; }) ); }; const handleDeleteTodo = (id) => { setTodos(todos.filter((todo) => todo.id !== id)); }; useEffect(() => { writeTodosToLocalStorage(todos); }, [todos]); return { todos, setTodos, id, handleButtonClick, handleKeyDown, handleTogglerIsDone, handleDeleteTodo, value, setValue, handleChange, }; }

其實和之前寫前後端分離的時候很類似,寫成自訂 hook 的過程,就像是把不同邏輯的 function 給模組化,這麼說似乎也沒錯,畢竟 hook 就是 fucntion。

透過抽出共同邏輯的方式,可將功能包裝在 hooks,就算是在不同 UI,也同樣能利用 return 的值,在畫面上呈現想要的資料。

hooks 觀念總結

hooks 基本上可以分成下列幾種:

  • 內建 hooks
    • useState:讓 function component 擁有 state,可以管理內部狀態
    • useEffect:在 render 完、瀏覽器 paint 畫面之後要做什麼事
    • useLayoutEffect:在 render 完、瀏覽器 paint 畫面之前要做什麼事
  • 自訂 hooks:把邏輯從 UI 抽出來寫一個 hook
  • 參考別人寫好的 hooks:useHooks

補充資料

推薦閱讀 Dan Abramov 所撰寫有關 React 的系列文章,裡面對於 useEffect 的原理有更詳細敘述:

第 11 屆 iT 邦幫忙鐵人賽有關 React 的系列文章:

參考資料: