《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理
前言
承接上篇 [React] 單向資料流介紹以及 component 初探,此篇主要敘述的是《React 思維進化》 2–8 ~ 2-9 章節的筆記,若有錯誤歡迎大家回覆告訴我~
2–8 ~ 2–9 章節剛好也是我在讀書會負責導讀的章節,有另外做一份簡報,也可以參考看看簡報,雖然內容大同小異XD,簡報連結點此🔗。
什麼是 state
在前端應用中,很常會遇到使用者與網頁互動,進而使網頁產生變化的情境,例如:商品加入購物車、下拉選單選擇選項、Modal 視窗的開與關等等,我們需要記錄這些「可更新的資料」以維持應用的正常運作,在資料更新時連動更新畫面區塊,這樣的資料通常稱為應用程式的「state(狀態資料)」。
而所謂「在資料更新時連動更新畫面區塊」就對應了單向資料流中,資料更新驅動畫面更新的概念,原始資料是畫面更新的啟動點,而 React 中的 state 就是扮演原始資料這樣的角色,當 state 這個原始資料變動時,才會連動畫面更新,示意圖如下:
在上篇文章有提到,React 採取一律重繪策略,當資料更新時會一律重繪 Virtual DOM,也就是 React element,再透過比較新舊 React element 來找出真正需要被更新的實際 DOM element 有哪些,然而,如果每次資料更新時都要重繪整個應用的 React element,會在一些不必要的地方上浪費效能,因此 React 應該只需重繪跟被更新資料有關的畫面區塊即可。
那要怎麼知道哪些畫面跟被更新的資料有關? React 會以 component 作為 state 運作的載體與一律重繪的界線,state 需依賴在 component 才能記憶狀態並維持狀態資料的正常運作,且當 state 更新並啟動重繪時,React 只會重繪該 component (包含其子孫 component) 以內的畫面區塊。以下圖為例,假設 App component 內有 3 個 Counter component,當第一個 Counter component 呼叫 setCount
觸發資料更新時(🔸setState
的方法在文章後面會介紹),只會觸發第一個 Counter component 的更新:
state 是依附在 component 上運作的,其生命週期也會隨著 component 一起存在或消亡,也可想成 state 是 component 內部記憶資料的地方。
在 function component 中,我們可透過 useState
來定義和取得 state,接下來來看看 useState
是什麼吧~
認識 useState
開發者可透過 useState
這個 hook 來定義或更新應用的狀態資料,當狀態資料被更新,就會觸發該 component 區塊內的 React element 重繪,進而連動更新實際畫面。可將 useState
想成是在 component 內註冊並存取資料的一種工具。
補充:Hooks 是什麼?
Hooks 是 React 提供的 API,只能在 function component 的最頂層才能被呼叫,React 透過 Hooks 來注入一些核心功能到 component 中。Hooks 的一些特殊行為與限制,會在後續章節提到。
useState
怎麼用? useState
只能在 component function 的最頂層作用域被呼叫,呼叫範例如下:
import { useState } from 'react';
export default function App(props){
//將 useState 回傳的值做陣列解構,第一個元素是「該次 render 的當前 state 值」,第二個元素是「用來更新 state 值的 setState 方法」,是一個函式
//useState()傳入的參數 initialState 是 state 的初始值,可以是任意型別的值
const [state, setState] = useState(initialState);
//...
}
useState
的參數:useState
會接收一個參數(上述範例中的 initialState),此參數是 state 的初始值,可以是任意型別的值useState
的回傳值:回傳值是一個陣列,包含兩個項目
- 第一個項目是「該次 render 的當前 state 值」
- 第二個項目是「用來更新 state 值的setState
方法」,是一個 JavaScript 函式。這個setState
方法可用來更新 state 的值,呼叫setState
並傳入新的 state 值作為參數,會觸發 component 的 re-render 機制。
開發慣例上,會將回傳的陣列做陣列解構來取得 state 值和setState
方法,並根據自身需求來重新命名變數。舉例來說,如果有個狀態資料是儲存照片輪播的 index 值,就把 state 值命名為 index,setState
方法命名為 set 開頭、資料名結尾:
const [index, setIndex] = useState(0);
useState
應用範例
以一個計數器範例來說明 useState
的應用,完整程式碼如下:
import { useState } from "react";
export default function Counter() {
//呼叫 useState 定義一個 state,來記憶計數器的值,且初始值為0
const [count, setCount] = useState(0);
const handleDecrementButtonClick = () => {
//以參數指定新的state值為目前count-1
setCount(count - 1);
};
const handleIncrementButtonClick = () => {
//以參數指定新的state值為目前count+1
setCount(count + 1);
};
return (
<div>
<button onClick={handleDecrementButtonClick}>-</button>
{/* count是useState取出的state的值,count一開始會是我們給的初始值0 */}
<span>{count}</span>
<button onClick={handleIncrementButtonClick}>+</button>
</div>
);
}
其中,因為「計數器的值」是一種會隨使用者互動而更新的資料,且會連動畫面更新,所以我們要以 state 定義這資料,呼叫 useState
並傳入初始值 0,接著,我們希望使用者點擊按鈕時可觸發資料更新,所以要綁定點擊事件。
onClick
事件綁定
在 React 中,我們可透過 React element 來間接管理和綁定事件到實際 DOM element 上。
如何綁定事件? 在對應實際 DOM element 類型的 React element 上新增 onClick
prop,並傳遞事件處理函式給這個 prop。簡單範例如下,點擊按鈕時就會觸發我們傳給onClick
prop 的 handleClick
函式:
const MyButton = () => {
const handleClick = () => {
console.log("click!");
};
return(
<button onClick={handleClick}>click me</button>
)
}
補充:只有對應實際 DOM element 的 React element 才會內建事件綁定的 prop
React element 可分為三種類型,分別為:
(1) 可對應實際 DOM element,如:<h1>
、<p>
、<button>
(2) 對應自定義的 component function,如:<MyComponent >
(3) Fragment 類型,也就是<>
或<Fragment>
上述三種類型中,只有第一種對應實際 DOM element 的 React element 才會內建事件綁定的 prop (事件綁定如:onClick
、onSubmit
…),只有傳遞事件綁定給這類 React element 時,React 才會自動幫你把事件綁定到該 DOM element 。例如: 在對應<button>
的 React element 傳遞onClick
prop:<button onClick={handleClick}>
,React 會自動綁定事件到該 button 上,類似幫你做button.addEventListener(“click”, handleClick);
。
此外,若對自定義的 component 傳遞事件綁定的 props,例如:<MyComponent onClick={handleClick} />
則不會有任何事件綁定效果,只是剛好自定義的 props名稱也叫onClick
而已,React 不會自動幫你綁定事件。而如果是對 Fragment 類型的 React element 傳遞事件綁定一樣沒有任何意義,Fragment 不接受 key 以外的 props。
(補充:Fragment 還可以接受 children props 哦,不然我們要怎麼包住多個 JSX 結構呢👀,但官方文件的確只有提到 key props)
setState
方法
點擊按鈕後,我們需要呼叫setState
方法來觸發狀態資料更新,並連動畫面更新,setState
方法是 useState
回傳的陣列中的第二個項目,我們可將要更新的值傳給 setState
作為參數,讓 setState
觸發 component 的 re-render。
setState
的參數是一個要更新的新值 nextState,可以是任何型別的值,但如果傳入的是一個函式,這函式會被視為 updater function,這個 updater function 會拿到一個 pending state 作為參數,並回傳要更新的新值 nextState。(關於 updater function 的說明可見官網範例)
當 setState
觸發 component 的 re-render 時,會重新執行 component function,並產生新版本的 React element,而當 component function 重新執行時,會再次執行到 useState
,此時得到的回傳值 state 就是新的 state 值(也就是上次 setState
傳入的新值)。
以上面的 Counter 範例來說,在第一次 render 時,state 會是初始值 0
,handleDecrementButtonClick
中的 setCount(count-1)
其實就是 setCount(0-1)
,而handleIncrementButtonClick
中的setCount(count+1)
則是 setCount(0+1)
,示意圖如下:
當點擊按鈕 + 觸發 handleIncrementButtonClick
事件後,component function 會 re-render,此時 useState
回傳的值就是新的值 1
,示意圖如下:
補充:呼叫
setState
後,React 不會立即觸發 re-render,而是等正在執行的事件內所有程式結束後,才會開始執行 re-render,因此會聽到「setState 方法是非同步的」這種說法。因為 React 是採用 batching 的機制來將更新排入佇列,之後才安排一次執行所有更新。
state 的補充觀念
Hooks 的限制useState
是 React 提供的 hooks API,而 React 的所有 hooks 都有嚴格限制:
- 只能在 component function 內被呼叫,hooks 需依賴 component 才能運作
- 只能在 component function 的頂層作用域被呼叫,不能在條件式、迴圈或 callback 函式中呼叫
function MyComponent() {
//✅ 合法的 hooks 呼叫: 在 component function 的頂層作用域呼叫
useState();
if(...){
//⛔️ 非法的 hooks 呼叫,沒有在 component function 的頂層作用域呼叫
useState();
}
for(...){
//⛔️ 非法的 hooks 呼叫,沒有在 component function 的頂層作用域呼叫
useState();
}
}
useState(); // ⛔️ 非法的 hooks 呼叫,沒有在 component function 內呼叫
而為何 hooks 有這些限制? 為了要確保 hooks 機制正確運作,沒遵守這規定可能導致資料丟失問題,詳細原理後面章節會討論。
為什麼 useState
的回傳值是一個陣列
前面有提到,useState
回傳的值是一個陣列:[該次 render 的當前狀態值, 更新狀態值的 setState 方法]
,將回傳值定義為陣列是為了讓開發者在呼叫 useState
後,能更方便的將回傳值賦值給自定義的變數。
如果回傳的不是陣列、而是其他資料型別呢? 因為 useState
會回傳兩個值,需要用陣列或物件這種集合的方式來回傳資料,而如果回傳的是物件,每次都需要解構回傳值、並為物件屬性賦予別名,會造成語法上不簡潔,且若多次使用 useState
,每個 state 都需要記得賦予別名,以避免名稱衝突。
以下是假想回傳物件的情況,非真實用法:
//⛔️ 非真實 useState 用法
const {state, setState} = useState();
//物件解構時為屬性賦予別名
const {state: count, setState: setCount} = useState(0);
const {state: isOpen, setState: setIsOpen} = useState(false);
因此,React 將 useState
的回傳值設計為陣列,我們可直接將 useState
的回傳值印出看看:
function MyComponent() {
const stateArray = useState("hello React");
console.log(stateArray);
}
印出的就是一個陣列,第一個值是該次 render 的狀態值,第二個值是一個函式(也就是setState
方法):
開發慣例上,可用 JavaScript 內建的陣列解構賦值語法來快速指定變數名稱。
setState
方法是更新 state 值並觸發 re-render 的唯一合法手段setState
是唯一的更新 state 方式,因為 state 是單向資料流的起點,當 state 變更後,才會驅動 React 重繪 React element,並更新對應的畫面區塊。
如果不透過 setState
方法,自己修改 state 值會怎樣? 那 React 就不知道你修改了 state 的值,因此不會觸發 React 的 re-render,也無法讓對應的畫面更新,導致資料與畫面不同步,單向資料流可靠性被破壞,因此務必要使用 setState
來更新 state 並觸發 re-render。
React 如何辨認同一個 component 中的多個 state
我們可以在 component function 內多次呼叫 useState
來定義不同的 state 資料,且每個 state 的值互不影響:
const [count, setCount] = useState(0);
const [name, setName] = useState('Chair');
但我們沒有給 id 或 key 這類可以辨識的值,React 怎麼知道哪次的 useState
要回傳哪個 state 的資料? 以上述範例來說,我呼叫第一次 useState
的時候,React 怎麼知道要回傳給我 count 的 state 資料,而不是 name 的資料?
因為 component 的所有 hooks 在每次 render 都會依賴固定的呼叫順序,以區別彼此,這也對應前面所提的 hooks 限制:「hooks 只能在 component function 頂層作用域被呼叫」,因為在頂層作用域呼叫 hooks,才可以保證每次 render 時,hooks 都會被呼叫、且執行順序固定不變。如果 hooks 寫在 if 內,當 if 條件不符合時就不會執行,if 條件符合才會執行,就會導致每次執行 component function 時,hooks 的執行順序不固定,hooks 機制會無法正常運作。
因此當我們多次呼叫 useState
和其他 hooks 時,React 記得的是「第一個呼叫的 hook」、「第二個呼叫的 hook」這種順序,是以順序來區別 hooks。以上述範例來說,React 是記得「第一個呼叫的 useState
」要回傳 count 的 state 資料,「第二個呼叫的 useState
」要回傳 name 的 state 資料。
回到問題,React 如何在多次 hooks 呼叫中,找出對應的資料?
→ React 根據 hooks 呼叫的順序,依序找出對應的資料。
同一個 component 的同一個 state,在該 component 的不同實例間的狀態資料獨立
在上篇文章有提到,component 是一種藍圖,可透過藍圖產出實例,產出的實例互不影響,而 state 作為依附在 component 上的資料,透過 component 藍圖產出的實例所擁有的 state 資料也彼此獨立、互不影響。
舉例來說,如果在 App 中呼叫 3 次 <MyComponent />
,當第一個 <MyComponent />
中的 state 值變動,不會影響第二個 <MyComponent />
的 state 值。
補充,關於 state 與 props 的差異,以以下表格說明:
React 畫面更新的機制:reconciliation
Render phase 與 Commit phase
React 的畫面處理機制可分為兩階段:
- 產生一份描述最新畫面結構的 React element
對應 component 的處理機制,稱為「render phase」,在 render phase,component 會渲染並產生 React element。 - 將 React element 轉換為畫面上實際的 DOM element
對應 component 的處理機制,稱為「commit phase」,在 commit phase,component 會將 React element 提交並處理到瀏覽器的實際 DOM element。
接著以產生初始畫面和更新畫面兩種情況,來看這兩種情況的 render phase 和 commit phase 發生什麼事。
產生初始畫面時:首次 render component
📍 Render phase:
執行 component function,以 props 與 state 資料來產生初始畫面的 React element,並將產出的 React element 交給 commit phase。
📍 Commit phase:
第一次 render 時,瀏覽器畫面還沒有此 component 對應的實際 DOM element,因此會將 render phase 回傳的 React element 全部轉換、建立成實際 DOM element,再透過瀏覽器 API appendChild()
放到實際畫面上。(e.g. 假設這 component 在 render phase 回傳的 React element 是 <h1>Hello React</h1>
,在第一次 render 時,這時畫面根本還沒有這個<h1>
的 DOM element,所以要全部轉換並渲染到畫面)
這段「component 首次 render 並 commit 到實際 DOM」的過程也稱為「mount」,而 mount 完成的狀態稱為「mounted」,「mounted」代表 component render 已完成,且已「掛載」到瀏覽器畫面。
在 mounted 後,才能在瀏覽器結構中找到 component 對應的那些 DOM element(承上述例子,mounted 後,才能在瀏覽器找到 <h1>Hello React</h1>
這個 DOM element)。
更新畫面時:re-render component
📍 Render phase:
再次執行 component function,以新的 props 與 state 重新產生新的 React element,比較新版本 React element 和上一次 render phase 產生的舊版本 React element,找出差異處,將差異處交給 commit phase 繼續處理。
📍 Commit phase:
只操作、更新新舊 React element 的差異處對應的實際 DOM element,其餘 DOM element 不動。
更新畫面的情況是 React 畫面管理機制的精髓,通常把 React 更新畫面的流程稱為「reconciliation」,接著就來看看 reconciliation 的流程吧~
Reconciliation
🌰 以一個 Counter component 作為接下來說明的範例:
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0); // state 值初始值為 0
const handleDecrementButtonClick = () => {
setCount(count - 1);//以參數指定新的 state 的值為目前 state 值-1
};
const handleIncrementButtonClick = () => {
setCount(count + 1);//以參數指定新的 state 的值為目前 state 值+1
};
return (
<div>
<button onClick={handleDecrementButtonClick}>-</button>
<span>{count}</span>
<button onClick={handleIncrementButtonClick}>+</button>
</div>
);
}
首次 render 時,count (state 值)預設是 0
,在 render phase 會產生如下的 React element:
<div>
<button onClick={handleDecrementButtonClick}>-</button>
<span>0</span>
<button onClick={handleIncrementButtonClick}>+</button>
</div>
接著在 commit phase 就會將以上的 React element 完整轉換為實際 DOM element,渲染至瀏覽器畫面上。
後續畫面更新則進入 reconciliation 的過程。
reconciliation 步驟一:呼叫 setState
方法更新 state 資料,並發起 re-render
在呼叫 setState
要更新資料時,React 會先以 Object.is()
比較要更新的 state 值和舊的值是否相同(參考:Object.is()):
- 如果相同,判定資料沒有更新,也不需驅動畫面更新,會直接中斷後續流程,不會觸發 re-render
- 如果不同,判定資料需驅動畫面更新,執行 component function 的 re-render
🌰 以上述 Counter component 為例,點擊 increment button 後,會執行 setCount(count+1)
,當前 count 值是 0
,所以就是執行 setCount(0+1)
,此時舊的 count 值是 0
,新的值是 1
,React 會以Object.is(0,1)
比較新舊值是否相同,得到比較結果為 false
,因此觸發 component function 的 re-render。
reconciliation 步驟二:更新 state 資料並 re-render component function
根據傳給 setState
的新 state 值來更新資料,以新的 props 與 state 再次執行 component function。
🌰 以上述 Counter component 為例,以 setCount(1)
傳入的新值 1
來更新 state 的值,再次執行 Counter 的 component function,這次 re-render 呼叫 useState
回傳的 count 就是新值 1
,此時會得到新版的 React element:
<div>
<button onClick={handleDecrementButtonClick}>-</button>
<span>1</span>
<button onClick={handleIncrementButtonClick}>+</button>
</div>
reconciliation 步驟三:比較新舊版本的 React element ,並更新差異處對應的實際 DOM element
以 diffing 演算法比較 re-render 的新版 React element 和上一次 render 的舊版 React element,找出兩者差異處,差異處對應的實際 DOM element 才是真正要操作的畫面區塊。
diffing 演算法找出差異處後,在 commit phase,React 會操作需要被更新的 DOM element,完成畫面更新,其他 DOM element 則不動,降低操作 DOM 產生的效能消耗。
🌰 以上述 Counter component 為例,比較 Counter component 兩次 render 的新舊 React element,找出差異處是 <span>
內的文字,因此只有 <span>
這個 DOM element 需要被操作和更新。
補充,diffing 演算法如何運作? React 的 diffing 演算法採用 heuristic algorithm(啟發式演算法),這個啟發式演算法採用 2 個假設,讓時間複雜度達到 O(n):
- 兩個不同類型的 element 會產生不同 tree
(如果 element 類型不同就不用深入比較子樹,可避免再去比較更深子樹的時間) - 開發者可透過 key prop 來指出哪些 child element 在不同 render 下能保持不變
(看到一樣的 key 值,React 就判斷是一樣的元素)
diffing 演算法會先比較新舊 React element tree 的 root element,不同類型的 root element 有不同的處理方式:
- element 類型不同:例如 element 從
<div>
變成<p>
時,React 會將舊的element (<div>
)銷毀,並建立新的 element (<p>
)。 - element 類型相同:比較元素的 attribute,只更新有變動的 attribute。
- 相同類型的 component:保留元件實例不變,state 和生命週期狀態會被保留,如果 props 改變,會根據需要重新渲染 component。
針對 children 的遞迴處理,可參考 React 在這裡的說明,也有提到 key prop 的重要性,為避免展開太多就先簡要說明 diffing 演算法到這~
Reconciliation 流程示意圖
以下圖說明 Reconciliation 完整流程:
setState 觸發的 re-render 會觸發子 component 的 re-render
當 setState
觸發 re-render,重新執行 component function 時,如果該 component 內有子 component,也會觸發子 component 的 re-render。
舉例來說,如果 App Component 內有 Counter Component,當 App 的 state 透過 setState
更新並觸發 App Component 的 re-render 時,也會觸發 App Component 內的 Counter Component 的 re-render。
因此,component 在以下情況會被觸發 re-render:
- 作為有 state 且呼叫
setState
的 component:component 本身有定義 state,該 state 對應的setState
方法被呼叫時(且要更新的 state 值和既有的值不同)。 - 作為子 component,被父代以上的 component re-render 影響: component 本身沒有因為自己的
setState
方法被呼叫而 re-render,而是 component 的父代或祖父代的 component 發生 re-render,因而觸發子 component 的 re-render。 - (補充)使用 context 而 context 的值發生變化時,有依賴該 context 的component 就會 re-render。
此點沒有在書中特別提及,但其實 context 的值也會影響 component 的 re-render,更多說明請見官方文件。
Reference:
- https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state
- https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment
- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/is
- https://zh-hant.legacy.reactjs.org/docs/reconciliation.html
如有任何問題歡迎聯絡、不吝指教✍️