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
服务端没有 window、document、localStorage。
下面这段代码在 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 里常用 useAsyncData 和 useFetch 做数据获取。它们的价值之一,就是把服务端获取到的数据序列化给客户端复用,避免客户端水合时重新拿到另一份结果。
推荐写法:
<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,可以从这几类问题查起:
- 模板里是否有
Math.random()、Date.now()、new Date() - setup 顶层是否读取了
window、document、localStorage useAsyncData或useFetch的 key 是否稳定- 服务端和客户端是否读取了不同的登录态来源
- 是否用 JS 断点决定首屏 DOM 结构
- 第三方组件是否依赖浏览器环境
- 接口返回是否会在短时间内产生不同结果
排查时先看控制台警告里的组件路径,再从这个组件的模板和 setup 顶层开始找。多数问题都出在"首次渲染两端结果不同"。
实用原则
第一,首屏内容尽量可复现。
SSR 最怕不可预测。随机数、当前时间、浏览器本地状态都要谨慎进入首屏模板。
第二,关键状态要让服务端也知道。
主题、语言、登录态、AB 实验分组,如果会影响首屏结构或文本,就应该尽量来自 cookie、请求头或服务端数据。
第三,客户端增强要有边界。
地图、编辑器、复杂图表可以放到客户端挂载后再启动,但文章标题、导航、商品信息这类核心内容不该依赖客户端才出现。
第四,优先保持 DOM 结构一致。
响应式布局交给 CSS,数据差异交给稳定的数据获取,浏览器能力交给 onMounted 或 <ClientOnly>。
总结
Nuxt SSR 水合问题的本质不是 Nuxt "太严格",而是服务端和客户端对首屏结果产生了分歧。
只要记住一条主线就够了:
服务端已经生成的 HTML,必须能被客户端第一次渲染结果接住。
当你写到随机值、时间、浏览器 API、登录态、响应式断点和第三方客户端库时,就停一下想想:这段逻辑在服务端和客户端第一次执行时,会得到同一个结果吗?
如果答案是否定的,就需要固定数据来源、延后到客户端、使用 cookie,或者把它隔离成明确的客户端增强区域。