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。
后来又遇到另一种 Header 错位
前面的解法处理的是「页面反向控制 Header」。这类问题可以通过 owner 边界拆掉:Header 自己决定 Header 的结构,页面不再通过 payload state 注册 Header actions。
后来同一个项目里又出现了另一种更棘手的 Header 错位:右上角账号入口在测试环境里偶发变成黑色纯文字,DOM 里能看到头像按钮的根节点,里面却塞进了登录按钮的图标和文字。
这一次,Header owner 没有再被页面反向控制。真正不稳定的是登录态首帧:
- SSR 阶段只能根据 cookie、静态 session 或本次 request 能拿到的轻量信息判断登录态。
- 项目规则不允许 SSR 为了 Header 额外打业务接口,所以服务端无法知道浏览器启动后会不会立刻发现 token 失效、被踢登或账号态过期。
- 客户端 hydration 首帧可能在插件、auth guard 或已有缓存恢复后切到另一种登录态。
这就变成了一个更一般的问题:结构 owner 是对的,但结构依赖的首帧状态不可完全确定。
Vue 的 SSR 文档把 hydration mismatch 说得很直接:预渲染 HTML 的 DOM 结构如果和客户端应用期望输出不一致,就会触发 mismatch。Vue 会尝试恢复,但它仍然建议开发阶段尽量消除 mismatch。这里的麻烦是,登录态不是随机数,也不是非法 HTML;它是一个业务上真实会变化的外部状态。
为什么 key 不够
这个账号入口曾经是典型的互斥分支:
// 简化后的形态
return isLoggedIn.value
? h(AvatarButton, { key: 'avatar' })
: h(SignInButton, { key: 'sign-in' })直觉上,加了 key 之后,Vue 应该知道这是两个不同按钮。这个判断在普通客户端 diff 里通常成立。Hydration 多了一层前提:真实 DOM 已经由服务端 HTML 生成好了,客户端第一步是在现有 DOM 上认领节点。
如果服务端先输出头像按钮:
<button class="account-avatar">
<span class="account-avatar__name">Avatar</span>
</button>客户端首帧却认为应该是登录按钮:
<button class="account-sign-in">
<span class="account-sign-in__icon"></span>
<span class="account-sign-in__text">Log In</span>
</button>两边根节点都是 button。key 能告诉 Vue 这两个 vnode 身份不同,但它不能改变服务端已经输出过一个 button 这个事实。hydration 阶段仍要面对「当前位置已有一个 button,客户端也要一个 button,但它们不是同一个业务分支」。
真实问题里,这种错位最后表现成:根节点 class 还像头像按钮,children 却像登录按钮。CSS 文件本身存在,登录按钮的样式规则也存在;只是 DOM 根节点没有登录按钮的 block class,.account-sign-in[...] 这种选择器当然命不中。
这也是为什么只看 CSS source 容易误判。样式并没有丢,丢的是 DOM 结构和 class 语义的一致性。
v-show 能缩小结构破坏
为了避免两个同根 button 分支互相混 children,账号入口改成了稳定子树:
<template>
<div class="account-host">
<AvatarButton v-show="isLoggedIn" />
<SignInButton v-show="!isLoggedIn" />
</div>
</template>这个改法先降低破坏形态,不承诺让登录态 mismatch 消失。两个按钮都参与 SSR,客户端只 patch display。即使服务端和客户端首帧登录态不同,至少不会再把登录按钮 children 塞进头像按钮根节点。
这条边界很重要:v-show 解决的是「结构混合」,不是「首帧状态一定一致」。如果 SSR 判断为已登录、CSR 首帧判断为未登录,Vue 仍然可能报告 style mismatch。Vue 为了性能,不会保证生产环境主动修正这类初始 style。最终正确状态仍然要靠上游 auth 链路尽量稳定,或者把这块渲染从 SSR 树里移出去。
所以 v-show 是一个低成本结构护栏。它适合下面这种条件:
- 互斥分支根节点相同,hydration 可能复用错外壳;
- 两个分支都比较轻,SSR 同时输出成本可接受;
- 隐藏分支不会误触发昂贵请求、副作用、音视频、地图或第三方 SDK;
- 业务可以接受 DOM 里同时存在两个按钮,只通过可见性切换。
ClientOnly 是另一种边界
如果某块 Header 真的是非核心模块,也不要求 SSR 首屏可交互,把它包进 Nuxt 的 <ClientOnly> 是更彻底的边界:服务端不输出这棵子树,客户端直接 fresh mount,没有 hydration 认领旧 DOM 的过程。
但它有两个工程细节不能省。
第一,要保留布局占位。直接让 Header 右侧空掉,再等客户端 mount 出按钮,会把 CLS 从「结构错位」换成「布局后移」。更稳的写法是让 fallback 或外层容器占住同样高度和大致宽度:
<template>
<ClientOnly>
<HeaderRightActions />
<template #fallback>
<div class="header-right-actions-placeholder" aria-hidden="true" />
</template>
</ClientOnly>
</template>.header-right-actions-placeholder {
width: 220px;
height: 44px;
}第二,<ClientOnly> 默认插槽会从 server build 里 tree-shake 掉。Nuxt 文档也提醒过:这意味着组件内部使用的 CSS 可能不会被内联到初始 HTML。也就是说,fallback 的占位样式最好放在 <ClientOnly> 外层所属组件里,或者用一个本来就会 SSR 的稳定父组件承载,不要指望只被 client 组件引用的样式一定出现在首屏 HTML。
所以它适合这类模块:
- 对 SEO 没价值;
- 首屏少显示几十到几百毫秒可以接受;
- SSR 无法稳定知道状态;
- 组件内部副作用只应该在浏览器发生;
- 能用 fallback 保留尺寸,避免明显 CLS。
对于全站 Header,整块包进 ClientOnly 在技术上可行,真正要判断的是产品体验:搜索、语言、登录入口、金币钻石这些是不是首屏必须稳定出现。如果只是账号入口,优先局部包;如果整个 Home Header 都是非核心工具条,整块 client-only 也可以接受,但一定要先定义 fallback 的高度和宽度。
用单测稳定复现 hydration
这类问题最痛苦的地方,是测试环境复现条件很窄。它通常需要「服务端首帧状态 A,客户端 hydration 首帧状态 B」,还可能受到 cookie、踢登、插件执行顺序和缓存影响。靠 QA 在测试环境里撞,效率很低。
更稳的方式是用 @vue/server-renderer 直接在单测里搭一条完整的 SSR + hydration 链路。核心步骤只有四个:
- 用
createSSRApp()和renderToString()渲染服务端 HTML。 - 把 HTML 塞进
happy-dom的真实 DOM 容器。 - 再用另一个
createSSRApp()以客户端首帧状态 mount 同一个容器。 - spy
console.warn/console.error,同时断言最终 DOM。
最小测试 helper 可以写成这样:
// tests/unit/components/header-account-hydration.test.ts
import { renderToString } from '@vue/server-renderer'
import { createSSRApp, nextTick, ref } from 'vue'
async function renderServerAndHydrateClient(params: {
createComponent: (isLoggedIn: Ref<boolean>) => Component
serverIsLoggedIn: boolean
clientIsLoggedIn: boolean
}) {
const serverState = ref(params.serverIsLoggedIn)
const serverHtml = await renderToString(
createSSRApp(params.createComponent(serverState)),
)
const host = document.createElement('div')
host.innerHTML = serverHtml
document.body.append(host)
const clientState = ref(params.clientIsLoggedIn)
const clientApp = createSSRApp(params.createComponent(clientState))
clientApp.mount(host)
await nextTick()
return { host, serverHtml, unmount: () => clientApp.unmount() }
}这个 helper 专门把 Vue hydration 这一步单独拿出来观察,不复刻 Nuxt 全部运行时。Header 这类问题往往正好卡在这一步:服务端 HTML 已经在 DOM 里,客户端首帧 vnode 要接管它。
用它可以直接写出三个用例:
v-if / v-else + key:服务端已登录、客户端未登录时,仍然会出现 hydration mismatch,并且 DOM 可能保留旧按钮外壳。v-show且服务端 / 客户端状态一致:没有 hydration mismatch,两个稳定子树都存在,只是 display 不同。v-show但服务端 / 客户端状态不一致:仍然有 style mismatch,但不会再混合两个按钮的 children。
这比「等测试环境偶现」更可控。测试环境负责证明真实业务链路是否还会触发;单测负责证明某个结构方案在 Vue hydration 机制下到底会发生什么。
这个结论能推多远
可以说,只要后续都按这个边界做,第一类问题基本不会再出现。
这里的「同类问题」指的是:后渲染的页面 slot,在 SSR 中写入前渲染的 layout/header 要消费的 payload state,导致服务端 HTML 和客户端 hydration 首帧结构不一致。
如果 Header 的首帧结构只依赖 Header 自己能稳定拿到的输入,比如 route、当前登录用户摘要、静态配置,那么 page slot 再怎么异步加载自己的数据,都不会影响 Header 首帧结构。Profile 页内部可以 loading,可以失败,可以重试,可以刷新;这些都属于 Profile 自己的页面状态,不会把 Header 带进 hydration mismatch。
第二类问题要单独处理。Header 自己拥有结构,不代表它依赖的状态一定能在 SSR 和 CSR 首帧保持一致。登录态、浏览器本地缓存、用户时区、权限状态、窗口尺寸、媒体能力、A/B 实验实时结果,都可能属于服务端无法完整知道的输入。
所以问题不只是「不要用 useState」。更通用的规则是:
- 不要让后渲染子树反向决定前渲染布局的首帧结构。
- 共享数据可以,共享 UI 指令状态要非常谨慎。
- layout/header 的结构应该由 layout/header 自己能稳定得到的信息决定。
- 结构 owner 正确但首帧状态不可确定时,要把不确定性隔离到稳定子树、mounted gate 或
ClientOnly里。 - 如果一个 guard 需要长注释解释为什么 hydration 前不能读某个状态,优先追问是不是职责边界错了。
- wrapper 能缩小破坏半径,但不能替代 SSR/CSR 首帧一致。
- 遇到 hydration mismatch,先还原 SSR DOM、payload state、客户端首帧 VNode 和组件渲染顺序,不要先调 CSS。
- 能用
@vue/server-renderer单测固定的,不要只依赖测试环境偶现。
我会怎么判断下一次
以后遇到类似问题,我会先问几个问题:
- 这块 DOM 是谁在 SSR 里先渲染的?
- 客户端首帧读取的状态,是否来自 SSR 后半段才写入的 payload?
- 这个状态是页面数据,还是 UI 指令?
- 当前组件是否在通过全局状态反向控制父级 layout?
- 能不能把结构决策放回结构拥有者那里?
- 如果结构 owner 已经正确,服务端是否仍然无法知道首帧状态?
- 这块 UI 是不是足够非核心,可以接受
ClientOnly和占位 fallback? - 能不能用
renderToString()把服务端状态 A、客户端状态 B 的组合写成单测?
如果答案指向「页面在控制 Header」「子树在控制 layout」「交互桥进入 payload」,那就不要先想着补 guard。guard 可以帮忙止血,但真正稳定的解法通常是把职责拆回去。
如果答案指向「结构 owner 已经正确,但服务端就是不知道浏览器首帧会是什么状态」,那就不要再假装 SSR 能算准。该输出稳定双子树就用 v-show,该推迟到浏览器就用 mounted gate 或 ClientOnly,该保留空间就给 fallback 明确尺寸。
这次最后得到的规则仍然很朴素:Header 管 Header,页面管页面;首帧不可确定的 UI,不要让 SSR 输出一棵语义上可能立刻过期的互斥树。需要共享时共享数据,不共享 UI 指令。SSR 项目里,这条规则不只是架构洁癖,它直接决定首屏 HTML 和 hydration 首帧能不能对上。
