iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

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

【 Day 17 】呼叫端還需要知道什麼?|瀏覽器 API(二)

  • 分享至 

  • xImage
  •  

昨天我們從 useLocalStorage 這個 Hook,來看 react-use、usehooks-ts 與 @react-hookz/web 的實作概念,而它們分別給出了不同的答案。

三種答案都能動,那差別到底在哪裡?

不過,我們昨天只看了一個地方:寫入之後,另一個元件怎麼知道。

而無論是這三個函式庫,還是我們一開始自己寫的版本,都站在同一個前提上:頁面第一次渲染時,就可以直接存取 localStorage。

這對在瀏覽器才產生畫面的單頁應用程式 (Single Page Application, SPA) 來說很正常,但如果這個頁面現在必須先在伺服器上渲染過一次呢?

讓我們把 Day 16 的 App 原封不動地交給伺服器先渲染一次,看看會發生什麼。

把同一個 Hook 交給伺服器

伺服器端渲染 (server-side rendering, SSR) 是先在伺服器上執行一次元件,把結果變成 HTML 送到瀏覽器。Next.js 這類框架預設就會這樣做,不必另外設定。

如果不透過框架,直接呼叫 React 提供的 renderToString,做的是同一件事:

import { renderToString } from "react-dom/server";
import { App } from "./App";

const html = renderToString(<App />);

執行之後,伺服器丟出了這樣的錯誤:

ReferenceError: localStorage is not defined

伺服器上沒有瀏覽器,也就沒有 localStorage。由於 useState 的初始值函式在伺服器上照樣會執行,所以它一讀 localStorage 就丟錯。如果 Hook 裡寫的是 window.localStorage,訊息就會變成另一句更常見的錯誤:window is not defined。

先判斷是不是在瀏覽器裡

既然如此,我們可以先判斷有沒有 window,它的值是 undefined 的話就不讀。我們在模組載入時判斷一次,伺服器上直接用初始值:

import { useState } from "react";

const isServer = typeof window === "undefined";

export function useLocalStorage(
  key: string,
  initialValue: string,
): [string, (value: string) => void] {
  const [value, setValue] = useState(() =>
    isServer ? initialValue : (localStorage.getItem(key) ?? initialValue),
  );

  const set = (next: string): void => {
    localStorage.setItem(key, next);
    setValue(next);
  };

  return [value, set];
}

伺服器不再丟錯,負責切換主題的 ThemeSwitch 與負責顯示主題的 ThemeLabel,都以 light 渲染成 HTML。目前,問題似乎解決了。

但是,假使使用者上次已經把主題切成 dark,再重新整理頁面,我們會在主控台看到這樣的錯誤:

Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:

- A server/client branch `if (typeof window !== 'undefined')`.

這是使用 SSR 時常見的水合錯誤 (hydration error)。

瀏覽器收到伺服器送來的 HTML 之後,React 會在瀏覽器裡把元件再執行一次,接手那份已經存在的 HTML,這一步叫做水合。水合時,React 預期第一次渲染的結果跟伺服器產生的 HTML 一模一樣。如果不一樣,就會報告這個錯誤。

伺服器上沒有 localStorage,所以渲染出來的是 light;瀏覽器裡的第一次渲染讀到了 dark。兩邊對不上,React 只好丟掉伺服器的那一份,在瀏覽器裡重新產生這棵元件樹。

錯誤訊息底下還列了幾種常見的原因,第一條就是我們剛剛加上的那種判斷。

改成掛載之後才讀

那麼,如果第一次渲染先不讀,等掛載之後再讀呢?

import { useEffect, useState } from "react";

export function useLocalStorage(
  key: string,
  initialValue: string,
): [string, (value: string) => void] {
  const [value, setValue] = useState(initialValue);

  useEffect(() => {
    setValue(localStorage.getItem(key) ?? initialValue);
  }, [key, initialValue]);

  const set = (next: string): void => {
    localStorage.setItem(key, next);
    setValue(next);
  };

  return [value, set];
}

這次伺服器與瀏覽器的第一次渲染都是 light,水合不再出錯。useEffect 執行之後,畫面才換成 dark。

可是,這個代價不只落在先經過伺服器端渲染的頁面上。一個從頭到尾只在瀏覽器裡執行的應用程式,原本第一次渲染就讀得到 dark,現在也得先渲染一次 light,畫面可能會閃一下。

Hook 看不到的那件事

兩種寫法,各自對一種頁面是對的:第一次渲染就讀,適合只在瀏覽器裡執行的頁面;掛載之後才讀,適合先在伺服器上渲染過的頁面。

那麼,Hook 能不能自己判斷,現在是哪一種頁面?

在 useState 的初始值函式裡,Hook 看得到的是現在有沒有 window。可是水合的時候,window 已經在了。這一次渲染是在接手伺服器產生的 HTML,還是一般的第一次渲染,從這裡分辨不出來。

這個頁面有沒有先在伺服器上渲染過,知道的是呼叫端。

所以,這不是伺服器上少了 window 的問題,而是有一件事,Hook 自己看不到,呼叫端卻知道。

那麼,把 localStorage 包成 Hook 之後,呼叫端還需要知道什麼?

讓我們回到 react-use、usehooks-ts 與 @react-hookz/web 這三個函式庫,看看它們怎麼面對伺服器端渲染,以及另一件包裝時容易被略過的事:存進去的東西讀不回來的時候。

三個函式庫怎麼面對伺服器

前面兩次嘗試,其實拆出了兩件事:伺服器上不能丟錯,以及水合前後要一致。三份實作同樣用簡化過的版本來看。

react-use:在呼叫任何 Hook 之前就返回

react-use 在模組載入時判斷一次 isBrowser。不在瀏覽器裡,就直接把初始值交回去:

const isBrowser = typeof window !== "undefined";

export function useLocalStorage<T>(
  key: string,
  initialValue?: T,
): [T | undefined, SetValue<T>, () => void] {
  if (!isBrowser) {
    return [initialValue, noop, noop];
  }

  const [state, setState] = useState(() => initializer.current(key));
  // ⋯⋯
}

initializer 是讀取 localStorage 的那個函式,後面還會再看到它。

這裡的 return 發生在任何 Hook 呼叫之前。按照 Hook 規則的一般寫法,這種條件式提早返回會讓 Hook 呼叫順序不穩定。

react-use 則刻意利用 isBrowser 在同一個執行環境裡不會變這件事,在伺服器,提早返回每次都會成立;而在瀏覽器,條件則每次都不會通過。因此,這裡的呼叫順序其實是固定的。

伺服器上不丟錯了。可是到了瀏覽器,第一次渲染一樣在 useState 的初始值函式裡讀 localStorage,跟我們加上 isServer 判斷的版本相同,水合時會對不上。

其實,react-use 在這之後還會用 useLayoutEffect,在掛載與 key 改變時重新讀一次 localStorage。但那發生在第一次渲染之後,並不能讓伺服器渲染與瀏覽器水合的第一次結果一致。

usehooks-ts:把選擇做成一個選項

usehooks-ts 也在模組載入時判斷 IS_SERVER,伺服器上直接回傳初始值。不同的是,第一次渲染要不要讀 localStorage,它沒有替呼叫端決定:

const [storedValue, setStoredValue] = useState(() =>
  initializeWithValue ? readValue() : resolveInitial(),
);

useEffect(() => {
  setStoredValue(readValue());
}, [key]);

前面的兩種寫法都在這裡。initializeWithValue 是 true,第一次渲染就讀;是 false,就先用初始值(resolveInitial),等掛載之後的 useEffect 再讀。

在原始碼 useLocalStorage.ts 裡,這個選項的型別是這一行:

initializeWithValue?: boolean

它的預設值是 true。這一行上方的註解寫著,在伺服器端渲染的情境下,應該把它設成 false。

@react-hookz/web:在模組層換掉整個 Hook

usehooks-ts 在 Hook 裡面判斷伺服器,@react-hookz/web 則在 Hook 外面就判斷完了。它的 useLocalStorageValue 在模組載入時,先探測 localStorage 能不能用:

let IS_LOCAL_STORAGE_AVAILABLE: boolean;
try {
  IS_LOCAL_STORAGE_AVAILABLE = isBrowser && Boolean(globalThis.localStorage);
} catch {
  IS_LOCAL_STORAGE_AVAILABLE = false;
}

export const useLocalStorageValue = IS_LOCAL_STORAGE_AVAILABLE
  ? (key, options) => useStorageValue(localStorage, key, options)
  : () => ({ value: undefined, set: noop, remove: noop, fetch: noop });

能用的話,useLocalStorageValue 就是把 localStorage 交給 useStorageValue 的那個函式;不能用的話,它整個換成一個什麼都不做的版本,value 是 undefined。伺服器上走的是後者,存取 localStorage 會丟錯的瀏覽器環境也是。

至於水合,useStorageValue 也有 initializeWithValue 選項,預設同樣是 true。設成 false 時,第一次渲染的 value 是 undefined,掛載之後才讀,正好對得上伺服器上那個什麼都不做的版本。

讓三份實作跑同一組伺服器情境

三份的寫法看完了,接著讓它們實際跑一次。pnpm test 會讓三份臨摹跑同一組情境,印出一張紅綠表。✓ 表示這份實作替呼叫端承擔了這件事,✗ 表示沒有。

跟伺服器有關的情境,設定都是:伺服器上沒有 localStorage,瀏覽器的 localStorage 裡已經存了值,就像重新整理一個用過的頁面。印出來是這三列:

情境 react-use usehooks-ts @react-hookz/web
伺服器端渲染不丟錯 ✓ ✓ ✓
預設選項下,水合前後一致 ✗ ✗ ✗
呼叫端關掉初次讀取(initializeWithValue: false)後,水合前後一致 ✗ ✓ ✓

第一列三個實作都是綠的。window 不存在這件事,三個函式庫都替呼叫端擋下來了。

第二列則相反,三個實作都是紅的。因為預設選項下,三份的第一次渲染都會去讀 localStorage,所以跟我們加上 isServer 判斷的那個版本一樣,水合時對不上。

第三列才分開:usehooks-ts 與 @react-hookz/web 留了一個開關,react-use 沒有。

三個函式庫,沒有一個替呼叫端決定水合該怎麼辦。

這與前面看到的事情是一致的:這個頁面有沒有先在伺服器上渲染過,在 useState 的初始值函式裡看不出來。

我們在 Day 02 那篇文章提過,介面寬度算的不只是參數個數。initializeWithValue 讓介面多了一格,而這一格放的,正是那件 Hook 看不到、呼叫端才知道的事。

react-use 的介面上沒有這一格。那件事並沒有因此消失,只是呼叫端得在 Hook 外面自己處理。

存進去的東西讀不回來

伺服器那件事,Hook 自己看不到,所以交回給了呼叫端。接下來這件事,Hook 自己看得到:存在 localStorage 裡的字串,解析失敗了。

這不需要刻意製造。昨天 useLocalStorage Hook 的例子,為保持簡單,存進去的是字串 dark,沒有經過 JSON.stringify。但其實三個函式庫預設都是用 JSON 存取,假使它們讀到的是純字串的 dark,JSON.parse 就會丟出語法錯誤:

SyntaxError: Unexpected token 'd', "dark" is not valid JSON

如此,畫面上就會顯示預設的 light。使用者上次選的 dark 不見了,localStorage 裡存的 dark 卻還在。

結果一樣,那差別在哪裡呢?

react-use:什麼都不留下的 catch

這是前面提到的 initializer:

const initializer = useRef((key: string): T | undefined => {
  try {
    const raw = localStorage.getItem(key);
    if (raw !== null) return JSON.parse(raw) as T;
    if (initialValue) localStorage.setItem(key, JSON.stringify(initialValue));
    return initialValue;
  } catch {
    return initialValue;
  }
});

try 包住的不只有 JSON.parse。原始碼在這個 catch 裡的註解寫著,在隱私模式或有儲存限制的時候,localStorage 本身也會丟錯,JSON.parse 與 JSON.stringify 也會。這些情況全都落進同一個 catch,交回初始值,什麼都沒有留下。

所以從呼叫端看過去,「從來沒存過」、「存了但讀不懂」與「localStorage 不能用」,拿到的都是同一個初始值。

usehooks-ts 與 @react-hookz/web:失敗時交回的值從哪裡來

另外兩份同樣退回初始值,但會在主控台留下一行。usehooks-ts 的 deserialize 是這樣:

const deserialize = (raw: string): T => {
  if (options.deserializer) return options.deserializer(raw);
  if (raw === "undefined") return undefined as T;
  try {
    return JSON.parse(raw) as T;
  } catch (error) {
    console.error("Error parsing JSON:", error);
    return resolveInitial();
  }
};

@react-hookz/web 的 defaultParse 是這樣:

const defaultParse = <T>(raw: string | null, fallback: T | null): T | null => {
  if (raw === null) return fallback;
  try {
    return JSON.parse(raw) as T;
  } catch (error) {
    console.warn(error);
    return fallback;
  }
};

兩段做的事情很接近:JSON.parse 失敗,印一行,交回某個值。差別在那個值從哪裡來。

usehooks-ts 的 deserialize 只收一個字串,失敗時交回的初始值,是從 Hook 裡面拿的。「沒有這個 key」也不經過它:readValue 讀到空的,就直接用初始值,不呼叫 deserialize。

@react-hookz/web 的 defaultParse 則多收一個 fallback。「沒有這個 key」(raw 是 null)與「讀不懂」走的是同一個函式,交回的都是傳進來的那個值。

這個差別,在呼叫端想換掉預設的解析方式時才會浮現。兩個函式庫留給呼叫端的選項,型別分別是:

// usehooks-ts
deserializer?: (value: string) => T;
// @react-hookz/web
parse?: (raw: string | null, fallback: T | null) => T | null;

@react-hookz/web 的 parse 一拿到參數就知道兩件事:raw 可能是 null,而讀不懂的時候,有一個 fallback 可以交回去。這個函式要負責什麼,簽名上已經寫出來了。

usehooks-ts 的 deserializer 只收一個字串。而且一旦傳了它,deserialize 的第一行就把整件事交出去,後面對 "undefined" 的特別處理與 console.error 都不會執行(它丟出的錯,仍然會被 readValue 外層的 try 接住)。要知道自己換掉了哪些東西,得回去讀 Hook 的本體。

印出來的紅綠表裡,跟解析失敗有關的是這兩列:

情境 react-use usehooks-ts @react-hookz/web
存的不是合法 JSON:退回初始值 ✓ ✓ ✓
存的不是合法 JSON:留下痕跡(console) ✗ ✓ ✓

「留下痕跡」看的只是主控台。三份都沒有把解析失敗交回給呼叫端,呼叫端從 Hook 拿到的,仍然只是一個值。

呼叫端還需要知道什麼

把伺服器與解析失敗放在一起,三個函式庫的呼叫端,各自還需要知道這些事:

  • react-use:這個頁面有沒有先在伺服器上渲染過;Hook 沒有開關,所以水合要在 Hook 外面自己處理;拿到初始值的時候,分不出是沒存過、讀不懂,還是 localStorage 不能用。
  • usehooks-ts:這個頁面有沒有先在伺服器上渲染過,以及要把 initializeWithValue 設成 false;換掉 deserializer 時,要知道自己換掉了哪些處理。
  • @react-hookz/web:這個頁面有沒有先在伺服器上渲染過,以及同一個 initializeWithValue;換掉 parse 時要負責的事,寫在它的簽名上。

三份都把「這個頁面有沒有先在伺服器上渲染過」留給了呼叫端。在它們的寫法裡,那是 Hook 看不到的事。

除此之外,三個函式庫對讀取失敗的處理也不一樣:react-use 直接吞掉,usehooks-ts 留下 console.error,@react-hookz/web 留下 console.warn。後兩者都讓呼叫端換掉解析方式,而 @react-hookz/web 還多交出一個 fallback。

介面多一個選項,不只是多一個參數。它代表模組願意把哪一個決策交給誰。

Day 16 看的是寫入之後誰會知道,今天看的是伺服器上與讀不回來的時候。到目前為止,我們看到的是:Hook 替呼叫端承擔得越多,呼叫端知道的事情可能越少;但它同時也替呼叫端做了更多決定。

那麼,模組做得更深,真的只是把複雜度藏起來嗎?

還是它其實也把某些決定一起藏掉了?

深一點,總是比較好嗎?

💡 文中三個函式庫的片段都是節錄,省略了錯誤處理與型別細節,也不是 production-ready 的實作;我們自己的兩個修法版本也只是為了把兩種讀取時機並排。

本文對照 usehooks-ts 3.1.1、react-use 17.6.1、@react-hookz/web 26.1.0,查核於 2026-08-31;文中引用的原始碼行號與註解查核於 2026-10-02。錯誤訊息與水合的行為跑在 react 19.3.0、react-dom 19.3.0 與 Node.js 24.12.0,查核於 2026-10-02。


上一篇
【 Day 16 】兩個元件用同一個 key,為什麼一邊改、另一邊不知道?|瀏覽器 API(一)
下一篇
【 Day 18 】那,深模組有沒有代價?|瀏覽器 API(三)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言