hydration-safe-client-state

水合安全的客户端状态

youteacher 落地页是服务端渲染的(Next.js)。有两处 UI 依赖服务端无法得知的信息:访客保存的主题,以及上线日期是否已过。如果客户端渲染出的首帧和服务端下发的不一致,React 就会报水合(hydration)不匹配。落地页用一个统一的模式规避了这一点——useSyncExternalStore 配一个故意取安全中性值的 server snapshot,让服务端和客户端首次渲染必然一致。

主题存储

主题存放在模块级状态里,而不是 React state——Header.tsx 顶部只定义一次的一个 currentTheme 变量和一个监听器数组。一个手写的小 store 包住它:subscribe 负责增删监听器,getSnapshot 返回 currentTheme,setter 则写入新值、把它以 theme 为键持久化到 localStorage、同步到 document.documentElementdata-theme dataset 属性上,并通知监听器。

模块首次在浏览器加载时,一个初始化函数会从 localStorage 读出已存主题(若有)写进 currentTheme,并盖上 data-theme 属性。在服务端这段初始化被跳过——它以 window 是否存在为守卫。

useTheme 通过 useSyncExternalStore 读这个 store。它的 server snapshot 无条件返回 'light'。于是服务端总是当作主题为 light 来渲染;真正保存的主题在客户端才应用。因为 CSS 是按 data-theme 属性(在 React 渲染之外命令式地设置)取样式的,实际配色可以偏离 light 默认值,而 React 从不会看到不匹配的树。

mounted 守卫

第二个 useSyncExternalStore 纯粹用作客户端检测标志:它的 client snapshot 返回 true,server snapshot 返回 false,subscribe 是空操作。结果是在服务端和客户端首次渲染时都为 false,之后才变 true。主题切换按钮只在这个标志为真时才渲染,所以这个交互控件绝不会出现在服务端 HTML 里,也就无从引发不匹配。

上线闸门

FoundingMember.tsx 把它的行动号召(CTA)绑在一个固定的上线时间戳上(2026 年 4 月 26 日,亚洲时区偏移)。它用同一套手法:useSyncExternalStore 配空操作 subscribe、一个把 Date.now() 与上线时间戳相比较的 client snapshot,以及一个始终返回 false(未上线)的 server snapshot

于是服务端、以及上线前的每一次客户端渲染,都把 CTA 显示为一个禁用的按钮,其 tooltip 是一句上线前提示。一旦上线时间戳已过,client snapshot 翻成 true,同一个 CTA 就渲染为一个指向产品注册页的普通链接。没有定时器,也没有重新拉取——这个闸门是在客户端求值的一次纯比较,而服务端被钉在上线前状态,因此水合总能对上。

为什么是这个形状

统一的原则是:凡服务端无法得知的,都在服务端渲染成一个固定的安全默认值,真相只在客户端才到达。 主题默认 light;切换按钮在 mounted 之前隐藏;上线 CTA 默认禁用。每一处都把 useSyncExternalStore 的第三个参数(server snapshot)当作钉住那个默认值的地方——而这正是那个参数存在的意义。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →