如果你的首次渲染依赖 document、matchMedia、location.hash、localStorage 或者某个 cookie,那你离一次 hydration mismatch 就只差一个不一样的值 —— 这些 API 在 SSR 期间根本不存在,而在客户端的首次渲染时就已经是真实可用的了。 tsc、ESLint 和跑在 Node 环境里的单元测试照样全部通过,因为它们谁都不会真的做一次 hydration 渲染。 两个诚实的修法:挂载后再 seed(简单,会闪一帧),或者在服务端把值读出来(不闪,但需要一次 cookie 往返)。
症状
后台侧边栏。它可以收起成一条图标导轨,而且这个选择会被记住。跳到另一个后台页面,导轨会先展开一小会儿,然后啪地收回去 —— 控制台里还多了一条 hydration warning。
一切都是绿的。tsc --noEmit:exit 0。eslint:exit 0。327 个单元测试全过。这个 bug 只有用我自己的眼睛,在浏览器里,在第二次页面加载的时候才看得见。
我当时的假设
我以为「client-only API」的意思是「在 useEffect 跑起来之前都拿不到」。所以在 render 期间直接读 document.cookie 感觉还算安全:服务端会走 typeof document === "undefined" 那个分支,客户端走真实的那个分支,React 会把差异抹平。
这是错的,而且错在一个很具体的地方,而那个具体的地方就是本文的全部重点。
真正的机制
服务端渲染出来的 HTML 到达。接着 React 会做一次 hydration 渲染 —— 它在客户端把你的组件树重新跑一遍,并期望输出和收到的 HTML 一模一样。那次渲染发生在真实的浏览器里。document 存在。window.matchMedia 存在。location.hash 有值。localStorage 读得到。
所以你写成「服务端分支」的那一支,只被走过 一次,就是在 SSR 的时候。而在客户端最开始的那次渲染 —— 就是必须对得上的那一次 —— 你拿到的是真实值。如果真实值和服务端的默认值不同,两棵树就分叉了:React 要么发出警告并把文本打补丁改掉,要么干脆丢掉服务端 HTML,从最近的 Suspense 边界开始在客户端重新渲染 —— 要是一个边界都没有,那就是整个根节点。你看到的那一下闪烁,就是这次纠正。
cookie 横幅的天真版本长这样,而且类型检查完美通过:
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;服务端:"ssr" → 什么都不渲染。客户端首次渲染:"none" → 渲染出一条固定横幅。Mismatch,每一次首访都必然发生。
修法 (a):挂载后再 seed
从一个服务端也能产出的值开始,然后在 effect 里把它纠正过来。来自 levain-template/src/components/sections/auth/AccountDashboard.tsx:
// Start on "orders" so the server render and the first client render agree
// (window.location.hash is unavailable on the server). A `#favorites` (etc.)
// deep-link is honoured just after mount via the effect below, which keeps
// hydration in sync at the cost of one imperceptible tab switch.
const [tab, setTab] = useState<Tab>("orders");
useEffect(() => {
const hash = window.location.hash.replace("#", "");
const valid: Tab[] = ["profile", "orders", "addresses", "favorites"];
if ((valid as string[]).includes(hash)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only post-mount seed to keep SSR/CSR hydration in sync
setTab(hash as Tab);
}
}, []);注意那条 disable 注释。在 eslint-config-next 16.2.10(它会顺带装上 eslint-plugin-react-hooks 7)里,react-hooks/set-state-in-effect 正好会命中这种写法,因为在一般情况下这是一次白白浪费掉的渲染。这里是故意的,所以配上一条注释解释原因。
当你不想跟 linter 吵架的时候,useSyncExternalStore 就是同一个思路的原语版本 —— getServerSnapshot 的字面意思就是「SSR 以及 hydration 渲染要用的那个值」:
// booking-template/src/lib/useIsMobile.ts
function subscribe(onChange: () => void): () => void {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function getSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}React 在 hydration 那一轮用 getServerSnapshot,紧接着立刻切到 getSnapshot。同样的形状,不用跟 lint 打架,而且还白送你一个变更订阅。booking-template/src/components/landing/CookieBanner.tsx 里的 cookie 横幅用的是一个哨兵值,而不是 boolean:
function readConsent(): string {
if (typeof document === "undefined") return "ssr";
const hit = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
return hit ? (hit.split("=")[1] ?? "none") : "none";
}
const getServerSnapshot = () => "ssr";"ssr" 什么都不渲染,所以 hydration 渲染和服务端的输出是逐字节一致的。横幅会晚一拍才出现。对一条 consent 提示条来说这完全没问题。
修法 (b):在服务端读
侧边栏就不行了 —— 一条每次导航都肉眼可见地自己展开的导轨看起来就是坏的。所以这个值搬进了服务端读得到的 cookie:
// booking-template/src/lib/admin/collapsed.ts
export const ADMIN_COLLAPSED_COOKIE = "vs-admin-collapsed";
export async function getAdminCollapsed(): Promise<boolean> {
const { cookies } = await import("next/headers");
return (await cookies()).get(ADMIN_COLLAPSED_COOKIE)?.value === "1";
}后台 layout 调用它,把值通过一个 context provider 传下去,侧边栏再拿它来 seed 自己的 state:
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());现在 SSR 和 hydration 算出来都是 true。没有 mismatch,也不闪。同一条接缝也用来处理主题:locale layout 从 cookie jar 里读 vs-theme / vs-mode,把 data-theme / data-mode 直接盖到 <html> 上,所以画出来的第一帧就已经是对的主题了。
这个取舍是真实存在的,值得直说。(a) 是局部的 —— 一个文件,不用动服务端,但它会闪。(b) 永远不闪,但要求这个值住在 cookie 里,也就意味着一次客户端写入、一次导航或刷新才能生效,以及那条路由上的一次 dynamic render。当闪烁会造成 layout shift 或者关乎品牌观感时,用 (b);其他一切情况用 (a)。
还有第三个大家会忘掉的选项:干脆别在 JS 里分支。landing 的 Reveal 把隐藏状态放在 CSS 里、用 html.has-js 来把关,并让 prefers-reduced-motion 在一条 media query 里把它关掉 —— 没有 useReducedMotion 调用,也就没有什么可 mismatch 的。
CI 为什么是绿的
因为 CI 里没有任何东西会 hydrate。
booking-template/vitest.config.ts 里写着 environment: "node" 和 include: ["tests/**/*.test.ts"] —— 只有 .ts,没有 .tsx。那 327 个测试跑的都是纯函数:可用时段的计算、优惠券规则、i18n key 覆盖率。它们 不可能 观察到 hydration mismatch,因为它们从来不会把同一棵树挂载两次。tsc 看到的两个分支类型都没毛病。ESLint 看到的是一个合法的 typeof 守卫。
CI 全绿告诉我的是这段代码格式良好。至于这个功能到底能不能用,它只字未提。
检查清单
下面每一个在 SSR 期间都是 undefined/不存在,而在客户端首次渲染时都是真实的。如果其中某一个决定了你返回什么,停下来,选 (a) 或者 (b):
document—— 包括document.cookiewindow、window.location.search、window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)以及任何建立在它之上的东西,useReducedMotion也算navigator(language、userAgent、onLine)Date.now()/new Date(),当它喂进渲染输出的时候Math.random()以及任何没有种子的 id
在 render 函数体和 useState 初始化器里 grep 这些东西。就这么一个下午,这个 grep 在我自己的代码里揪出了四处。
这就是我在 booking 模板里落地的模式,在线 demo 见 booking.violettadev.com。