iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 4

【 Day 03 】React 給 Hook 設計者設下了哪些約束?

  • 分享至 

  • xImage
  •  

昨天把臨摹要用的三把尺,也就是複雜度、資訊隱藏以及深模組建立了起來。只是我們要衡量的對象是 React Hook,它不是一般模組,它活在 React 裡,所以必須遵循 React 的規則。

那麼,React 究竟對寫 Hook 的人規定了什麼?今天讓我們來探索這些規則,看看它們是什麼、又為什麼在那裡。

寫應用程式的時候,我們大多順著框架的慣例走,很少有機會遇上。但只要開始寫一個要給別人用的 Hook,它們就會一條一條浮出來。

想省掉一次訂閱,結果整個元件掛了

假設我們要寫一個 Hook,把一個活在 React 外面的值帶進元件裡。它接收一個來源,訂閱它的變化,然後回傳目前的值。來源可能還沒準備好,所以允許傳 null

import { useEffect, useState } from "react";

type Store = {
  get: () => number;
  subscribe: (onChange: () => void) => () => void;
};

export function useStoreValue(store: Store | null): number | null {
  const [value, setValue] = useState<number | null>(store ? store.get() : null);

  if (store === null) return null;

  useEffect(() => store.subscribe(() => setValue(store.get())), [store]);

  return value;
}

這段程式碼僅用來凸顯 React 對 Hook 的約束,不是可以直接用的實作。程式碼的目的有二:訂閱來源;從來源取值,然後回傳出來。

我們透過 if (store === null) return null 來判斷是否提早回傳,如果還沒有來源,就不要執行 useEffect 來建立訂閱與清理。

接著,我們在元件裡使用它:

const Panel = ({ store }: { store: Store | null }) => {
  const value = useStoreValue(store);

  return <p>目前的值:{value === null ? "尚未接上" : value}</p>;
};

一開始傳進一個 store,畫面正常。然而,如果把 store 換成 null,那一次渲染就報錯了:

Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

「渲染的 Hook 比預期少」這句話,說明 React 其實有某種方式,可以把不同次渲染的 Hook 呼叫對應起來。那麼,React 是怎麼做到的呢?

可以先把它想成每個元件都有一連串專門給 Hook 用的位置,它們依照呼叫順序來排列。

以這段程式碼為例,第一次渲染時,React 得知這個元件有 useStateuseEffect 兩個 Hook,前者佔第一個位置,後者佔第二個位置;到了下次渲染,React 會從第一個位置開始,一個一個往下比對。

React 不需要知道我們把這些 Hook 存下來是為了什麼、裡面有什麼東西;它只需要知道:這是這個元件這次渲染的第一個 Hook、第二個 Hook。

它記的不是名字,是位置。

所以那一行提早回傳的判斷句,當條件成立後,元件就在那時回傳了 null,這使得元件在這次渲染時,React 找不到第二個位置的 useEffect,於是發出抱怨。

那麼,如果把 useEffect 拿掉,同樣的情境,React 會因為這行提早回傳而報錯嗎?

export function useStoreValue(store: Store | null): number | null {
  const [value] = useState<number | null>(store ? store.get() : null);

  if (store === null) return null;

  return value;
}

這次領走位置的只有一個 useState,且無論那行提早回傳是否成立,React 都可以比對得到它,因此這是合法的寫法。React 是靠 Hook 在呼叫序列的位置,去對應每輪渲染建立的 Hook 鏈。只要上一輪留下來的節點這一輪沒有被領完,React 就會發現。

💡 React 有一組 lint 規則(eslint-plugin-react-hooks)在撰寫程式碼時來阻擋這類寫法,所以實務上多半不會跑到執行期才發現。這裡刻意繞過它,是為了看 React 執行期實際拿什麼當依據。

對寫應用程式的人來說,這條規則的日常樣貌是「Hook 要寫在元件最上層、不要放進迴圈、條件語句」。但對寫 Hook 的人來說,它的意思要更重一些,即是:一個 Hook 提供的所有能力,在每一次渲染都得先被建立起來,即使這一次用不到。

我們沒有辦法用「這次不呼叫」來換取更便宜的成本。想讓某個功能可以被關掉,就得在別的地方付錢:多一個參數、多一層判斷,或者乾脆拆成兩個 Hook。這個取捨在後面的臨摹中會反覆出現。

值早就變了,畫面卻要等別人

第二條約束可以用一個更小的 Hook 看出來。這次我們不使用狀態,直接在 Hook 中讀取模組層級的變數,然後把它及改變它的方法回傳出來:

type Theme = "light" | "dark";

let currentTheme: Theme = "light";

export function useThemeName() {
  function setTheme(theme: Theme) {
    currentTheme = theme;
  }

  return { currentTheme, setTheme };
}

呼叫端是一個顯示目前主題、順便帶一個計數器的元件:

import { useState } from "react";

const Badge = () => {
  const { currentTheme, setTheme } = useThemeName();
  const [count, setCount] = useState(0);

  return (
    <>
      <p>主題:{currentTheme}</p>
      <p>計數:{count}</p>
      <button onClick={() => setTheme("dark")}>深色主題</button>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </>
  );
};

畫面起來之後,如果先點擊「深色主題」按鈕,試著把 currentTheme 改成 "dark",結果發現:畫面上顯示的主題依然是 light。

接著,點擊 +1 按鈕,我們會發現,主題跟計數同時改變了。結果如下:

1. 初始                  主題:light | 計數:0
2. 點擊「深色主題」之後     主題:light | 計數:0
3. 點擊「+1」之後         主題:dark  | 計數:1

第二列是這個 Hook 的問題所在,第三列則說明了它為什麼是問題:那個值其實早就變了,只是畫面要等到另一件不相干的事情發生,才順便把它帶上來。

useThemeName 誠實地回報了它被呼叫當下的值。它做不到的是讓自己再被呼叫一次。換句話說,它可以讀取資料,卻沒有能力要求 React 重新渲染。

讓畫面更新的通路只有一條,就是某個元件的狀態改變,而狀態的鑰匙在 React 手上。一個 Hook 能決定的只有「這次被問的時候要回答什麼」,不能決定「什麼時候再被問一次」。

所以任何要把外面的東西帶進來的 Hook,最後都會需要一個叫醒 React 的機關。最直覺的做法,大概是用 useState 存一份副本,再用 useEffect 去訂閱來源、值變了就寫回那份副本。

這個做法夠不夠,是這一篇留下的一個問題,之後會回來面對它。

畫面上的兩個數字,讀的是同一個變數

前面兩條約束處理的是「怎麼接進 React」,接下來還有一個問題:即使接進來了,我們能不能保證它永遠讀到同一份資料?

想像畫面上有兩個元件,一個在頁首、一個在側邊欄,各自顯示購物車裡的商品數量。它們讀的是同一個變數,呈現出來的卻是一個寫 3、另一個寫 4。

這聽起來不太合理。同一次渲染、同一個來源,怎麼會有兩個答案?

關鍵在於「同一次渲染」這幾個字,比我原本以為的寬鬆。

其實在 React 18 之後,一次渲染不一定是一口氣做完的。它可以做到一半先讓出去給比較急的事情,稍後再回來接著做,也可能整段丟掉重來,這個現象,叫做並行渲染 (concurrent rendering)。

對 React 自己管理的狀態來說,這件事不會造成困擾。在一次渲染裡,count 是一個常數,中途被讓出去再回來,它讀到的仍然是同一個值。那次渲染看到的是一張凍結的快照。

問題是那張快照只蓋得住 React 自己知道的東西。

一個模組層級的變數、一個第三方 SDK 的內部狀態、瀏覽器替我們保管的一份資料,這些都不在快照裡。渲染中途讓出去的那個空檔,外面的世界並不會跟著暫停。於是同一次渲染中,先渲染完的元件和後渲染的元件,有機會讀到同一份資料的兩個版本。

開頭那兩個數字就是這樣來的。頁首先畫完,讀到 3;渲染中途讓了出去,購物車在那個空檔多了一件;側邊欄接著畫,讀到的變成了 4。

一份資料在畫面上分裂成兩個版本,這個現象叫做撕裂 (tearing)

它很少發生。要碰上它,得同時具備一次被切開的渲染,以及剛好落在那個切口上的一次改動。所以它很容易被當成一個理論上的問題,在親眼看到之前,我們大概都不太會相信它真的會出現。

不過 React 本身把它看得相當認真。在 react-dom 的原始碼裡有一個函式叫做 isRenderConsistentWithExternalStores,這個名字很直白,檢查的就是一次渲染與外部狀態之間的一致性,但它不是免費的,必須專程去確認。

而這正是這條約束對 Hook 設計者的意義。我們在前一節得到的結論是「需要一個叫醒 React 的機關」,現在還要再加上一句:那個機關同時得保證,一次渲染裡的每一個讀取,讀到的是同一個版本。

所以 React 18 給了一個新的 Hook:useSyncExternalStore

它長什麼樣子、要餵給它什麼、那些東西為什麼是必要的,等到未來我們需要它的時候再來探索。今天只要先知道有這個東西、以及它為何而來就夠了。

這三條約束是換來的

仔細想想,這三條約束其實是同一件事的三個切面。

React 靠位置認 Hook,所以我們不能少做事;Hook 只能回答不能發問,所以我們需要一個叫醒 React 的通路;渲染可以被切開,所以那個通路還得負責一致性。

值得一提的是,這三條約束並不是 React 沒設計好留下的坑。React 選擇用宣告式的方式描述畫面,也讓渲染工作可以被中斷、重新開始;而當 React 這樣管理渲染與狀態時,Hook 就必須維持穩定的呼叫順序,外部資料也必須有明確的更新與一致性機制。

換句話說,前面看到的三條約束,正是這套設計換來的另一面。

接下來要臨摹的函式庫,規模有大有小,但它們寫下第一行的時候,面對的大概都是這三條約束。

明天,我們要進入第一個設計問題,它來自一個尋常不過的畫面:兩個元件,都需要同一份從伺服器來的資料。那份資料,到底是誰的?


上一篇
【 Day 02 】一個 Hook 的複雜度,到底從哪裡來?
下一篇
【 Day 04 】兩個元件要同一份遠端資料,這份資料是誰的?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言