0%

[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:

但通常我們不會想要在每次 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 卻還沒有更新:

因此不能直接在 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:

但這樣做其實有個缺點,透過執行的 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:

那麼該如何解決 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 的原理有更詳細敘述:

  • A Complete Guide to useEffect
  • How Are Function Components Different from Classes?

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

  • 從 Hooks 開始,讓你的網頁 React 起來

參考資料:

  • 【Day.26】React進階 - useEffect v.s useLayoutEffect
  • Overreacted - Dan Abramov.
  • [ReactDoc] React Hooks - useEffect