问题是什么
首页 SectionA 有两套进入动画:
- 首次访问时,六边形从上下两个方向交错进入。
- 当前标签页内重访时,所有六边形统一从上方向下进入。
是否重访通过 sessionStorage.sectionAPlayed 判断。问题发生时,React 会报告:
A tree hydrated but some attributes of the server rendered HTML
didn't match the client properties. This won't be patched up.
错误中最重要的差异不是 clip-path、will-change 等属性的书写形式,而是同一个六边形的 transform:
+ transform: translate(62%, -100%)
- transform: translate(-62%, 100%)
客户端认为它应该在右上方,服务端 HTML 却把它放在左下方。这正好对应“重访统一进入”和“首次交错进入”两套起点。
更麻烦的是,这个问题并不总出现。有时重访完全正常,有时突然报警并变成交错进入;切换黑白主题后,出现概率似乎也会变化。
最初的错误实现
问题来自客户端组件首次渲染时直接读取 sessionStorage:
const [played] = useState(() =>
!PLAY_EVERY_TIME &&
typeof window !== 'undefined' &&
sessionStorage.getItem('sectionAPlayed') === 'true'
);
随后,首帧位置直接依赖 played:
if (phase === 'initial') {
return played
? 'translate(62%, -100%)'
: index % 2 === 1
? 'translate(-62%, 100%)'
: 'translate(62%, -100%)';
}
虽然文件带有 'use client',但 Next.js App Router 仍会在服务端预渲染 Client Component。'use client' 表示这是客户端边界,并不表示组件只在浏览器中渲染。
因此同一段初始化代码会得到两个结果:
| 环境 | 能否读取 sessionStorage |
played |
|---|---|---|
| 服务端预渲染 | 不能,没有 window |
false |
| 浏览器首次渲染 | 能 | 由当前标签页的记录决定 |
当 sectionAPlayed === 'true' 时:
服务端:played = false -> 输出交错起点
客户端:played = true -> 期望统一上方起点
服务端 HTML 和客户端第一次 React 渲染从一开始就不一致,这就是根因。
如何复现
在当前标签页中清除播放记录,然后刷新:
sessionStorage.removeItem('sectionAPlayed');
location.reload();
这次服务端和客户端都得到 false,所以通常不会产生水合错误,并播放首次访问的交错动画。动画开始后,页面会写入:
sessionStorage.setItem('sectionAPlayed', 'true');
不清除记录,再进行一次硬刷新:
location.reload();
此时服务端仍只能得到 false,客户端却得到 true,水合输入已经不一致。只要 React 对这部分服务端 DOM 执行正常水合,就会复现警告。
也可以直接制造重访状态:
sessionStorage.setItem('sectionAPlayed', 'true');
location.reload();
需要注意:在原来的异步渲染结构下,“没有看到警告”不代表两端已经一致。它也可能意味着该 Suspense 边界这一次没有按相同路径完成水合。
为什么报错后反而交错进入
React 的提示中有一句很关键:
This won't be patched up.
这类属性水合不一致发生后,React 不保证把服务端已有属性修正为客户端值。此时可能出现:
- 服务端先输出交错的
transform。 - 客户端首次渲染期望统一从上方进入。
- React 发现不一致并报警,但保留了已有的服务端 style。
- 后续
phase切换为entering,目标位置变为translate(0, 0)。 - 浏览器从保留下来的交错起点执行过渡。
所以“出现水合错误”和“动画重新变成交错进入”其实是同一个问题的两个表现。
为什么它时好时坏
首页不是同步加载的。它经过了以下结构:
<ThemeProvider>
<Suspense fallback={null}>
<LazyHomePageClient />
</Suspense>
</ThemeProvider>
同时,Next.js 会把服务端渲染结果以流式响应发送给浏览器。最终行为受到多个时序影响:
HomePageClient异步 chunk 何时下载完成。- Suspense 边界何时从 fallback 切换到真实内容。
- 服务端流式 HTML 何时插入页面。
next-themes何时恢复data-theme并引起 Provider 更新。- 开发环境的模块缓存、Fast Refresh 和 React Strict Mode。
- 浏览器前进后退缓存是否直接恢复已有页面。
如果 React 正常水合了服务端的 SectionA,就会比较出不同的 transform,随后报警并可能保留交错起点。
如果这个边界因为加载时序而改走客户端渲染,浏览器会直接用 played = true 创建 DOM。此时没有同一批服务端属性可供比较,于是不报警,动画也会正确地统一从上方进入。
这就是错误时隐时现的原因:数据冲突一直存在,只是并非每次都通过同一条 React 渲染路径暴露出来。
为什么一度看起来和主题有关
实际测试中曾观察到:黑色主题容易报错,白色主题不报错。于是最初怀疑主题变量或 next-themes 改变了六边形样式。
但代码检查表明:
- SectionA 不读取当前主题。
- 主题只切换
data-theme和 CSS 变量。 - 主题逻辑不修改
sectionAPlayed。 - CSS 变量无法把
translate(62%, -100%)改成translate(-62%, 100%)。
随后使用全新的无头浏览器会话,分别预置相同的 sectionAPlayed = true 和不同主题,并在页面代码执行前拦截 sessionStorage.getItem。两个主题下,SectionA 首次客户端渲染都确实读取到了 true。
更有意义的是,隔离环境得到了与人工测试相反的结果:白色主题报警,黑色主题不报警,而且结果在该环境中可以重复。
这证明主题不是数据差异的根因。主题恢复只是改变了 Provider 和 Suspense 的调度时序,从而影响“这一次是否走到会报警的水合路径”。缓存、机器速度或开发服务器状态改变后,这种表面关联完全可能反转。
不可靠的修复方式
只加 suppressHydrationWarning
这只能隐藏警告,不能保证错误的服务端 transform 被修正。动画仍可能从交错位置开始。
用 visibility 隐藏六边形
如果仍然渲染节点,只是设置 visibility: hidden,React 依然会执行:
style={hexagonMaskStyle(index)}
也就仍然会在播放状态尚未确定时调用 getTransformStyle()。DOM 属性冲突没有消失,只是用户暂时看不到。
在 useEffect 中读取,但先渲染默认起点
服务端和客户端首帧虽然能保持一致,但重访时会先把“首次访问的交错起点”放进 DOM,再切换成“重访的上方起点”。如果浏览器绘制了中间状态,就会闪烁或播放错误动画。
通过主题分支规避
主题只是影响异步调度的旁路因素。针对主题加条件只会把问题转移到另一种缓存或加载环境中。
最终的稳定方案
解决方案是把“尚未读取”和“首次访问”分成两个不同状态:
const [played, setPlayed] = useState<boolean | null>(null);
useEffect(() => {
setPlayed(
!PLAY_EVERY_TIME &&
sessionStorage.getItem('sectionAPlayed') === 'true'
);
}, []);
null 不代表首次访问,而是代表浏览器还没有完成读取。
在此期间,六边形节点完全不创建:
<div className="hexagon-wrapper" style={hexagonWrapperStyle()}>
{played !== null && IMAGES.map((item, index) => (
<div
className="hexagon-mask relative group"
key={index}
style={hexagonMaskStyle(index)}
>
{/* ... */}
</div>
))}
</div>
这条约束很重要。在 played === null 时:
- 没有
.hexagon-maskDOM。 - 不执行
IMAGES.map()。 - 不调用
hexagonMaskStyle()。 - 不调用
getTransformStyle()。 - SSR 和客户端首帧都只输出相同的空容器。
读取完成后,React 才根据确定的 played 创建带有正确起点的节点。
为什么还需要两次 requestAnimationFrame
节点挂载后不能立刻切换到 entering。如果 React 提交初始位置后马上提交终点,浏览器可能把两次更新合并到同一帧,结果是没有过渡动画。
最终实现会等待两次动画帧:
useEffect(() => {
if (played === null || phase !== 'initial' || isLoading) return;
let enterFrame = 0;
const mountedFrame = requestAnimationFrame(() => {
enterFrame = requestAnimationFrame(() => {
setPhase('entering');
sessionStorage.setItem('sectionAPlayed', 'true');
});
});
return () => {
cancelAnimationFrame(mountedFrame);
cancelAnimationFrame(enterFrame);
};
}, [isLoading, phase, played]);
它形成了明确的渲染顺序:
SSR / 客户端首帧
played = null
只渲染空容器
↓
useEffect 读取 sessionStorage
↓
挂载六边形,并写入正确的初始 transform
↓
浏览器绘制初始位置
↓
第二个 requestAnimationFrame
↓
phase = entering,过渡到 translate(0, 0)
这样首次访问仍然从交错位置进入,重访仍然统一从上方进入,但任何六边形 DOM 都不会在播放状态确定前获得 transform。
验证结果
修复后进行了四组隔离检查:
| 主题 | 播放记录 | 六边形首次 DOM 状态 | 水合错误 |
|---|---|---|---|
| light | 无 | 上下交错 | 无 |
| light | 有 | 全部在上方 | 无 |
| dark | 无 | 上下交错 | 无 |
| dark | 有 | 全部在上方 | 无 |
同时通过 TypeScript 检查:
npx.cmd tsc --noEmit
稳定性的关键不在于让 React 忽略差异,而在于让不确定数据根本不参与 SSR 和 hydration,并确保依赖该数据的 DOM 只在数据确定后创建。
排查期间出现的次生故障
隔离测试期间还遇到过另一个 Next.js 错误:
Invariant: Expected clientReferenceManifest to be defined.
This is a bug in Next.js.
它与 SectionA 的水合逻辑不是同一个问题。原因是为了测试曾在同一个项目目录同时启动两个 next dev:原服务使用 3000,临时服务使用 3015。
端口虽然不同,但两个进程默认仍然共用同一个 .next 输出目录。它们会竞争写入:
.next/server/app/page.js
.next/server/app/page_client-reference-manifest.js
当一个进程读取到另一个进程正在重建或已经覆盖的产物时,页面路由就可能找不到与自身编译状态匹配的 client reference manifest。
处理方式是:
- 确保同一工作目录只运行一个 Next dev server。
- 停止开发服务。
- 删除项目内的
.next生成缓存。 - 重新启动开发服务,让 manifest 完整重建。
如果确实需要并行启动两个 Next 实例,应该为它们配置不同的构建目录,或者使用独立 worktree,而不能只修改端口。
最后得到的原则
这次问题可以归纳为几条规则:
- Client Component 仍可能参与服务端预渲染。
localStorage、sessionStorage、视口尺寸等浏览器数据不能决定 hydration 首帧的 HTML 属性。- “没有水合警告”不一定代表服务端和客户端一致,也可能是 Suspense 边界改走了客户端渲染。
- 动画的起始状态必须先被浏览器实际绘制,再切换到目标状态。
- 未知状态要显式建模为
null或pending,不能与业务上的false混为一谈。 - 如果某组 DOM 的属性依赖客户端数据,最稳妥的方式是在数据确定前不创建这组 DOM。
- 同一工作目录不要同时运行多个共享
.next的开发服务器。
真正稳定的修复不是消除控制台提示,而是消除产生不确定首帧的结构。