iT邦幫忙

2022 iThome 鐵人賽

DAY 28
0
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 28

[Day 28] 一次弄懂 React hooks 的運作原理與設計思維(上)

  • 分享至 

  • xImage
  •  

React hooks 從 2019 年初推出以來也經過了幾年的時間,它以非常快的速度就發展成為 React 開發方式的絕對主流選擇。搭配 function component 的設計,能夠讓資料流的可預期性以及邏輯重用性上都比起以往 class component 的開發方式來說帶來巨大的提升。

雖然 hooks 的 API 都十分的簡潔易用,但是相信大多數人都對其抱有過一些疑惑:Hooks 的運作原理到底是什麼?useState 中的資料到底是保存在哪裡?為什麼 hooks 不可以寫在條件式或迴圈中?而接下來我們將會針對這些問題進行一次深入的解析,幫助大家了解 hooks 的運作原理以及背後的設計思維。


Fiber nodes

在進入 hooks 本身之前,我們得先提到一個隱藏在 React 內部的核心機制:Fiber nodes。

你是否曾想過 component 的 local state 到底是存放在哪裡?

function App() {
  // 下面兩個 <Count> 個體分別的 count state 是獨立、不會互相影響的
  return (
    <>
     <Counter />
     <Counter />
    </>
  );
}

function Counter() {
  // 這個 local state 的值真正被保存的地方在哪裡?
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
    </div>
  )
}

如上面範例的 <Counter> 中有一個 count state,當我們每次去呼叫 setCount 來更新 state 時,component 就會在 re-render 時取得變化後的 state 值,然而這個 state 值的「本體」到底是被儲存在哪裡?而當我們的 <App> 中有多個 <Counter> 時,他們分別的 local state 顯然也是不會互相影響的。

其實在 React 所設計的抽象層中,除了以 React elements 來描述「某個歷史時刻的畫面 UI」,其實也會為每個 component 在被實際呼叫時產生一個對應的「狀態管理節點」,它在 React 中被稱為 fiber node。一個 fiber node 內容的結構大致上長得像這樣:

Untitled

你可能會想問它跟 React elements 的關係以及區別是什麼?具體來說,Fiber nodes 的工作則是負責保存並維護目前 React 應用程式的狀態資料;而 React elements 是 render 流程的產物,用於描述某個歷史時刻的畫面 UI 結構

因此每當你的 React 應用程式啟動了 reconciliation 時,reconciler 就會負責去調度 component 的 render 並將資料的改動更新到 fiber nodes 裡,並且將該次 render 出來的 React elements 與前一次 render 的 React elements 進行比較,並移交 renderer 處理真實的 DOM 更新。因此,fiber nodes 其實才是 React 應用程式的心臟,作為核心的應用程式狀態的存放本體。而 React elements 只是每次 render 時用來描述當時的畫面結構的一種可拋棄式產物。

Fiber nodes 並不是從 hooks 時代才開始出現的,而是早在 class component 時代就已經存在。以 class component 的方式宣告的 state 和以 function component + useState 宣告的 state,其實一樣都會存放在 fiber node 中。另外像是連續呼叫 setState 方法時的待執行 queue 也會被暫存在這裡。

當我們 render 一個 component 並呼叫 useState 時,React 其實會為了這個 component 建立對應的 fiber node,並在裡面去存放這個 useState hooks 的相關最新狀態資料:

function App() {
  const [count, setCount] = useState(100);

  // ...
}

https://i.imgur.com/cAIzR3U.png

而當我們呼叫 setState 時,這個 fiber node 中的資料本體就真的會被覆蓋更新。因此,每當我們的 component 再次 render 並呼叫到 useState 時,其實是嘗試將那個瞬間的 state 值「捕捉」起來並回傳,以保證 function component 在每次 render 中取出的的 state 值都是永遠不變的。

而當我們在 component 中調用多個 useState 呢?

function App() {
  const [count, setCount] = useState(100);
  const [count2, setCount2] = useState(200);
  const [count3, setCount3] = useState(300);
  
  // ...
}

https://i.imgur.com/CyRr2er.png

你會發現第二個 state 的資料居然是放在第一個 state 裡面,而第三個 state 的資料放在第二個 state 裡面?這其實就涉及的 hooks 的核心設計原理,我們也會在稍後的篇幅中進一步解析。

如果你對上面提及的 reconciler、renderer、reconciliation 等觀念並不熟悉的話,建議可以參考本系列文前面的篇章:[Day 06] Render React elements 以及 [Day 11] React 畫面更新的核心機制(下):Reconciliation

如何在 runtime 中拿到 component 對應的 fiber node

Fiber nodes 是隱藏在 React 內部機制的一種核心資料,因此我們其實並不需要在開發時與其直接的接觸。不過為了研究用途,我們其實還是可以透過一些小技巧拿到這個資料,來讓我們對於其內部一探究竟。

在 class component 中,其實只要透過 component 裡的 this._reactInternals 就能取得對應的 FiberNode

class App extends React.Component {
  render() {
    // 注意,請不要在 production code 中這樣做
    // 透過 component 裡的 this._reactInternals 就能取得對應的 FiberNode
    window.AppFiberNode = this._reactInternals;

    return <div>App</div>;
  }
}

然而這招並不能直接用在 function component 中,function component 並沒有提供可以直接取得本身 fiber node 的管道。不過由於父 component 與子 component 的 fiber nodes 之間也是相連的,所以我們其實可以透過用一個 class component 包住一個 function component 的方式來間接取得:

class App extends React.Component {
  render() {
    // 注意,請不要在 production code 中這樣做
    // 透過 component 裡的 this._reactInternals 就能取得對應的 FiberNode
    window.AppFiberNode = this._reactInternals;
    
    // <Counter> 會是 <App> 的子節點
    return <Counter />;
  }
}

function Counter() {
  const [count, setCount] = useState(0);

  console.log(window.AppFiberNode.child); // 取得 <Counter> 個體的 fiber node

  // ...
}

請注意這只是為了研究用途,滿足我們的好奇心來一探 fiber nodes 內部的模樣並觀察狀態資料的變化。千萬不要在實際的專案程式碼邏輯中嘗試去直接存取 fiber nodes,這很有可能導致 React 的內部機制運作異常


為什麼 Hooks 的運作是依賴於固定的調用順序

在認識了 React 核心的狀態存放資料 fiber nodes 後,接下來就讓我們進入到關於 hooks 的原理。相信有接觸過 hooks 的開發者們一定都清楚它有一個重要的規則:我們只能在 React function component 裡的頂層調用,不可以在條件式或迴圈裡等地方調用。為什麼會有這樣的限制呢?

讓我們透過 hooks 的實際呼叫來一窺端倪。當我們在一個 function component 中去調用多次 useState 時,大概會長得像這樣:

function App() {
  const [count, setCount] = useState(100);
  const [name, setName] = useState('default name');
  const [flag, setFlag] = useState(false);
  
  // ...
}

這好像是一段再常見不過的 React 程式碼。不過這裡我們可以思考看看一個問題:我們有嘗試告訴 React 這三個 state 分別的命名嗎?

你可能會說,當然有啊!程式碼中不是有把它們分別命名為 countname 以及 flag 嗎?

但其實當你仔細觀察程式碼後,就會發現 useState 回傳的其實是一個陣列,是我們在 useState 回傳了之後才以陣列解構的方式重新命名為指定的變數名稱的。上面的那段 component 程式碼其實等同於:

function App() {
  const state1Returns = useState(0);
  const count = state1Returns[0];
  const setCount = state1Returns[1];

  const state2Returns = useState('');
  const name = state2Returns[0];
  const setName = state2Returns[1];

  const state3Returns = useState(false);
  const flag = state3Returns[0];
  const setFlag = state3Returns[1];
  
  // ...
}

我們在調用時只告訴了 useState 這個 state 的預設值,沒有提供任何其他參數。

然而若我們並沒有告知 React 每個 state 的自定義名稱或 key 之類的資訊,那麼 React 的內部是怎麼區分這些 state 資料的存放的?讓我們到實際的 fiber node 中一探究竟:

https://i.imgur.com/eECLFqG.png

你會發現 fiber node 內部其實是以「一個 state 連著下一個 state」這種 linked list 的方式在存放這些狀態資料的。從這個結構我們其實可以觀察出,hooks 其實是以呼叫順序作為其區分並存放資料的依據。因此當你在 component 中調用多個 hooks 時,它們其實會依照你呼叫的順序依序關聯存放:

function App() {
  // component 裡的第一個 state hook
  const [count, setCount] = useState(100);

  // component 裡的第二個 state hook,可以從第一個 state hook 身上找到
  const [name, setName] = useState('default name');

  // component 裡的第三個 state hook,可以從第二個 state hook 身上找到
  const [flag, setFlag] = useState(false);
  
  // ...
}

在 fiber node 中,頂層存放的會是第一個 hook 的 state,然後你可以在其中找到第二個 hook 的 state,並又可以往下找到第三個 hook 的 state。這種資料的結構意味著如果我們在某次 render 中跳過了某個 hook 的呼叫,有可能會導致在其後面呼叫的所有 hooks 無法與前一次 render 時的 hooks 做正確的對應:

function App() {
  const [flag, setFlag] = useState(false);

  if (!flag) {
    const [foo, setFoo] = useState('foo');
  }

  const [bar, setBar] = useState('bar');
  const [fizz, setFizz] = useState('fizz');

  const handleClick = () => {
    setFlag(true);
  };
  
  return <button onClick={handleClick}>click me</button>
}

在上面的範例中,當我們點擊按鈕時並觸發 re-render 時,const [foo, setFoo] = useState('foo'); 這行 hook 調用就會因為 flagtrue 而被跳過,此時就會發生後面的 bar state 以及 fizz state 的 hooks 錯誤的與前一次 render 中的其它 hooks 對應,導致 hooks chain 不一致的問題:

https://i.imgur.com/TuOvfpu.png

此時 React 就會因為發現這次 render 中呼叫的 hooks 總數量與前一次不同,而檢查到這個問題並噴錯。這就是為什麼 component 中的所有 hooks 都必須保證在每次 render 皆會被呼叫到,一旦有某個 hook 的調用在某次 render 被跳過,其後面的所有 hooks 的順序都會跟著跳號,導致 React 內部在存取狀態資料時會有錯置的問題。

至此我們可以做個總結:React 之所以制定了 hooks 的呼叫規則,是為了讓 component 中的 hooks 被呼叫的順序在每次 render 之間都是維持固定不變的,以保證內部的狀態資料存取機制正確運作。

那我該怎麼安全的讓 hooks 不再被執行到

不過有時候我們還是會希望讓某些用不到的 hooks 不再被執行到。由於 hooks 一旦在 component 中必須保持在所有 render 中都有被呼叫到,因此唯一可以合法地讓 hooks 不再運作的方法,其實就是 unmount 呼叫了這些 hooks 的 component

function App() {
  const [isFoo, setIsFoo] = useState(true);

	return isFoo ? <Foo /> : <Bar />;
}

function Foo() {
  useEffect(() => { // ... });
}

isFootrue 變為 false 時,<Foo> component 就會被 unmount,因此裡面的 effect 也就不會再隨著 <App> 的 re-render 而被呼叫到。


至此我們已經了解到了 hooks 的狀態資料是依照調用順序去存取的,因此我們必須要保證這個順序的固定才能維持這個機制的正常運作。然而有一個更深入的問題是,React 為什麼要將 hooks API 設計成以順序性來調用,而不是設計成自定義 key 之類的方式?在下一個篇章中,我們將接著深入解析 hooks 背後的設計思維與脈絡。

參考資料


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 27] useCallback 與 useMemo 的正確使用時機
下一篇
[Day 29] 一次弄懂 React hooks 的運作原理與設計思維(下)
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言