Header 不要被页面反向控制:一次 Nuxt SSR hydration 错位复盘
这次问题一开始看起来只是 Header 右上角错位:刷新 Profile 页面时,右上角按钮会短暂散开,像是刚进页面那一下还没摆好。要是只看最终状态,很容易把它当成 CSS 问题;但控制台里同时出现了 hydration mismatch,这就把问题从「样式没对齐」推到了另一个层面:服务端渲染出来的 DOM,和客户端首帧准备接管的 VNode,不是同一棵树。
真正的问题不在某一个按钮,也不在某一个 flex gap,而在这条链路里:
- layout 先渲染 Header。
- 页面 slot 后渲染。
- 页面在渲染过程中写了 Header 要消费的
useState。 - 这个写入没有影响已经输出的 Header HTML,却进入了 Nuxt payload。
- 客户端 hydration 首帧从 payload 恢复状态,Header 直接切到了另一套结构。
也就是说,服务端 HTML 和 Nuxt payload 表达了同一次 SSR 里的两个不同时间点:Header 渲染时的世界,和 page slot 写完 state 后的世界。
最小模型
可以把问题压缩成下面这个模型。layout 里 Header 在 slot 前面:
<!-- layouts/demo.vue -->
<template>
<Header />
<slot />
</template>Header 读一个 Nuxt useState,决定右上角是普通按钮还是 Profile actions:
<!-- components/Header.vue -->
<script setup lang="ts">
const headerMode = useState('header-mode', () => 'normal')
</script>
<template>
<button v-if="headerMode === 'normal'">Language</button>
<div v-else>Profile Actions</div>
</template>Profile 页面在 setup 里写这份状态:
<!-- pages/profile.vue -->
<script setup lang="ts">
const headerMode = useState('header-mode', () => 'normal')
watchEffect(() => {
headerMode.value = 'profile'
})
</script>
<template>
<main>Profile</main>
</template>看单个文件都合理:Header 读状态,Profile 写状态,useState 负责 SSR/CSR 同步。但组合起来,顺序错了。
SSR 渲染 Header 时,slot 还没执行,所以 headerMode 还是 normal,HTML 输出的是:
<button>Language</button>SSR 继续渲染 Profile,watchEffect 同步执行,把 headerMode 改成 profile。这次写入已经来不及影响前面输出的 Header HTML,却会被 Nuxt 序列化到 payload。
客户端 hydrate 时,useState('header-mode') 从 payload 恢复成 profile,于是 Header 首帧想要的是:
<div>Profile Actions</div>同一个位置,服务端是 button,客户端是 div。Vue 只能一边告警,一边尽力复用和修补现有 DOM。Header 又处在首屏 flex 布局里,节点类型和数量一变,就变成肉眼可见的错位。
这不是 watchEffect 的原罪
一个容易过度归纳的结论是:SSR 里不要用 watchEffect。
这个说法太粗了。watchEffect 在 SSR 中同步执行一次是正常行为,它可以安全地做本组件内部派生,例如根据 props 算一个展示值。真正危险的是这组条件同时出现:
- effect 写入跨组件状态;
- 写入目标会进入 Nuxt payload;
- 这个状态被 layout/header 等更早渲染的组件消费;
- 客户端首帧会根据这个状态切换 DOM 结构。
如果 effect 只影响当前组件内部,通常不会有这个问题。如果写入的是页面自己的异步状态,也不一定有问题。危险点在于:后渲染的 slot 子树,写了前渲染的 layout/header 首帧要消费的 UI 状态。
useState 适合保存什么
Nuxt 的 useState 很有用。它适合保存「SSR 期间已经参与首屏输出、客户端首帧也应该复用」的状态,例如:
- 服务端预取的页面数据;
- 当前 locale、主题、实验配置;
- 当前请求内多个组件都要读取的首屏轻量数据;
- 需要避免服务端和客户端重新随机生成的值。
但它不适合当作所有跨组件通信的默认方案。尤其是这类状态:
- Header 点击通知当前页面打开弹窗;
- 页面 mounted 后给 Header 注册按钮;
- toast、popover、action request;
- 只在浏览器里存在的权限、焦点、滚动状态。
这些状态更像 client-only interaction bridge。它们不应该进入 SSR payload,更不应该决定首屏 DOM 结构。
这次问题落在 useState 的使用场景上:代码把「页面注册 Header 按钮」这种 UI 指令状态放进了 useState。它晚于 Header SSR HTML 写入,又早于客户端 hydration 首帧生效,于是刚好卡在最危险的位置。
为什么加 guard 只是止血
最开始可以用 mounted guard 暂时挡住问题:
const hydrated = ref(false)
const headerActions = computed(() => {
if (!hydrated.value) return []
return state.value.actions
})
onMounted(() => {
hydrated.value = true
})这能让客户端 hydration 首帧不要立刻消费 payload 里的 late state。它是有效补丁,但不是好架构。
原因有三点:
- 它只在消费侧挡住了状态,没有解释状态为什么能被页面晚写进 payload。
- 后续如果另一个 computed 直接读同一份 state,仍然可能绕过 guard。
- 代码读起来像一个魔法条件,维护者必须知道这段 hydration 历史,才明白为什么 hydration 前要返回空数组。
类似地,给 slot 外面加一个稳定 wrapper 也有价值,但它只能缩小影响范围。父级 flex 容器看到的子节点稳定了,wrapper 内部如果 SSR 和 CSR 首帧还是两套结构,mismatch 依然可能发生。
所以 guard 和 wrapper 都是「减灾」,不是「消除触发条件」。
最终解法:Header 管 Header,页面管页面
真正把问题拆掉的方式,是把职责重新划清:
- Header 的按钮、弹窗、点击行为,由 Header 自己管理。
- Profile 页只管理 Profile 页主体内容。
- 如果 Header 和 Profile 都需要某份用户数据,抽成明确的数据源或 store;不要让页面把 UI 指令状态写给 Header。
重构之后,Header 在 Profile 路由下自己判断:
- 当前是不是 Profile 路由;
- 当前 uid 是不是自己;
- 主态显示编辑入口;
- 客态显示更多入口;
- More 弹窗和确认弹窗由 Header 内部处理。
Profile 页面只负责资料、照片墙、关系、荣誉、编辑保存后刷新页面数据。它不再注册 Header actions,也不再监听 Header 发来的 action request。
这一步没有把 guard 藏到更深的地方,而是删除了导致 guard 必须存在的关系:Profile 不再反向控制 Header。
这个结论能推多远
可以说,只要后续都按这个边界做,同类问题基本不会再出现。
这里的「同类问题」指的是:后渲染的页面 slot,在 SSR 中写入前渲染的 layout/header 要消费的 payload state,导致服务端 HTML 和客户端 hydration 首帧结构不一致。
如果 Header 的首帧结构只依赖 Header 自己能稳定拿到的输入,比如 route、当前登录用户摘要、静态配置,那么 page slot 再怎么异步加载自己的数据,都不会影响 Header 首帧结构。Profile 页内部可以 loading,可以失败,可以重试,可以刷新;这些都属于 Profile 自己的页面状态,不会把 Header 带进 hydration mismatch。
这也说明,问题不只是「不要用 useState」。更通用的规则是:
- 不要让后渲染子树反向决定前渲染布局的首帧结构。
- 共享数据可以,共享 UI 指令状态要非常谨慎。
- layout/header 的结构应该由 layout/header 自己能稳定得到的信息决定。
- 如果一个 guard 需要长注释解释为什么 hydration 前不能读某个状态,优先追问是不是职责边界错了。
- wrapper 能缩小破坏半径,但不能替代 SSR/CSR 首帧一致。
- 遇到 hydration mismatch,先还原 SSR DOM、payload state、客户端首帧 VNode 和组件渲染顺序,不要先调 CSS。
我会怎么判断下一次
以后遇到类似问题,我会先问几个问题:
- 这块 DOM 是谁在 SSR 里先渲染的?
- 客户端首帧读取的状态,是否来自 SSR 后半段才写入的 payload?
- 这个状态是页面数据,还是 UI 指令?
- 当前组件是否在通过全局状态反向控制父级 layout?
- 能不能把结构决策放回结构拥有者那里?
如果答案指向「页面在控制 Header」「子树在控制 layout」「交互桥进入 payload」,那就不要先想着补 guard。guard 可以帮忙止血,但真正稳定的解法通常是把职责拆回去。
这次最后得到的规则很朴素:Header 管 Header,页面管页面。需要共享时共享数据,不共享 UI 指令。SSR 项目里,这条规则不只是架构洁癖,它直接决定首屏 HTML 和 hydration 首帧能不能对上。
