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 触发条件

这个结论能推多远

可以说,只要后续都按这个边界做,同类问题基本不会再出现。

这里的「同类问题」指的是:后渲染的页面 slot,在 SSR 中写入前渲染的 layout/header 要消费的 payload state,导致服务端 HTML 和客户端 hydration 首帧结构不一致。

如果 Header 的首帧结构只依赖 Header 自己能稳定拿到的输入,比如 route、当前登录用户摘要、静态配置,那么 page slot 再怎么异步加载自己的数据,都不会影响 Header 首帧结构。Profile 页内部可以 loading,可以失败,可以重试,可以刷新;这些都属于 Profile 自己的页面状态,不会把 Header 带进 hydration mismatch。

这也说明,问题不只是「不要用 useState」。更通用的规则是:

  1. 不要让后渲染子树反向决定前渲染布局的首帧结构
  2. 共享数据可以,共享 UI 指令状态要非常谨慎
  3. layout/header 的结构应该由 layout/header 自己能稳定得到的信息决定
  4. 如果一个 guard 需要长注释解释为什么 hydration 前不能读某个状态,优先追问是不是职责边界错了
  5. wrapper 能缩小破坏半径,但不能替代 SSR/CSR 首帧一致
  6. 遇到 hydration mismatch,先还原 SSR DOM、payload state、客户端首帧 VNode 和组件渲染顺序,不要先调 CSS

我会怎么判断下一次

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

  1. 这块 DOM 是谁在 SSR 里先渲染的?
  2. 客户端首帧读取的状态,是否来自 SSR 后半段才写入的 payload?
  3. 这个状态是页面数据,还是 UI 指令?
  4. 当前组件是否在通过全局状态反向控制父级 layout?
  5. 能不能把结构决策放回结构拥有者那里?

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

这次最后得到的规则很朴素:Header 管 Header,页面管页面。需要共享时共享数据,不共享 UI 指令。SSR 项目里,这条规则不只是架构洁癖,它直接决定首屏 HTML 和 hydration 首帧能不能对上。