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 }
,只有當 onclick
或 children
有變的話,才會 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 有 onclick
和 children
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")
);
會用事件代理的方式有兩個原因:
- 效能比較好
- 因為會在頁面上動態新增、刪除元素,所以要用事件代理的機制,確保 event listener 可以捕捉到正確的資訊