再戰 todo list 與其他 hooks


Posted by saffran on 2021-02-25

初探 useEffect

除了 useState 之外,另一個很重要的 hook 就是 useEffect

useEffect 用白話文來解釋就是「render 完,瀏覽器 paint 之後你想做什麼事情?」,就把想做的事情寫在 useEffect 裡面

假設:
todo list 的初始值是從 api 來的,我需要在第一次 render 時 call api

錯誤寫法
把 call api 的程式碼寫在 function component 裡面,錯誤的原因是:我每次重新 render 都會重新發一次 request,但我只想要在「第一次 render 之後」call api 就好

function App() {
  fetch(...).then()...

  const [value, setValue] = useState("");

因此,React 提供了 userEffect 這個內建的 hook

Mount 之後會執行一次 useEffect,之後每次「改變 state(render 完),瀏覽器 paint 之後」,就會重新執行 useEffect

要先在上方引入 useEffect
useEffect 裡面要傳入的是一個 function

import { useState, useRef, useEffect } from "react"; // 用解構的語法

function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "abc", isFinished: true },
    { id: 2, content: "not done", isFinished: false },
  ]);

  const id = useRef(3);

  useEffect(() => {
    console.log("after render!");
  });
...

但有時我不希望在「每次改變 state 都執行 useEffect 裡面的 function」,我希望「只有在改變某些 state 之後,才執行 useEffect 裡面的 function」

例如:
我想要把 todos 存到 localStorage 裡面

以前的作法如下

App 函式的外面,宣告一個 writeTodosToLocalStorage function:

function writeTodosToLocalStorage(todos) {
  window.localStorage.setItem("todos", JSON.stringify(todos));
}

function App() {

然後,在每次更新 state 時(新增 todo、刪除 todo、標示已完成/未完成),我都要呼叫一次 writeTodosToLocalStorage function 來把最新的 todos 存到 localStorage 裡面

要注意的是,下面不可以直接寫 writeTodosToLocalStorage(todos),原因為:
setTodos() 是「非同步」,所以 todos 陣列不會即時更新。如果直接寫 writeTodosToLocalStorage(todos) 就會把舊的 todos 存進 localStorage 了

例如在新增 todo 時

  // 新增 todo
  const handleButtonClick = () => {
    setTodos([
      {
        id: id.current,
        content: value,
        isFinished: false,
      },
      ...todos,
    ]);

    writeTodosToLocalStorage([
      {
        id: id.current,
        content: value,
        isFinished: false,
      },
      ...todos,
    ]);
    setValue("");
    id.current++;
  };

useEffect 的作法如下

首先,我在 useEffect 裡面寫好「呼叫 writeTodosToLocalStorage()」的程式碼

現在的問題是:

因為 inputvalue 有放在 state 裡面,所以每當我在欄位輸入字母時,value 的 state 都會改變,就會重新 render 一次,那麼 useEffect 就會執行一次

但是,如果我只有在欄位輸入字母,我並不希望執行 useEffect(因為 todos 並沒有改變),我只希望在「todos 的 state 有改變時」(也就是:對 todo 做新增、刪除、標示已完成/未完成),才執行 useEffect

解決方法:

React 在 useEffect 提供了第二個參數(是一個陣列),陣列裡面可以放「我關注的資料」

加上第二個參數 todos 就代表:
第一次 render 完後(Mount 之後),會執行 useEffect
之後,就只有在 todos 有改變時,才會重新執行 useEffect

function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "abc", isFinished: true },
    { id: 2, content: "not done", isFinished: false },
  ]);

  const id = useRef(3);

  useEffect(() => {
    writeTodosToLocalStorage(todos);
    console.log(JSON.stringify(todos));
  }, [todos]);
...

在頁面第一次 render 完後,把 localStorage 裡面存的 todos 放到「function App() 裡面 todos 的 state 裡面去」

作法如下:
我在 useEffect 的第二個參數放一個「空陣列 []」,代表說:只有「在陣列裡面的東西」改變時,才會重新執行 useEffect。但因為陣列裡面是空的,所以永遠都不會有東西改變 -> 這就代表:只有在「頁面第一次 render 時」會執行 useEffect,之後就再也不會執行了

function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "abc", isFinished: true },
    { id: 2, content: "not done", isFinished: false },
  ]);

  const id = useRef(3);

  // 把 localStorage 裡面存的 todos 放到「function App() 裡面 todos 的 state 裡面去」
  useEffect(() => {
    const todosData = window.localStorage.getItem("todos") || "";
    if (todosData) {
      setTodos(JSON.parse(todosData));
    }
  }, []);

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    writeTodosToLocalStorage(todos);
  }, [todos]);

發現一個問題是:一打開網頁,畫面會閃一下

原因為:
畫面第一次 render 時,會 render 出的是 todos 的初始值,也就是下面那兩個 todo:

  const [todos, setTodos] = useState([
    { id: 1, content: "abc", isFinished: true },
    { id: 2, content: "not done", isFinished: false },
  ]);

在「browser 畫出畫面之後(使用者也已經看到畫面了)」才會執行 useEffect,才會把 localStorage 裡面存的 todos 放到「function App() 裡面 todos 的 state 裡面去」,然後才會第二次 render。所以我們最後看到的是第二次 render 後的畫面

第一次 render 和第二次 render 之間,就會感覺閃了一下

解決方法請往下看

初探 useLayoutEffect 與 lazy initializer

要解決「畫面閃一下」的問題,有兩個解法:

  1. useLayoutEffect
  2. todos 陣列的初始值直接設為「從 localStorage 拿出來的 todos」

解法一:useLayoutEffect 用白話文來解釋就是「render 完,瀏覽器 paint 之前你想做什麼事情?」,就把想做的事情寫在 useLayoutEffect 裡面

Mount 之後會執行一次 useLayoutEffect,之後每次「改變 state(render 完),瀏覽器 paint 之前」,就會重新執行 useLayoutEffect

useLayoutEffect 用法如下:

  • 先在最上方引入 useLayoutEffect
  • 然後,把 setTodos(JSON.parse(todosData)) 那段改為使用 useLayoutEffect
import { useState, useRef, useEffect, useLayoutEffect } from "react"; // 用解構的語法

function writeTodosToLocalStorage(todos) {
  window.localStorage.setItem("todos", JSON.stringify(todos));
}

function App() {
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState([
    { id: 1, content: "abc", isFinished: true },
    { id: 2, content: "not done", isFinished: false },
  ]);

  const id = useRef(3);

  // 把 localStorage 裡面存的 todos 放到「function App() 裡面 todos 的 state 裡面去」
  useLayoutEffect(() => {
    const todosData = window.localStorage.getItem("todos") || "";
    if (todosData) {
      setTodos(JSON.parse(todosData));
    }
  }, []);

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    writeTodosToLocalStorage(todos);
  }, [todos]);

這樣就不會閃一下的原因為:

這個 Hook 的流程圖 Flow Diagram 很重要,要記起來!

流程如下:

  1. Mount -> Mount 就是「把 component 放到 DOM 上」
  2. Render
  3. React updates DOM
  4. Cleanup LayoutEffects -> 把「上一個 LayoutEffects」清掉
  5. Run LayoutEffects 把畫面更新成最新的(這時使用者都還不會看到任何畫面)
  6. Browser paints screen(等瀏覽器畫完,使用者才會看到畫面)
  7. Cleanup Effects
  8. Run Effects

只要每次更新 state,就會重新跑一次 Update 的流程(重新 render 一次)
Unmount 就是:把所有 Effects 都清掉

在 browser 畫畫面之前,我就先用 useLayoutEffect 提早改變了 state(並且 updates DOM)

因此,使用者就不會看到「第一次 render 初始值」的畫面了

解法二:把 todos 陣列的初始值直接設為「從 localStorage 拿出來的 todos」

function App() {
  const [value, setValue] = useState("");

  const todosData = window.localStorage.getItem("todos") || "";
  const [todos, setTodos] = useState(JSON.parse(todosData) || []); // 把 todos 的初始值直接設為「從 localStorage 拿出來的 todos」

  const id = useRef(3);

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    writeTodosToLocalStorage(todos);
  }, [todos]);

但是,這樣會遇到的問題是:造成了效能的浪費

const [todos, setTodos] = useState(JSON.parse(todosData) || []);
因為這裡的 useState() 小括號裡面傳的是初始值,所以就只有在第一次 render(頁面重新整理) 時才會吃到這個值

每次重新 render 時,const todosData = window.localStorage.getItem("todos") || ""; 這行還是會做事情(還是會把最新的 todos 從 localStorage 拿出來)

然後,const [todos, setTodos] = useState(JSON.parse(todosData) || []); 這行也還是會做事情(把剛拿到的 todosData 放到 useState() 裡面)

但是,只有「第一次」放到 useState() 裡面的值會被撈出來當作 todos 陣列的初始值,從第二次開始每次放到 useState() 裡面的值 React 都會自動忽略,因此,這樣就造成了效能的浪費,因為根本就不需要每次 render 都把最新的 JSON.parse(todosData) 放到 useState() 裡面

解決方法:lazy initializers

當我們想把一些較複雜的運算當作 state 的初始值時,就可以使用 lazy initializers 的方式來寫(寫在一個 function 裡面)

Mount 的 Run lazy initializers 只有在第一次 render 時才會執行

useState() 裡面可以放一個 function
這個 function 就是 lazy initializers
這個 function 只有在第一次 render (頁面重新整理)時會執行
這個 function return 的東西就會是「初始值」

把整個 window.localStorage.getItem 這些複雜的運算都搬到 lazy initializers 裡面,這樣就只有在頁面第一次 render 時會做運算,之後就不會再重新運算,就不會造成效能的浪費了

function App() {
  const id = useRef(1);
  const [value, setValue] = useState("");

  const [todos, setTodos] = useState(() => {
    console.log("init");
    let todosData = window.localStorage.getItem("todos") || "";
    if (todosData) {
      todosData = JSON.parse(todosData); // 把 todos 的初始值直接設為「從 localStorage 拿出來的 todos」
      id.current = todosData[0].id + 1; // 把 id 設為「localStorage 裡面最大的 id 再加 1」
    } else {
      todosData = [];
    }
    return todosData;
  });

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    writeTodosToLocalStorage(todos);
  }, [todos]);

再探 useEffect(return 另一個 function)

推薦閱讀文章 A Complete Guide to useEffectHow Are Function Components Different from Classes?

useEffect 裡面的 function 可以「return 另外一個 function」,return 的這個 function 就是「在下一個 Effects 執行之前,我想要怎麼清掉上一個 Effects」

  • 清掉這個 Effects 之後,才會執行下一個 Effects
  • return 的這個 function 就叫做 cleanup function

第一次 render 的 todos 就永遠都不會改變了
setTodos() 只是在「調整下一次 render 的 todos 要長什麼樣子」,並不會去改變上一次 render 的 todos

執行 cleanup function 的時機點有兩個

1. 在執行「下一個 Effects」之前,要先把上一個 Effects 清掉(執行 cleanup function 來清掉上一個 Effects)

以官網為範例:
使用者一進入網站時,就連到他的 userId 去拿資料 > 拿到這個 userId 的資料後就可以進行處理

當今天換了一個 userId 進入網站時,我要先用 cleanup function 斷掉上一個的 connection,再重新連到下一個 userId 的資料

  useEffect(() => {
    WebSocket.CONNECTING(userId).subscribe(() => {
      // ...
    });

    // cleanup function
    return () => {
      WebSocket.disconnect(userId);
    };
  }, [userId]);

2. component 在 Unmount 時也會執行 cleanup function

所以,當我想要在 component 不見時做什麼事情,就可以寫在 cleanup function

下面的程式碼中,把 useEffect 的第二個參數放上一個「空陣列」,
如此一來,這裡的 cleanup function 就只有在 Unmount 時會執行

原因為:
這個 useEffects 只有在第一次 render 時會執行,因此不會發生第一個時機點的情況(不會第二次執行 useEffects),所以就只有在 Unmount 時會執行

  useEffect(() => {
    // component 要 mount 時想要做什麼事情,就寫在這裡
    console.log("Mount");

    return () => {
      // cleanup function(component 要 unmount 時想要做什麼事情,就寫在這裡)
      console.log("Unmount");
    };
  }, []);

hooks 重要觀念補充

這是 React 的機制:hooks 只能寫在 component 的第一層

不能去調整 hook render 的順序,也不能根據條件去決定是否要執行這個 hook,只要寫了就是要用

意思就是:
在 component 底下,不能把 hooks 包在 if...else 之類的條件判斷裡面,例如

  // 把 todos 存到 localStorage 裡面去
  if (true) {
    useEffect(() => {
      writeTodosToLocalStorage(todos);
      console.log("useEffect: todos", todos);

      // cleanup function
      return () => {
        console.log("clearEffect: todos", todos);
      };
    }, [todos]);
  }

上面這樣寫的話,React 就會報錯:
React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render

舉另一個例子,我想要做的功能是:如果沒有 todos 我就不要用這個 hook
錯誤寫法:用 if...else 包住 hook

  if (!todos) {
    useEffect(() => {
      writeTodosToLocalStorage(todos);
      console.log("useEffect: todos", todos);

      // cleanup function
      return () => {
        console.log("clearEffect: todos", todos);
      };
    }, [todos]);
  }

正確寫法:在 hook 裡面做判斷
在最前面加上 if (!todos) return

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    if (!todos) return; // 如果沒有 todos 的話,就直接 return
    writeTodosToLocalStorage(todos);
    console.log("useEffect: todos", todos);

    // cleanup function
    return () => {
      console.log("clearEffect: todos", todos);
    };
  }, [todos]);

寫一個自己的 hook!

自己定義的 hook 就叫做 custom hook
custom hook 一定要用 use 開頭

範例:把「input 在 onChange 時會 setValue() 這整件事情」包成一個 useInput hook

作法如下:
新增一個檔案叫做 useInput.js

  • 宣告一個 useInput function 並且 export 出去,這個 useInput function 就是我自定義的 hook
  • 最後要 return 一個物件,物件裡面有 value, setValue, handleChange
    如果要 return 的東西很多,就用物件來 return
    如果要 return 的東西很少,就用陣列來 return

useInput.js:

import { useState } from "react";

export default function useInput() {
  const [value, setValue] = useState("");
  const handleChange = (e) => {
    setValue(e.target.value);
  };

  return {
    value,
    setValue,
    handleChange,
  };
}

然後,在 App.js 就可以這樣使用這個 useInput 的 hook:
先把 useInput import 進來

import useInput from "./useInput";

然後,在 function App() 裡面,就可以用解構的語法,拿到 useInput() 回傳的 value, setValue, handleChange
hook 其實就是一個 function,所以呼叫 useInput() 來執行這個 hook

const { value, setValue, handleChange } = useInput();

inputonChange 就可以直接寫 handleChange

      <input
        type="text"
        placeholder="add todo"
        value={value}
        onChange={handleChange}
      />

App.js:

import TodoItem from "./TodoItem";
import { useState, useRef, useEffect, useLayoutEffect } from "react"; // 用解構的語法
import useInput from "./useInput";

function writeTodosToLocalStorage(todos) {
  window.localStorage.setItem("todos", JSON.stringify(todos));
}

function App() {
  const id = useRef(1);

  const [todos, setTodos] = useState(() => {
    console.log("init");
    let todosData = window.localStorage.getItem("todos") || "";
    if (todosData) {
      todosData = JSON.parse(todosData); // 把 todos 的初始值直接設為「從 localStorage 拿出來的 todos」
      id.current = todosData[0].id + 1; // 把 id 設為「localStorage 裡面最大的 id 再加 1」
    } else {
      todosData = [];
    }
    return todosData;
  });

  const { value, setValue, handleChange } = useInput(); // 用解構的語法,拿到 useInput() 回傳的 value, setValue, handleChange

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    if (!todos) return; // 如果沒有 todos 的話,就直接 return
    writeTodosToLocalStorage(todos);
    console.log("useEffect: todos", todos);

    // cleanup function
    return () => {
      console.log("clearEffect: todos", todos);
    };
  }, [todos]);

  // 新增 todo
  const handleButtonClick = () => {
    setTodos([
      {
        id: id.current,
        content: value,
        isFinished: false,
      },
      ...todos,
    ]);
    setValue("");
    id.current++;
  };

  // 刪除 todo
  const handleDeleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  // 切換已完成/未完成
  const handleToggleIsFinished = (id) => {
    setTodos(
      todos.map((todo) => {
        if (todo.id !== id) return todo;
        return {
          ...todo,
          isFinished: !todo.isFinished,
        };
      })
    );
  };

  return (
    <div className="App">
      <input
        type="text"
        placeholder="add todo"
        value={value}
        onChange={handleChange}
      />
      <button onClick={handleButtonClick}>Add todo</button>

      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          handleDeleteTodo={handleDeleteTodo}
          handleToggleIsFinished={handleToggleIsFinished}
        />
      ))}
    </div>
  );
}

export default App;

改寫之後,我就不需要寫下面這兩段:

const [value, setValue] = useState("");
  const handleInputChange = (e) => {
    setValue(e.target.value);
  };

這樣改寫完後,todo list 一樣可以照常跑

這樣做的好處是:把共同的邏輯抽出來放到 custom hook(另一個檔案),在 App.js 就可以少寫很多程式碼

例如:
假設我有第二個 input,我就可以共用 useInput.js 裡面的程式碼

只要將傳進來的三個東西,分別取一個新的名字即可(用 ES6 的語法)

  • value 改名叫 todoName
  • setValue 改名叫 setTodoName
  • handleChange 改名叫 handleTodoNameChange
  const { value, setValue, handleChange } = useInput(); // 用解構的語法,拿到 useInput() 回傳的 value, setValue, handleChange
  const {
    // 將傳進來的三個東西,分別取一個新的名稱
    value: todoName,
    setValue: setTodoName,
    handleChange: handleTodoNameChange,
  } = useInput();

再寫一個 useTodos 的 hook

把所有跟 todos 相關的邏輯都抽出來,放到另一個 useTodos.js 檔案
useInput 這個 hook 也整合到 useTodos 裡面
useInput.js:

import { useState } from "react";

export default function useInput() {
  const [value, setValue] = useState("");
  const handleChange = (e) => {
    setValue(e.target.value);
  };

  return {
    value,
    setValue,
    handleChange,
  };
}

useTodos.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);
  const { value, setValue, handleChange } = useInput();
  const [todos, setTodos] = useState(() => {
    console.log("init");
    let todosData = window.localStorage.getItem("todos") || "";
    if (todosData) {
      todosData = JSON.parse(todosData); // 把 todos 的初始值直接設為「從 localStorage 拿出來的 todos」
      id.current = todosData[0].id + 1; // 把 id 設為「localStorage 裡面最大的 id 再加 1」
    } else {
      todosData = [];
    }
    return todosData;
  });

  // 把 todos 存到 localStorage 裡面去
  useEffect(() => {
    if (!todos) return; // 如果沒有 todos 的話,就直接 return
    writeTodosToLocalStorage(todos);
  }, [todos]);

  // 新增 todo
  const handleButtonClick = () => {
    setTodos([
      {
        id: id.current,
        content: value,
        isFinished: false,
      },
      ...todos,
    ]);
    setValue("");
    id.current++;
  };

  // 刪除 todo
  const handleDeleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  // 切換已完成/未完成
  const handleToggleIsFinished = (id) => {
    setTodos(
      todos.map((todo) => {
        if (todo.id !== id) return todo;
        return {
          ...todo,
          isFinished: !todo.isFinished,
        };
      })
    );
  };

  return {
    id,
    todos,
    setTodos,
    handleButtonClick,
    handleDeleteTodo,
    handleToggleIsFinished,
    value,
    setValue,
    handleChange,
  };
}

App.js:

import TodoItem from "./TodoItem";
import useTodos from "./useTodos";

function App() {
  // 用解構的語法,拿到「從各個 hooks 回傳的東西」
  const {
    id,
    todos,
    setTodos,
    handleButtonClick,
    handleDeleteTodo,
    handleToggleIsFinished,
    value,
    setValue,
    handleChange,
  } = useTodos();

  // UI
  return (
    <div className="App">
      <input
        type="text"
        placeholder="add todo"
        value={value}
        onChange={handleChange}
      />
      <button onClick={handleButtonClick}>Add todo</button>

      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          handleDeleteTodo={handleDeleteTodo}
          handleToggleIsFinished={handleToggleIsFinished}
        />
      ))}
    </div>
  );
}

export default App;

用 custom hooks 的好處:把「邏輯」和「UI」分開,程式碼更乾淨了

邏輯:會在各個 hooks
UI:會在 App.js

因為「把邏輯都抽出來寫到 hooks 裡面去了」,在 App.js 裡面就只會剩下:「從各個 hooks 拿東西」和「UI」而已

hooks 總結

hooks 有分為幾種:

  • useState:讓 function component 也可以擁有 state,才能夠去管理內部的狀態
  • useLayoutEffect:在 component render 完,但在瀏覽器 paint 之前,想要做什麼事情
  • useEffect:在 component render 完,然後瀏覽器也 paint 完之後,想要做什麼事情

基本上,要做什麼事情都會用 useEffect 來寫,並不會把要做的事情直接寫在 function component 的第一層,如下:

function App() {
  localStorage.setItem(...) // 把要做的事情直接寫在 function component 的第一層

原因有兩個:
這邊假設我要做的事情是「把 todos 儲存到 localStorage 裡面去」

  1. 如果把「儲存到 localStorage」這件事情直接寫在 function component 的第一層,那就會在 component render 的同時去進行「儲存到 localStorage」,這樣會影響到效能。因此,要把「儲存到 localStorage」寫在 useEffect 裡面,這樣就會在 component render 完,瀏覽器也 paint 完之後,才去執行這個 useEffect
  2. 如果把「儲存到 localStorage」這件事情直接寫在 function component 的第一層,那就會在每一次 render 完之後都會做一次儲存。但是我只想要在 todos 的 state 有改變時再儲存就好。因此,要把「儲存到 localStorage」寫在 useEffect 裡面,這樣我就可以在 useEffect 的第二個參數傳入 todos 去設定「在 todos (指定的 state)改變時,才會去執行這個 useEffect」

推薦閱讀 從 Hooks 開始,讓你的網頁 React 起來


#React







Related Posts

1. 時間複雜度 (上篇)

1. 時間複雜度 (上篇)

Apache 與 PHP 的關係

Apache 與 PHP 的關係

【隨堂筆記】程式語言概論 II(C語言)

【隨堂筆記】程式語言概論 II(C語言)


Comments