React 中的性能優化


Posted by saffran on 2021-02-25

React 的渲染機制(Reconciliation)與 Virtual DOM

React 的 component 在 render 的時候,並不會直接產生出一個真的 DOM,而是 render 出一個 virtual DOM(會是一個 JS 的 object)

例如下面的 component:

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

render 時產生的 virtual DOM 會是這樣:(就是一個 JS 的 object)
Reace 會把 tag: 'div' 這行 render 成 <div></div>

  {
    tag: 'div',
    props: {
      className: 'App'
    }
    children: {

    }
  }

找出「前一個 virtual DOM」和「後一個 virtual DOM」之間的差異後,再把這些「需要改變的地方」應用到真的 DOM 上面

reconciliation 就是:調和、比對差異的過程

面試重點考題 - virtual DOM 有兩個特點:

第一個特點:

因為有了這層 virtual DOM,讓 React 可以透過「比對 virtual DOM 的差異(Reconciliation)」,快速找出需要改變的地方,就不用每次改變 state 時就要全部清空重新 render(很沒效率),只需要 re-render 有改變的地方即可

第二個特點:透過中間的這層 virtual DOM,React 可以做到很多事情

前面有提到 virtual DOM 是一個 JS 的 object(如下程式碼),React 會把這個 object render 成 HTML DOM

  {
    tag: 'div',
    props: {
      className: 'App'
    }
    children: {

    }
  }

其實這個 virtual DOM 不只可以 render 成 HTML DOM(做成網站),也就是說:不一定要呈現在瀏覽器上面

  • React component 也可以 render 成「markdown 的語法」(就可以做成簡報、投影片)
    render 成 markdown 例如:
    tag: 'div' render 成 ## div
  • React component 也可以 render 成「手機 app 的 component」(就可以做成手機的 app)

推薦文章

Virtual DOM | 為了瞭解原理,那就來實作一個簡易 Virtual DOM 吧!
從頭打造一個簡單的 Virtual DOM

re-render 有分為幾個不同的層面

第一個層面是:
當 component 的 state 改變時,就會再 call 一次 function component
「再 call 一次 function component」這個行為就稱為 re-render
第二個層面是:
找出 DOM diff 之後,把有差異的地方應用到真正的 DOM 上面 -> 這個行為也稱為 re-render

所以,有可能發生的事情是:
state 改變了 -> 有 re-render(再 call 一次 function component)

如果這個 state 是「UI 不會用到的 state」

這個改變的 state 並不會影響到 UI -> 在 virtual DOM 會做一次 DOM diff,但是因為 virtual DOM 並沒有變,所以最後並不會有東西被應用到真的 DOM 上面去

雖然,上面這樣做的效能已經比「每次 state 改變都把畫面清空,再真的把東西放到 DOM 上面去」還要好

但是其實,因為畫面根本不會改變,所以根本就不需要 re-render

那要怎麼解決這個 re-render 的問題呢?

如何避免 re-render?

首先,來示範一下這個 re-render 的問題

先把 Add todo 按鈕變成一個 component
每一次 render Button 時,都會在 console 印出 "render button"

// Add todo 按鈕
function Button({ onclick, children }) {
  console.log("render button");
  return <button onClick={onclick}>{children}</button>;
}

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>
...

這個 re-render 的問題就是:
我只要在 input 欄位打一個字母,Button 就會 re-render 一次
可是,我打這些字母跟 Button 沒有任何關係,Button 不應該 re-render 啊

那要怎麼讓 Button 不要 re-render 呢?

memo() 把 component 包起來

React 有提供一個 hook 叫做 memo,把 Button 這個 component 用 memo() 包起來

memo() 把 component 包起來之後,React 就會自動檢測:如果傳給這個 component 的 props 都沒有變的話,就不會 re-render

在此範例中,傳給 Button 的 props 就是 { onclick, children },只有當 onclickchildren 有變的話,才會 re-render

...
import { memo } from "react"; // 從 React 引入 memo

// Add todo 按鈕
function Button({ onclick, children }) {
  console.log("render button");
  return <button onClick={onclick}>{children}</button>;
}

const MemoButton = memo(Button); // 把 Button 這個 component 用 memo 包起來

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}
      />
      <MemoButton onclick={handleButtonClick}>Add todo</MemoButton>
...

改成 MemoButton 之後,會發現:當我在 input 欄位輸入字母,還是會一直 re-render 啊?原因是:
Button 的 props 有 onclickchildren
children (也就是 Add todo)沒有變,但是 onclick 會變
因為 onclick={handleButtonClick} 裡面的 handleButtonClick 會變,為什麼呢?
因為:
每當我在 input 欄位輸入一個字母,function App() 都會重新執行一次 -> 在 function App() 裡面的 useTodos() 也會重新執行一次 -> 在 useTodos() 這個 hook 裡面,handleButtonClick 是用 const 宣告的:

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

也就是說,每執行一次 function useTodos(),就會重新宣告一次 handleButtonClick(重新產生一個新的 function)

例如我執行了兩次 useTodos()

useTodos();
useTodos();

就等於是宣告了兩個 handleButtonClick
這兩個 handleButtonClick 是不同的兩個 function(指向到不同的記憶體位置),所以執行第一次和第二次的 handleButtonClick 是會變的

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

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

「指向到不同的記憶體位置」這個概念就是例如 {} === {} 會是 false

那要怎麼解決這個「每次 render 都會產生新的 function」的問題呢?

useCallback() 把一個 function 記起來

useCallback 背後就是用 useRef 做的
useCallback 這個 hook 來解決「每次 render 都會產生新的 function」的問題

handleButtonClick 的 function 用 useCallback() 包起來
要傳入第二個參數,第二個參數會是一個 dependency array,就是當這個 array 裡面的東西改變時,handleButtonClick 才會改變

在 dependency array 傳空陣列代表:因為是空的,永遠不會有東西改變,所以 handleButtonClick 這個 function 也永遠都不會變

只有在第一次執行的時候會執行到 useCallback(...) 小括號裡面的程式碼,然後 useCallback 就會幫我把 handleButtonClick 這個 function 記起來,第二次執行時就直接用 useCallback 記起來的那個版本 -> 也就是說,不會再有新的 handleButtonClick 產生了(handleButtonClick 永遠都會是同一個 function)

useTodos.js:
要記得先引入 useCallback

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

改成這樣之後,當我在 input 欄位輸入字母時,Button 就不會 re-render 了

es-lint 會跳出一個提醒:React Hook useCallback has missing dependencies...

因為在 handleButtonClick 裡面有用到 setTodos 這個 function
照理來說,當 setTodos 改變了,handleButtonClick 就要產生一個新的 function 才對

當我在 handleButtonClick 想要印出 console.log(value),為什麼印出的都是空值呢?

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

原因為:
我用 useCallback 騙 React 說「handleButtonClick 永遠都不會改變」

要記得:每個 function 都只看得到「自己這一次 render 的值」

  • 第一次 render 時,value 的初始值是空字串,所以就會印出空字串
  • 當我在 input 欄位輸入 123 -> 第二次 render -> value 的值會變成 123(第二次 render 跟第一次 render 的 value 會是不同的兩個變數,在不同的 scope 裡面),但是因為我用 useCallback 把第一次的 handleButtonClick 記起來了,所以 value 永遠都會是第一次 render 的 scope 裡面的那個空字串,所以還是會印出空字串

這樣是不正確的,因為這樣每次 render 都不會更新,永遠都會是第一次 render 的值

那怎麼辦呢?
就按照 es-lint 提示我的:

handleButtonClick 裡面有用到的 setTodos, setValue, value, todos 都放入 useCallback 的第二個參數

這樣當 setTodos, setValue, value, todos 其中一個有改變時,就會產生一個新的 handleButtonClick function,這樣在 handleButtonClick 裡面的 setTodos, setValue, value, todos 值才會是對的,這樣 render 出來的東西才會是對的

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

前面其實講錯了,在 input 輸入字母時,Button 會需要 re-render,因為 handleButtonClick 會用到 setTodos, setValue, value, todos 這些值,而在 input 輸入字母時,setTodos, setValue, value, todos 這些值都會改變,所以 Button 一定得 re-render

有時候,會需要傳一個物件進去

function Test({ style }) {
  console.log("test render");
  return <div style={style}>test</div>;
}

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

  // UI
  return (
    <div className="App">
      <Test style={{ color: "red" }} />
      <input
        type="text"
        placeholder="add todo"
        value={value}
        onChange={handleChange}
      />
      <MemoButton onclick={handleButtonClick}>Add todo</MemoButton>

當我在 input 欄位輸入字母時,會發現:Test component 也會一直 re-render

原因為:
<Test style={{ color: "red" }} /> 傳入的這個 object { color: "red" }

在每一次的 render,{ color: "red" } 這個 object 都會是「不同的 object」

就等於是這樣寫:
第一次 render 的 const s = { color: "red" } 和第二次 render 的 const s = { color: "red" } 會是不同的兩個 object
{ color: "red" } === { color: "red" } 會是 false,雖然內容一樣,但是是在不同的記憶體位置

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

  const s = { color: "red" };
  // UI
  return (
    <div className="App">
      <Test style={s} />

那要怎麼讓 Test component 不會一直 re-render 呢?

解決方式一:把 const s = { color: "red" } 移到 function App() 外面

這樣每次 render 都是用到同一個 object,不會因為重新執行 function App() 而產生新的 object

function Test({ style }) {
  console.log("test render");
  return <div style={style}>test</div>;
}

const s = { color: "red" }; // 把 object 移到 function App 外面

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

  // UI
  return (
    <div className="App">
      <Test style={s} />

解決方式二:

有時候,這個 s 會根據 value 的不同而改變,因此一定要寫在 function App() 裡面,例如:
value 有值時會是 red,沒有值的話會是 blue

function Test({ style }) {
  console.log("test render");
  return <div style={style}>test</div>;
}

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

  const s = {
    color: value ? "red" : "blue",
  };

  // UI
  return (
    <div className="App">
      <Test style={s} />

useMemo 這個 hook 來處理「會需要複雜計算的資料」

不要搞混了!memo 是給 function component 用的,useMemo 是給資料用的

useMemo() 裡面傳入一個 function,讓這個 function 回傳 s 的值
useMemo() 裡面傳入第二個參數,第二個參數會是一個 dependency array,在 array 裡面填入 value

意思就是:只有當 value 改變時,才會重新執行一次 useMemo() 裡面的程式碼,也就是:才會重新計算 s 的值是什麼,並且回傳計算後的值

s 的值會需要複雜計算時,用 useMemo 就可以節省很多效能,因為只有在 value 有改變時才會重新計算 s 的值

import { memo, useMemo } from "react"; // 從 React 引入 memo, useMemo

// Add todo 按鈕
function Button({ onclick, children }) {
  console.log("render button");
  return <button onClick={onclick}>{children}</button>;
}

const MemoButton = memo(Button); // 把 Button 這個 component 用 memo 包起來

function Test({ style }) {
  console.log("test render");
  return <div style={style}>test</div>;
}

const redStyle = {
  color: "red",
};

const blueStyle = {
  color: "blue",
};

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

  const s = useMemo(() => {
    console.log("calculate s");
    return value ? redStyle : blueStyle;
  }, [value]);

  // UI
  return (
    <div className="App">
      <Test style={s} />

因此,會發現:當我在 input 欄位輸入字母時,因為 value 改變了,所以會重新計算 s 的值(會看到有印出 "calculate s")。
但是如果我是把 todo 改為「已完成」,value 並沒有改變,就不會重新計算 s 的值(不會印出 “calculate s”)

React 特別的事件機制

以下面這個按鈕為例:

<MemoButton onclick={handleButtonClick}>Add todo</MemoButton>

在按鈕上面加上 onClick 事件,但其實這個 onClick 事件「並不是」放在 <button></button> 這個 DOM 上面喔!

在按鈕上面按右鍵「檢查」,在 Event Listeners 裡面可以看到:
雖然在 button 上面有這個 click 的事件,但是把它 remove 後,按鈕還是可以正常運作(功能還是一樣)

原因為:

重要觀念:React 是用「事件代理」的機制

這個按鈕以及頁面上的所有元素,它們的 event listener 都是用「事件代理」的方式被綁在最上層的 <div id="root"></div> 這個節點上面(寫在 index.html)

在 index.js,todo list 這整個 app 就是被放在 <div id="root"></div> 裡面:

ReactDOM.render(
  <ThemeProvider theme={theme}>
    <App />
  </ThemeProvider>,
  document.getElementById("root")
);

會用事件代理的方式有兩個原因:

  1. 效能比較好
  2. 因為會在頁面上動態新增、刪除元素,所以要用事件代理的機制,確保 event listener 可以捕捉到正確的資訊

#React







Related Posts

Day 1 Markdown & Minimal Table

Day 1 Markdown & Minimal Table

MTR04 W2 D17 陣列練習題

MTR04 W2 D17 陣列練習題

學會 HTML & CSS (關於 HTML 的部份)

學會 HTML & CSS (關於 HTML 的部份)


Comments