React 元件通常使用來自於狀態 (useState, useReducer…)、輸入屬性或上下文 (useContext) 的資料。因此,所有這些數據都由 React 管理。
但,當我們想要追蹤不被 React 管理的資料時,該如何操作?例如,如何讓 MobX 或 Zustand 等函式庫提供一個看起來被 React 自然追蹤的狀態?
追蹤外部資料
useSyncExternalStore,自 React 18 即可使用,允許讀取和追蹤來自 React 之外的資料,例如:狀態管理函式庫,或瀏覽器提供的數值(數值可能會改變)。
瀏覽器中
使用 Laravel、Inertia 以及 React Starter Kit 來建立應用程式時,前端會出現很多自動生成的程式碼。
自動生成的檔案中,在 resources/js/hooks 資料夾中可見一些 hooks。其中兩個,useIsMobile 和 useAppearance,允許追蹤瀏覽器的大小和網頁的色彩模式(淺色或深色)。
useIsMobile
一起看看 useIsMobile hook 的程式碼吧!
1 - mql : MediaQueryList
該功能的入口為 mql(MediaQueryList 實例),其中包含了所需的資訊:視窗寬度是否小於 MOBILE_BREAKPOINT - 1(即 767px)?
const mql = typeof window === "undefined" ? undefined : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);所以問題是:mql 是否 match?
建立這個媒體查詢,我們使用了一個瀏覽器 API 的功能 - window.matchMedia。所以它不是應用程式中宣告的狀態。
2 - 數值的變化
為了追蹤此資料的變化,我們會監聽媒體查詢的 change 事件:
mql.addEventListener("change", callback);因次,該媒體查詢的資訊存在應用程式之外,也在瀏覽器中,並由外部事件管理。
3 - 與 React 同步
想要同步這個資料,追蹤它的變化,所以 React 知道何時觸發需要重新算繪的元件。
這裡就是 useSyncExternalStore 派上用場的地方:
export function useIsMobile(): boolean { return useSyncExternalStore( mediaQueryListener, // subscribe isSmallerThanBreakpoint, // getSnapshot getServerSnapshot, // getServerSnapshot );}關於 subscribe 參數,mediaQueryListener 函式會將被傳遞,之後將接受 callback 參數。React 會呼叫此函式並負責提供 callback 。
function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) { //... mql.addEventListener("change", callback); //...}4 - 數值讀取
為了讀取數值,React 會使用第二個參數 - useSyncExternalStore,getSnapshot。之後,我們寫入 isSmallerThanBreakpoint 此函式。
export function useIsMobile(): boolean { return useSyncExternalStore( mediaQueryListener, // subscribe isSmallerThanBreakpoint, // getSnapshot getServerSnapshot, );}這裡想要的資訊將被返回:媒體查詢是否 match?
function isSmallerThanBreakpoint(): boolean { return mql?.matches ?? false;}此數值變化的時候,React 將知道需把使用該 hook 的元件重新算繪。
例如,Laravel 應用程式中的元件 - resources/js/components/nav-user.tsx:
export function NavUser() { //... const isMobile = useIsMobile(); // 取得媒體查詢的數值
return ( { /* ... */ } <DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" align="end" side={ isMobile ? 'bottom' // 手機的話,會顯示在底部 : state === 'collapsed' ? 'left' : 'bottom' } > {/* ... */} </DropdownMenuContent> { /* ... */ } );}5 - 伺服器端算繪
伺服器端算繪元件時,因無 window 物件,所以無法存取任何瀏覽器 API。因此 mql 會是 undefined,getServerSnapshot 會返回 false。
const mql = typeof window === "undefined" ? undefined : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
//...
function getServerSnapshot(): boolean { return false;}6 - 清理
mediaQueryListener 此訂閱函式本身就會返回另一個函式。如同 useEffect hook 返回的數值,它的作用是在兩種情況下進行清理(cleanup):
- 元件卸載
- 訂閱函式的參考變化
清理過程很簡單,將卸載 listener:
function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) { //...
// Cleanup function return () => { mql.removeEventListener("change", callback); };}訂閱函式是 mediaQueryListener,它是在 useIsMobile.tsx 檔案中所定義的,因此是在模組層級中:所以是穩定的,且參考也不會改變。因此同步只會在元件卸載時才被移除(清理)。
useAppearance
現在來看看 useAppearance hook 的程式碼。
1 - currentAppearance
currentAppearance 是包含外觀 (light、dark、system) 的資料,並且會變化。這些資料會作為 React 需要取得外觀時的 snapshot,因此是我們要在 React 中追蹤的數值。
let currentAppearance: Appearance = "system";2 - 修改
currentAppearance 可在兩種情況下被修改:
- 從
localStorage初始化外觀(不涉及 hook,會在應用程式載入時被呼叫) - 用戶互動
3 - 與 React 連結
useAppearance hook 中,會同步 currentAppearance 與 React:
export function useAppearance(): UseAppearanceReturn { const appearance: Appearance = useSyncExternalStore( subscribe, () => currentAppearance, () => "system", );
//...}subscribe 函式,將自己增加一個 listener – 當外觀變化時被呼叫。
4 - 更新
useAppearance hook 會宣告並返回一個 updateAppearance 函式:
export function useAppearance(): UseAppearanceReturn { //...
const updateAppearance = useCallback((mode: Appearance): void => { currentAppearance = mode;
// Store in localStorage for client-side persistence... localStorage.setItem("appearance", mode);
// Store in cookie for SSR... setCookie("appearance", mode);
applyTheme(mode); notify(); }, []);
return { appearance, resolvedAppearance, updateAppearance } as const;}這個函式先套用選擇的外觀,再通知已安裝 listeners:這時,React 才能觸發使用這個 hook 的元件算繪。
這樣,應用程式將會自然地與用戶所有的修改同步。
外部函式庫
透過上述兩個例子,我們可以將 React 應用程式連接到來自瀏覽器的外部資料。
但,使用函式庫時,情況又如何呢?
MobX
MobX 是個可在 React 應用程式中使用的狀態管理函式庫。
React 整合中,MobX 包含一個 Observer 元件和一個高階元件 (HOC),observer。所以,當 Observable 的數值變化時會自動觸發重新算繪。
為了連接狀態管理 (MobX 內部) 和元件的重新算繪 (React 負責),有個 useObserver hook 會使用 useSyncExternalStore hook。
因此,當 MobX 內部狀態變化時,React 會同步,並重新算繪需要的元件。
Zustand
Zustand 是另一個狀態管理函式庫,被視為 Redux 或 React Context API 的替代方案。
src/react.ts 中可見 useSyncExternalStore 的使用,用來連接狀態管理邏輯 (Zustand 專用) 與重新算繪邏輯(若有需求)。
結論
useSyncExternalStore 不會取代 React 管理的狀態,但允許追蹤 React 以外的資料,例如:由函式庫管理的狀態或瀏覽器提供的數值(來自 API 或應用程式)。