一、React 的渲染機制(Reconciliation)與 Virtual DOM
(兩大重點面試會考!)
陽春版
- 新增 todo 更改資料,由資料決定畫面
- 作法:state 改變 => 清空畫面 + render => DOM
- 缺點:效能隱憂,一百個 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
- 層面一:當 component state 改變,就會再 call 一次 function,這個 function 會 return 一個東西。那麼再 call 一次 function 的行為就是 re-render
- 層面二:當 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 ++
}, [])
小結
- memo 可以將 component 給包起來
- 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,
這樣寫法的執行流程
- first render => value = ' '
- handleButtonClick 長相如下 => console.log(value) => ' '
- 打字 a => second render => value = 'a' => 再執行下面的 handleButtonClick,但因為 useCallback [],和 react 說好不管發生甚麼事情都不會產生新的 function,所以 handleButtonClick 就會是第一次的 function,又因為第一次的 function 在他的 scope 裡面他的 value = ' ',所以 console.log(value) => ' '
- 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 將這個功能給停掉。