返回首页

Nuxt SSR 水合避坑:让服务端 HTML 和客户端状态对齐

Nuxt 的 SSR 能带来更快首屏和更好的 SEO,但服务端渲染结果必须和客户端首次渲染对得上。本文梳理水合不一致的常见来源,以及在数据、时间、浏览器 API 和条件渲染上的处理方式。

39 约 6 分钟 · 5214 字 前端

Nuxt 的 SSR 很像一次接力。

服务端先根据当前路由渲染出 HTML,浏览器拿到 HTML 后立刻展示,然后客户端下载 JavaScript,把已有 DOM 和 Vue 组件实例接起来。这个"接起来"的过程,就是 hydration,也常被翻译成水合。

理想情况下,客户端第一次渲染出来的虚拟 DOM 应该和服务端返回的 HTML 一致。如果两边对不上,就会出现 hydration mismatch。轻则控制台警告,重则局部节点被丢弃重建,带来闪动、状态错乱和交互异常。

这篇不讲 Nuxt Content 的写作能力,只聚焦 Nuxt SSR 页面里最常见的水合问题。

一句话理解水合

水合不是重新生成一份页面,而是让客户端应用接管服务端已经生成好的 DOM。

服务端做了第一步:

请求页面 -> 执行页面逻辑 -> 渲染 HTML -> 返回给浏览器

客户端接着做第二步:

下载 JS -> 创建 Vue 应用 -> 对照现有 DOM -> 绑定事件和组件状态

如果服务端 HTML 是:

<p>你好,Ada</p>

客户端第一次渲染却认为应该是:

<p>你好,Guest</p>

Nuxt 就很难安静地接管这块 DOM。

坑 1:在模板里直接使用随机值

最典型的例子是 Math.random():

<template>
  <p>幸运数字: {{ Math.random() }}</p>
</template>

服务端执行一次,客户端水合时再执行一次,两次随机值大概率不一样。时间也是同理:

<template>
  <p>{{ new Date().toLocaleString() }}</p>
</template>

这类值的特点是:同一份代码在服务端和客户端执行,结果不稳定。

更稳的做法是把值固定下来。如果这个值是服务端决定的,就通过数据获取传给页面:

<script setup lang="ts">
const { data } = await useAsyncData('page-seed', async () => {
  return {
    seed: Math.floor(Math.random() * 10000)
  }
})
</script>

<template>
  <p>幸运数字: {{ data?.seed }}</p>
</template>

如果这个值只需要在客户端展示,就等挂载后再渲染:

<script setup lang="ts">
const mounted = ref(false)
const localTime = ref('')

onMounted(() => {
  mounted.value = true
  localTime.value = new Date().toLocaleString()
})
</script>

<template>
  <p v-if="mounted">{{ localTime }}</p>
</template>

核心原则是:不要让服务端和客户端在首次渲染时各自生成不可预测的内容。

坑 2:直接读取浏览器 API

服务端没有 windowdocumentlocalStorage

下面这段代码在 SSR 阶段就可能报错:

const theme = localStorage.getItem('theme')

即使你用条件判断避开报错,也可能制造水合差异:

<script setup lang="ts">
const isDark = import.meta.client
  ? localStorage.getItem('theme') === 'dark'
  : false
</script>

<template>
  <button>{{ isDark ? '深色' : '浅色' }}</button>
</template>

服务端永远渲染"浅色",客户端第一次渲染可能是"深色"。

更好的方式是把客户端偏好延后应用,或者把关键偏好放进 cookie,让服务端也能读到。

只在客户端需要的逻辑,放进 onMounted:

<script setup lang="ts">
const isDark = ref(false)
const ready = ref(false)

onMounted(() => {
  isDark.value = localStorage.getItem('theme') === 'dark'
  ready.value = true
})
</script>

<template>
  <button>
    {{ ready && isDark ? '深色' : '浅色' }}
  </button>
</template>

如果这个状态会影响首屏外观,则应该考虑用 cookie:

const theme = useCookie<'light' | 'dark'>('theme', {
  default: () => 'light'
})

这样服务端和客户端看到的是同一个来源。

坑 3:数据获取在两端不一致

Nuxt 里常用 useAsyncDatauseFetch 做数据获取。它们的价值之一,就是把服务端获取到的数据序列化给客户端复用,避免客户端水合时重新拿到另一份结果。

推荐写法:

<script setup lang="ts">
const route = useRoute()

const { data, pending, error } = await useAsyncData(
  () => `post-${route.params.slug}`,
  () => $fetch(`/api/posts/${route.params.slug}`)
)
</script>

这里的 key 很重要。稳定的 key 能帮助 Nuxt 识别这份数据,并在客户端复用服务端 payload。

容易踩坑的写法是 key 不稳定:

await useAsyncData(
  () => `post-${Date.now()}`,
  () => $fetch('/api/posts/latest')
)

每次执行 key 都不同,缓存和复用都会失效。

另一个问题是接口返回不稳定。比如接口按当前时间返回"今日推荐",服务端和客户端刚好跨过时间边界,结果就可能不同。SSR 页面依赖的数据应该尽量具备可复现性,至少在同一次请求链路中保持稳定。

坑 4:权限和登录态只在客户端判断

很多页面会根据登录态展示不同内容:

<template>
  <UserMenu v-if="user" />
  <LoginButton v-else />
</template>

如果 user 只从 localStorage 里读,服务端不知道用户是谁,就只能渲染未登录状态。客户端一水合发现已登录,立刻切成用户菜单,这就是典型不一致。

SSR 场景下,登录态最好来自服务端也能读取的来源,例如 cookie 或服务端 session。

可以把鉴权逻辑前移到 route middleware 或 server API:

export default defineNuxtRouteMiddleware(async () => {
  const user = useState('current-user')

  if (!user.value) {
    user.value = await $fetch('/api/me')
  }
})

真实项目里还要处理错误、重定向和缓存,但方向是一致的:首屏关键分支不要只靠客户端临时判断。

坑 5:客户端专属组件没有隔离

有些组件天然只能在浏览器里运行,比如依赖 canvas、地图 SDK、富文本编辑器、图表库的组件。

如果它们在服务端执行会报错,或者服务端和客户端渲染结构不同,可以使用 <ClientOnly>:

<template>
  <ClientOnly>
    <RichTextEditor v-model="content" />

    <template #fallback>
      <div class="editor-skeleton" />
    </template>
  </ClientOnly>
</template>

<ClientOnly> 不是用来逃避所有 SSR 问题的万能开关。它适合明确的客户端专属区域。

如果一个页面的大部分内容都包在 <ClientOnly> 里,那 SSR 的首屏和 SEO 价值基本就被你自己关掉了。

坑 6:响应式断点导致首屏结构不同

很多人会根据屏幕宽度渲染不同结构:

<template>
  <DesktopNav v-if="isDesktop" />
  <MobileNav v-else />
</template>

问题是服务端并不知道浏览器真实宽度。你可以根据 user-agent 猜,但这并不总可靠。

更稳的做法是优先用 CSS 处理布局差异:

<template>
  <nav>
    <DesktopNav class="hidden md:block" />
    <MobileNav class="md:hidden" />
  </nav>
</template>

这样服务端和客户端的 DOM 结构一致,显示差异交给 CSS。

如果确实必须根据宽度渲染不同组件,要接受它属于客户端增强逻辑,并做好 fallback。

一个排查清单

遇到 hydration mismatch,可以从这几类问题查起:

  1. 模板里是否有 Math.random()Date.now()new Date()
  2. setup 顶层是否读取了 windowdocumentlocalStorage
  3. useAsyncDatauseFetch 的 key 是否稳定
  4. 服务端和客户端是否读取了不同的登录态来源
  5. 是否用 JS 断点决定首屏 DOM 结构
  6. 第三方组件是否依赖浏览器环境
  7. 接口返回是否会在短时间内产生不同结果

排查时先看控制台警告里的组件路径,再从这个组件的模板和 setup 顶层开始找。多数问题都出在"首次渲染两端结果不同"。

实用原则

第一,首屏内容尽量可复现。

SSR 最怕不可预测。随机数、当前时间、浏览器本地状态都要谨慎进入首屏模板。

第二,关键状态要让服务端也知道。

主题、语言、登录态、AB 实验分组,如果会影响首屏结构或文本,就应该尽量来自 cookie、请求头或服务端数据。

第三,客户端增强要有边界。

地图、编辑器、复杂图表可以放到客户端挂载后再启动,但文章标题、导航、商品信息这类核心内容不该依赖客户端才出现。

第四,优先保持 DOM 结构一致。

响应式布局交给 CSS,数据差异交给稳定的数据获取,浏览器能力交给 onMounted<ClientOnly>

总结

Nuxt SSR 水合问题的本质不是 Nuxt "太严格",而是服务端和客户端对首屏结果产生了分歧。

只要记住一条主线就够了:

服务端已经生成的 HTML,必须能被客户端第一次渲染结果接住。

当你写到随机值、时间、浏览器 API、登录态、响应式断点和第三方客户端库时,就停一下想想:这段逻辑在服务端和客户端第一次执行时,会得到同一个结果吗?

如果答案是否定的,就需要固定数据来源、延后到客户端、使用 cookie,或者把它隔离成明确的客户端增强区域。