為什麼只能在最頂端層呼叫 Hook?從 useState 實作原理來回答
2022年10月24日
在 React 的官方文件中,有特別一篇文章在講述 Hook 的使用規則,其中提到「不要在迴圈、條件式或是巢狀的 function 內呼叫 Hook」以及「只能在最頂端層呼叫 Hook」 的原因。為什麼有這些規則呢? 讓我們一起了解這個問題。
React 是如何知道哪個 state 會對應到哪個 useState 呢?
在討論 Hook 的使用規則前,我們先來看一段帶有 useState
Hook 的程式碼。讀完後想想看,React 要怎麼知道哪個 state 是對應到哪個 useState
呢?
import { useState } from "react";
export default function MyComponent() {
const [number, setNumber] = useState(0);
const [showMore, setShowMore] = useState(false);
function handleNextNumber() {
setNumber(number + 1);
}
function handleMoreClick() {
setShowMore(!showMore);
}
return (
<>
<button onClick={handleNextNumber}>Next</button>
<h3>{number + 1}</h3>
<button onClick={handleMoreClick}>
{showMore ? "Hide" : "Show"} details
</button>
{showMore && <p>Hello World!</p>}
</>
);
}
上方程式碼中,使用了兩個 useState
的 Hook,我們並不會將可識別的值傳入 useState
,那 React 是如何知道哪個 state 會對應到哪個 useState
呢? 答案是,如果每一次 Hook 的調用順序是穩定的,React 就能夠知道哪個 state
對應到哪個 useState
。
如上方例子所示,每一個 Hook 在每一次元件渲染時的調用順序都一樣,只要 Hook 的調用順序在每次渲染時保持一致,React 就能正確地將內部 state 和對應的 Hook 進行關聯。但如果今天有一個 Hook 沒有遵守 React 規範,例如:寫在 if…else
判斷式中,那每次渲染的順序可能就會產生變化,這會使得 React 無法得知每個 Hook 對應的值應該返回什麼,這將導致 state 的順序可能錯亂。這也是為甚麼,我們不該在迴圈、條件式或是巢狀的 function 內呼叫 Hook,以及只能在最頂端層呼叫 Hook。
在面試中,能夠回答到上面,基本分已經拿到。如果要跟深入說明,建議在面試中當場用程式碼舉例。我們在接下來的段落一起來深究。
React Hook 背後實作機制
在底層,React 透過一個陣列去儲存每一個元件的 state pairs,並且還會維護當前陣列的 index,在渲染之前先設定為 0。每次調用 useState 時,React 都會產生一組新的 state pair 並遞增 index。透過 index,React 就能有效知道哪個 state 是對應到哪個 useState
。
我們直接透過簡單的程式碼範例來模擬 useState
的機制(程式碼來源:React hooks: not magic, just arrays)。
// 初始化 state 空陣列
let state = [];
// 初始化 setters 空陣列
let setters = [];
// 首次渲染
let firstRun = true;
// 初始化指標值 0
let cursor = 0;
function createSetter(cursor) {
return function setterWithCursor(newVal) {
state[cursor] = newVal;
};
}
// 實作 useState
export function useState(initVal) {
// 只有首次渲染會進入以下程式碼
// state push 進 state 的陣列當中
// setters push 進 setters 的陣列當中
if (firstRun) {
state.push(initVal);
setters.push(createSetter(cursor));
// 執行完之後,將首次渲染值改為 false
firstRun = false;
}
// 透過對應紀錄好的順序,可以取出該 setter 在陣列中的值
const setter = setters[cursor];
// 透過對應紀錄好的順序,可以取出該 state 在陣列中的值
const value = state[cursor];
// 指標值 +1
cursor++;
// 最後回傳 state 值和 setter 函式
return [value, setter];
}
// Our component code that uses hooks
function RenderFunctionComponent() {
const [firstName, setFirstName] = useState("Rudi"); // 指標值: 0
const [lastName, setLastName] = useState("Yardley"); // 指標值: 1
return (
<div>
<Button onClick={() => setFirstName("Richard")}>Richard</Button>
<Button onClick={() => setFirstName("Fred")}>Fred</Button>
</div>
);
}
function MyComponent() {
cursor = 0; // 每次渲染前都會重置指標值為 0
return <RenderFunctionComponent />; // 渲染
}
console.log(state); // 渲染前: []
MyComponent();
console.log(state); // 第一是渲染: ['Rudi', 'Yardley']
MyComponent();
console.log(state); // 後續渲染: ['Rudi', 'Yardley']
// 點擊 Fred 按鈕
console.log(state); //點擊完結果: ['Fred', 'Yardley']
從上面的程式碼,我們可以看到 useState
的歷程
- 初始化: 兩個空陣列分別儲存
setters
和state
,設置指標 (cursor) 值為 0 - 首次渲染: 遍歷所有的
useState
,並將setters
放到setters
的陣列當中,將state
放進state
的陣列當中 - 重新渲染: 每次重新渲染都會重置指標值為 0,並依次從陣列中取出之前的
state
,因為先前在存放setters
與state
是依序放入的,因此只要這個順序沒變,就可以確保重新渲染後,是拿到對的setters
與state
。 - 事件觸發: 每個
setter
都有對應指標的state
值 ,因此只要有事件觸發調用到任何setter
,都會修改到此setter
到應到state
陣列中的值。因此只要順序沒有變,就會改到對的值。
上方程式碼的例子可以看到,React 背後透過指標值來記錄對應的 state
和 setter
,但如果今天我們沒有遵照 React 規範編寫 Hook 而導致 Hook 調用順序錯誤,顯而易見的,指標值也會錯誤,在這種情況下,我們得到的 state
值或 set 的 state
值也會錯誤,這就會造成 Bug 的產生。
參考資料