[FE302] React 基礎 - hooks 版本 (性能優化)


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

(兩大重點面試會考!)

陽春版

  1. 新增 todo 更改資料,由資料決定畫面
  2. 作法:state 改變 => 清空畫面 + render => DOM
  3. 缺點:效能隱憂,一百個 todo,就算只編輯其中一個,也還是要將一百個東西重新再 render 一次。

react 如何解決陽春版的缺點?換句話說,react 如何快速找出要改變的地方(Reconciliation)?

重點一、virtual DOM 進行比對

react component 在 render 並不是直接產生出真的 DOM,直接將改變應用到畫面上去,而是產生 virtual DOM (一個虛擬的 JS 物件)。所以他會有上個 state JS 的物件,以及下一個 state JS 的物件,就可以做這兩個 virtual DOM 之間的比對。

比對的過程就叫做 Reconciliation。最後再把這些改變的地方應用到 DOM 去。

DOM is a tree。我要怎麼找到樹裡面不同的節點?原本的時間複雜度為 O(n^3),但因為 react 多了一些假設因此時間複雜度也降低了。

假設一、若相對應節點不同,那我就假設下面的東西完全不能被共用。就不用再往目標節點下面的節點找。直接將整個節點拆掉換新的。因此少掉很多比對的時間。包括加的 list 需要 key 也是因為要幫助 react 找哪個元素有改變,就不用每個元素都動都比對。

效能也許沒有直接操作快,但至少也比完全把畫面清掉重新 render 一次快,算是一個折衷的方式。

重點二、透過中間層決定要將 JS 物件 render 出甚麼東西

因為 virtual DOM 不是真的 DOM,所以在網頁上面,REACT 這套 library 就可以將 virtual DOM 轉成真的 DOM。

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

  }
}
=> <div></div>

同樣的 code 如果不轉成 div,轉成 mark down 的語法 # div,就可以用 react 的語法 render 出 mark down。同理,若轉成手機 app 的語法,就可以把 react render 成手機的 component。

透過中間層他的 target 可以不同。

推薦文章:
Virtual DOM | 為了瞭解原理,那就來實作一個簡易 Virtual DOM 吧!
從頭打造一個簡單的 Virtual DOM
搜尋 virtual DOM 前幾篇都看一下

re-render

  1. 層面一:當 component state 改變,就會再 call 一次 function,這個 function 會 return 一個東西。那麼再 call 一次 function 的行為就是 re-render
  2. 層面二:當 state 改變,他找出 DOM diff 把他的東西 patch 到真的 DOM 上面,這個行為也叫 re-render

所以可能發生 state 改變,但這個 state 是 UI 上沒有用到的 state,因為 virtual DOM 沒有改變所以我真正的 DOM 並不會改變。這就代表我只有在層面一 re-render,所以她只有多做一次 virtual DOM 的 diff。並沒有把真的東西放到畫面上去。所以效能比真的放到畫面上去好,因為少了一個步驟。

但層面一的 re-render 其實沒有意義,因為我根本沒有用到。可是因為 state 改變,他在我 state 裡面所以我還是會重新 render 一次。但就實質意義而言,我不需要去做 render 的動作、virtual DOM 的比對,因為我知道他絕對不會有新的東西產生。

因為是找出真的要改的地方應用上去,所以 React 的效能會比陽春版的效能好很多。

二、如何避免 re-render?

先將 button 變成 component

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <button onClick={handleButtonClick}>Add todo</button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

每次當 button render 他就會 call 一次這個 function,然後 console.log('render button'),所以我們可以檢是在甚麼情況下 button 會 render。

function Button({onClick, children}) {
  console.log('render button')
  return <button onClick={onClick}>{children}</button>
}
  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <Button onClick={handleButtonClick}>Add todo</Button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

每打一次字就會 render button,因為 value 的 state 改變,state 改變就會使 App 這個 component re-render。parent re-render children 會一起 re-render。

parent re-render 底下的所有 children 也會跟著一起 re-render!

我每打一個字,button 就會 re-render,但事實上 button 不需要 re-render。因為打字的 value 與 button 間沒有關係。

如何讓一個 component 不要 re-render ?

React 提供了 memo,可以將 component 又用 memo 包起來。

import {useState, useRef, useEffect, useLayoutEffect, memo} from 'react'

function Button({onClick, children}) {
  console.log('render button')
  return <button onClick={onClick}>{children}</button>
}
const MemoButton = memo(Button)

加上 memo 後,React 會自動檢測如果傳給 button 的 props,onClick 跟 children 都沒有變的話,他就不會 re-render。若有其中一個變了,他就會 re-render。

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

重新打字後,發現還是 re-render,首先我們傳進去的 children 都沒有變,都是 Add todo。但 handleButtonClick 變了,我們現在是包在 hook 裡面,但每次跑 hook 都會產生新的 function,執行一個 hook 就把他想成執行一個 function。

執行第一次、第二次,裡面產生出來的會是不同的 instance。他有點像是重新 new 一次變數的感覺。

// 做的事情一樣,但兩個 function 不一樣,這就是變數指向的概念,他們會是兩個不同的 function
  const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])

    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

    const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

如果是這樣 <MemoButton>Add todo</MemoButton> 因為 props 沒變,所以就不會 re-render。

因為傳入的 handleButtonClick,每次都不一樣,所以每次都會 re-render,所以這個時候就會透過另外的 hook 去處理這件事情:useCallback

用法就是將 function 用 useCallBack 包住。一樣要傳第二個參數表示當我甚麼東西變了,我的 function 要改變,類似 useEffect 的用法

傳空陣列表示我沒有任何東西變的時候,我的 function 就不會改變。這個陣列全名是 dependency array,這個 function dependency 甚麼。

這樣寫表示 handleButtonClick 這個東西,我用 useCallback 這個 hook 包住,他就都不會改變。可以想成 useCallback 幫我們做了 memory 的事情。所以只有第一次 render 會執行到這邊,第二次就會用他已經存好的 function,所以就不會改變。

透過這的作法,打字就不會 re-render 但 function 還是正常執行。

import {useState, useRef, useEffect, useLayoutEffect, memo, useCallback} from 'react'

  const handleButtonClick = useCallback(() => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [])

小結

  1. memo 可以將 component 給包起來
  2. useCallback 可以將 function 給記憶,他就會永遠都是同一個 function

React Hook useCallback has missing dependencies

eslint 偵測到你在 react 當中有用到 setTodos 這個 function,所以當 setTodos 改變時,handleButtonClick 就該產生一個新的 function

  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [])

這樣寫新增的都是空的,因為我騙了 react:和 react 說只有第一次 render 這個 function 才會變,handleButtonClick 不會變。因為每次 render 都是重新執行 function,

這樣寫法的執行流程

  1. first render => value = ' '
  2. handleButtonClick 長相如下 => console.log(value) => ' '
  3. 打字 a => second render => value = 'a' => 再執行下面的 handleButtonClick,但因為 useCallback [],和 react 說好不管發生甚麼事情都不會產生新的 function,所以 handleButtonClick 就會是第一次的 function,又因為第一次的 function 在他的 scope 裡面他的 value = ' ',所以 console.log(value) => ' '
  4. render 的是兩個不同的 value
  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [])

依照 eslint 提示,要把用到的東西都加上去。
當這四個東西 [setValue, setTodos, value, todos] 有任一東西改變,就要重新產生 handleButtonClick 的 function,這樣裡面抓到的值才會是正確的。如果用到上個 render 的 function 就會吃到上個 render 的值。

  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [setValue, setTodos, value, todos])

請注意,要把每次 render 都想成一次 function call。每一個 function 都只看到自己這次 render 的值。

因為按下 Add Todo 會用到輸入的值,所以一樣要產生出新的 function 出來。所以最一開始講的是錯誤的假設,這個 button 按的時候不需要任何東西。他需要知道現在 input 裡面的值是多少,也需要知道現在 todo 的值是多少,才能做事情。

現在,新增另外一個 state 跟這個 <MemoButton onClick={handleButtonClick}>Add todo</MemoButton> 毫不相關,他就不會 re-render。因為他跟 handleButtonClick 無關。

此外,handleButtonClick 只有 [setValue, setTodos, value, todos] 改變才會重新產生 function。

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <input type="text"/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

有時會需要傳一個物件,如下:

打字時會發現 test 也一直 re-render,為甚麼?原因是因為每次 render 都是一個新的物件,觀念參照物件 reference。舉例:{color: 'red'} === {color: 'red'} // false

function Test({style}) {
  console.log('test render')
  return <div style={style}>test</div>
}
function App() {
 略
   return (
    <div className="App">
      <Test style={{color: 'red'}} />
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}

解決方法:
法一:移到外面去,這樣一來每次 call App() 都是用同一個 const s = {color: 'red'}
缺點:s 可能會依據 value 的值不一樣,這時就不能寫在外面

function Test({style}) {
  console.log('test render')
  return <div style={style}>test</div>
}
const s = {color: 'red'}
function App() {
  略
  return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}

法二:放在 App() 裡面。

發現顏色不變時還是 re-render,這時可以藉由 useMemo 這個 hook 解決這個問題。import {useState, useRef, useEffect, useLayoutEffect, memo, useCallback, useMemo} from 'react'

留意:useMemo 是給資料用的,memo 是給 component 用的。通常會在計算量龐大時使用。舉例:今天做非常複雜的科學計算,如果 value 沒變我也要重新再執行一次,會非常耗資源。今天我使用 useMemo 我將複雜的計算包在這裡面。用法類似 useCallback,只不過是給 value 而非 function。

  const s = {color: value ? 'red' : 'blue'}
  return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

useMemo 用法:傳給他一個 function,function 回傳 s 的值 const s = {color: value ? 'red' : 'blue'},後面一樣傳 dependency array。

這樣寫法表示:當 value 改變,我會重新執行一次這個東西,然後重新回傳一個新的物件。發現這樣寫和剛剛一樣,因為 value 改了就會重新執行一次。用來舉例不太適合。

  const s = useMemo(() => {
    return {
      color: value ? 'red' : 'blue',
    }
  }, [value])

只要 value 改變就重新計算一次 s 的值,其他東西改變就都不重新計算。也就是,只有 value 改變我才重新計算並回傳 s 應該要有的值。

const redStyle = {
  color: 'red'
}
const blueStyle = {
  color: 'blue'
}
function App() {
 略
   const s = useMemo(() => {
    console.log('calculate s')
    return {
      color: value ? redStyle : blueStyle,
    }
  }, [value])
}

小結

useMemo, useCallback 都是為了確保他的 reference 值是一樣的。useRef 也是類似的東西。這是幾個和 react 效能有關的 hook。

三、React 特別的事件機制

寫 code 時會在 input 上面放 onChange 或在 button 上面放 onClick。會想像他的 click function 是放在 DOM 上面。但其實不是 !

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <Button onClick={handleButtonClick}>Add todo</Button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

dev-tool 右鍵檢查 button 確實有 click function 但將他 remove,button 的功能還是可以動。

真正的 eventListener 是放在 id = root 這層。React 是用事件代理,點擊任何東西他監聽的事件都是綁在 root 這層上面。會這麼做的原因 1. 效能好 2. 動態新增刪除東西,確保 eventListener 可以捕捉到正確資訊

所以點完成按鈕,她的事件最後是發到 root 這邊的 onClick 的 listener,再根據點擊的東西去做相對應的處理。

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

結論:React 的事件機制是綁在上面的節點。(面試可能會問)
補充:event pooling
React 16 onClick 的 e 是共用的,React 17 將這個功能給停掉。

#virtual dom #useMemo #useCallback #memo #React #re-render #react 效能 #react 事件機制






Related Posts

SQL 檢視表、常用函式與集合運算入門教學

SQL 檢視表、常用函式與集合運算入門教學

W11_怎麼做一個 Blog

W11_怎麼做一個 Blog

使用 Prometheus 和 Grafana 打造 Flask Web App 監控預警系統

使用 Prometheus 和 Grafana 打造 Flask Web App 監控預警系統






Comments