一個 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?
建立這個媒體查詢,我們使用了 window.matchMedia,這是一個瀏覽器 API 的功能。所以它不是應用程式中宣告的狀態。
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 返回的,它的角色是清理它安裝的事情,在兩種情況下:
- 元件卸載
- 訂閱函式參考的變化
清理過程很簡單,會刪除安裝的 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 或應用程式)。