昨天把臨摹要用的三把尺,也就是複雜度、資訊隱藏以及深模組建立了起來。只是我們要衡量的對象是 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 得知這個元件有 useState 及 useEffect 兩個 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 就必須維持穩定的呼叫順序,外部資料也必須有明確的更新與一致性機制。
換句話說,前面看到的三條約束,正是這套設計換來的另一面。
接下來要臨摹的函式庫,規模有大有小,但它們寫下第一行的時候,面對的大概都是這三條約束。
明天,我們要進入第一個設計問題,它來自一個尋常不過的畫面:兩個元件,都需要同一份從伺服器來的資料。那份資料,到底是誰的?