水合安全的客户端状态
youteacher 落地页是服务端渲染的(Next.js)。有两处 UI 依赖服务端无法得知的信息:访客保存的主题,以及上线日期是否已过。如果客户端渲染出的首帧和服务端下发的不一致,React 就会报水合(hydration)不匹配。落地页用一个统一的模式规避了这一点——useSyncExternalStore 配一个故意取安全中性值的 server snapshot,让服务端和客户端首次渲染必然一致。
主题存储
主题存放在模块级状态里,而不是 React state——Header.tsx 顶部只定义一次的一个 currentTheme 变量和一个监听器数组。一个手写的小 store 包住它:subscribe 负责增删监听器,getSnapshot 返回 currentTheme,setter 则写入新值、把它以 theme 为键持久化到 localStorage、同步到 document.documentElement 的 data-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)当作钉住那个默认值的地方——而这正是那个参数存在的意义。