為什麼只能在最頂端層呼叫 Hook?從 useState 實作原理來回答

2022年10月24日

💎 加入 E+ 成長計畫 與超過 300+ 位軟體工程師一同在社群中成長,並且獲得更多的軟體工程學習資源

在 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 的歷程

  1. 初始化: 兩個空陣列分別儲存 settersstate,設置指標 (cursor) 值為 0
  2. 首次渲染: 遍歷所有的 useState,並將 setters 放到 setters 的陣列當中,將 state 放進 state 的陣列當中
  3. 重新渲染: 每次重新渲染都會重置指標值為 0,並依次從陣列中取出之前的 state,因為先前在存放 settersstate 是依序放入的,因此只要這個順序沒變,就可以確保重新渲染後,是拿到對的 settersstate
  4. 事件觸發: 每個 setter 都有對應指標的 state 值 ,因此只要有事件觸發調用到任何 setter ,都會修改到此 setter 到應到 state 陣列中的值。因此只要順序沒有變,就會改到對的值。

上方程式碼的例子可以看到,React 背後透過指標值來記錄對應的 statesetter,但如果今天我們沒有遵照 React 規範編寫 Hook 而導致 Hook 調用順序錯誤,顯而易見的,指標值也會錯誤,在這種情況下,我們得到的 state 值或 set 的 state 值也會錯誤,這就會造成 Bug 的產生。

參考資料

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們