Header 不要被页面反向控制:一次 Nuxt SSR hydration 错位复盘

这次问题一开始看起来只是 Header 右上角错位:刷新 Profile 页面时,右上角按钮会短暂散开,像是刚进页面那一下还没摆好。要是只看最终状态,很容易把它当成 CSS 问题;但控制台里同时出现了 hydration mismatch,这就把问题从「样式没对齐」推到了另一个层面:服务端渲染出来的 DOM,和客户端首帧准备接管的 VNode,不是同一棵树。

真正的问题不在某一个按钮,也不在某一个 flex gap,而在这条链路里:

  1. layout 先渲染 Header。
  2. 页面 slot 后渲染。
  3. 页面在渲染过程中写了 Header 要消费的 useState
  4. 这个写入没有影响已经输出的 Header HTML,却进入了 Nuxt payload。
  5. 客户端 hydration 首帧从 payload 恢复状态,Header 直接切到了另一套结构。

也就是说,服务端 HTML 和 Nuxt payload 表达了同一次 SSR 里的两个不同时间点:Header 渲染时的世界,和 page slot 写完 state 后的世界。

SSR 与 hydration 错位的三层结构

最小模型

可以把问题压缩成下面这个模型。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 布局里,节点类型和数量一变,就变成肉眼可见的错位。

Header 与页面 slot 的 SSR 时间线

这不是 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。它是有效补丁,但不是好架构。

原因有三点:

  1. 它只在消费侧挡住了状态,没有解释状态为什么能被页面晚写进 payload。
  2. 后续如果另一个 computed 直接读同一份 state,仍然可能绕过 guard。
  3. 代码读起来像一个魔法条件,维护者必须知道这段 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。

通过职责重构拆掉 hydration 触发条件

后来又遇到另一种 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>

两边根节点都是 buttonkey 能告诉 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 链路。核心步骤只有四个:

  1. createSSRApp()renderToString() 渲染服务端 HTML。
  2. 把 HTML 塞进 happy-dom 的真实 DOM 容器。
  3. 再用另一个 createSSRApp() 以客户端首帧状态 mount 同一个容器。
  4. 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」。更通用的规则是:

  1. 不要让后渲染子树反向决定前渲染布局的首帧结构
  2. 共享数据可以,共享 UI 指令状态要非常谨慎
  3. layout/header 的结构应该由 layout/header 自己能稳定得到的信息决定
  4. 结构 owner 正确但首帧状态不可确定时,要把不确定性隔离到稳定子树、mounted gate 或 ClientOnly
  5. 如果一个 guard 需要长注释解释为什么 hydration 前不能读某个状态,优先追问是不是职责边界错了
  6. wrapper 能缩小破坏半径,但不能替代 SSR/CSR 首帧一致
  7. 遇到 hydration mismatch,先还原 SSR DOM、payload state、客户端首帧 VNode 和组件渲染顺序,不要先调 CSS
  8. 能用 @vue/server-renderer 单测固定的,不要只依赖测试环境偶现

我会怎么判断下一次

以后遇到类似问题,我会先问几个问题:

  1. 这块 DOM 是谁在 SSR 里先渲染的?
  2. 客户端首帧读取的状态,是否来自 SSR 后半段才写入的 payload?
  3. 这个状态是页面数据,还是 UI 指令?
  4. 当前组件是否在通过全局状态反向控制父级 layout?
  5. 能不能把结构决策放回结构拥有者那里?
  6. 如果结构 owner 已经正确,服务端是否仍然无法知道首帧状态?
  7. 这块 UI 是不是足够非核心,可以接受 ClientOnly 和占位 fallback?
  8. 能不能用 renderToString() 把服务端状态 A、客户端状态 B 的组合写成单测?

如果答案指向「页面在控制 Header」「子树在控制 layout」「交互桥进入 payload」,那就不要先想着补 guard。guard 可以帮忙止血,但真正稳定的解法通常是把职责拆回去。

如果答案指向「结构 owner 已经正确,但服务端就是不知道浏览器首帧会是什么状态」,那就不要再假装 SSR 能算准。该输出稳定双子树就用 v-show,该推迟到浏览器就用 mounted gate 或 ClientOnly,该保留空间就给 fallback 明确尺寸。

这次最后得到的规则仍然很朴素:Header 管 Header,页面管页面;首帧不可确定的 UI,不要让 SSR 输出一棵语义上可能立刻过期的互斥树。需要共享时共享数据,不共享 UI 指令。SSR 项目里,这条规则不只是架构洁癖,它直接决定首屏 HTML 和 hydration 首帧能不能对上。