Header 不要被页面反向控制:一次 Nuxt SSR hydration 错位复盘
刷新 Profile 页面时,Header 右上角的按钮会短暂散开。只看最终状态,很容易把它当成 CSS 问题;但控制台里同时出现了 hydration mismatch,说明服务端渲染的 DOM 与客户端首帧准备接管的 VNode 结构不一致。
错位来自 SSR 的渲染顺序和状态写入时机:
- 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 setup 阶段执行,它写入的状态被更早渲染的 Header 消费,才产生了错位。不能由此推成「SSR 里不要用 watchEffect」。需要警惕的是这组条件同时出现:
- 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
})
这个 guard 将 payload 里 late state 的消费推迟到 mounted 之后,但页面仍会把状态晚写进 payload:
- 它只在消费侧挡住了状态,没有解释状态为什么能被页面晚写进 payload。
- 后续如果另一个 computed 直接读同一份 state,仍然可能绕过 guard。
- 代码读起来像一个魔法条件,维护者必须知道这段 hydration 历史,才明白为什么 hydration 前要返回空数组。
类似地,给 slot 外面加一个稳定 wrapper 也有价值,但它只能缩小影响范围。父级 flex 容器看到的子节点稳定了,wrapper 内部如果 SSR 和 CSR 首帧还是两套结构,mismatch 依然可能发生。
Header 管 Header,页面管页面
要消除这条回写关系,需要重新划清职责:
- Header 的按钮、弹窗、点击行为,由 Header 自己管理。
- Profile 页只管理 Profile 页主体内容。
- 如果 Header 和 Profile 都需要某份用户数据,抽成明确的数据源或 store;不要让页面把 UI 指令状态写给 Header。
重构之后,Header 自己判断当前是否为 Profile 路由、当前 uid 是否为本人,再按主客态显示入口:主态显示编辑入口,客态显示更多入口。More 弹窗和确认弹窗也由 Header 内部处理。
Profile 页面只负责资料、照片墙、关系、荣誉、编辑保存后刷新页面数据。它不再注册 Header actions,也不再监听 Header 发来的 action request。
Profile 不再反向控制 Header,也就删除了导致 guard 必须存在的那条回写关系。
后来又遇到另一种 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 文档说明,预渲染 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,客户端保留两份结构,后续状态更新主要切换 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 的高度和宽度。
SSR 与 hydration 使用不同首帧状态时,可以直接用 renderer 和浏览器式 DOM 环境构造回归,分别观察 v-if 的结构错位和 v-show 的样式差异。完整内容见hydration 的完整回归用例弹窗。
总结
这两次 Header 错位都表现为 hydration 首帧不一致,原因却不同。第一次是页面 slot 晚写 payload,Header 的 HTML 已经输出,客户端却拿到了更新后的状态。让 Header 根据路由、用户数据和配置管理自己的按钮与弹窗,页面只管理主体内容,就能拆掉这条反向回写关系,页面自身的加载、失败和刷新也不必再影响 Header。
第二次没有页面回写,变化来自 SSR 与客户端首帧看到的登录态。正确的状态归属不能消除这类输入差异,key 也不能让服务端 DOM 自动变成另一种业务分支。v-show 保留两份稳定子树,可以避免按钮外壳与 children 混合,但仍可能留下显示状态 mismatch,且要接受隐藏分支同时存在的成本;ClientOnly 则避开旧 DOM 接管,代价是延后显示,并要让首屏占位和占位样式可靠地参与 SSR。
guard 和 wrapper 可以减小错位影响,不能替代对状态来源的修正。服务端状态 A、客户端状态 B 的单测用于观察 Vue 的告警和最终 DOM;真实 auth 链路验证则负责确认业务现场是否仍会触发,两者不能互相代替。
hydration 的完整回归用例
用单测稳定复现 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'
import type { Component, 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()
host.remove()
},
}
}
这个 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 机制下到底会发生什么。