SSR 服务端渲染原理精讲
SSR 是 Nuxt4 的默认渲染模式,也是它区别于纯 SPA 框架的核心能力。但 SSR 不只是"在服务端执行 Vue"那么简单——从请求到达、组件渲染、数据序列化、HTML 响应、客户端接管(Hydration),每一步都有独特的机制和陷阱。本章从底层原理讲起,让你真正理解 Nuxt4 SSR 的每一个环节。
1. SSR 水合(Hydration)机制
1.1 SSR 的完整请求流程
浏览器请求 GET /videos/123
│
▼
┌──────────────────┐
│ Nitro 服务器 │
│ │
│ ① 创建 Vue App │ ← createSSRApp()
│ ② 执行中间件 │ ← route middleware
│ ③ 数据获取 │ ← useFetch / useAsyncData(服务端执行)
│ ④ 渲染组件树 │ ← renderToString()
│ ⑤ 序列化状态 │ ← Payload(数据 + 状态)
│ ⑥ 拼接 HTML │ ← HTML 模板 + 渲染结果 + <script>payload</script>
└──────────────────┘
│
▼ HTTP Response(完整 HTML)
┌──────────────────┐
│ 浏览器客户端 │
│ │
│ ⑦ 显示 HTML │ ← 用户立即看到内容(FCP)
│ ⑧ 下载 JS 包 │ ← Vue runtime + 页面组件
│ ⑨ Hydration │ ← 将静态 HTML "激活"为响应式 Vue 应用
│ ⑩ 应用可交互 │ ← TTI(Time to Interactive)
└──────────────────┘
1.2 什么是 Hydration
Hydration(水合/注水)是指:客户端 Vue 接管服务端渲染的 HTML,将其"激活"为一个可交互的 Vue 应用。
具体过程:
- 服务端渲染出完整 HTML,浏览器直接展示(用户可以看到内容,但还不能交互)
- 浏览器下载并执行 JavaScript 打包文件
- Vue 在客户端重新创建虚拟 DOM 树
- Vue 对比虚拟 DOM 和真实 DOM,建立事件绑定和响应式关联
- 从此刻起,页面成为完整的 Vue 应用
// Nuxt 内部简化逻辑
// 服务端:
const html = await renderToString(app) // 渲染为 HTML 字符串
const payload = serializePayload(state) // 序列化数据状态
// 客户端:
const app = createSSRApp(App)
app.mount('#__nuxt') // Hydration:不会重新创建 DOM,而是"接管"已有 DOM1.3 Hydration vs 传统 SPA
| 方面 | SSR + Hydration | 纯 SPA(CSR) |
|---|---|---|
| 首屏 HTML | 包含完整内容 | 空的 <div id="app"> |
| FCP(首次内容绘制) | 快(HTML 直接展示) | 慢(等 JS 下载 + 执行) |
| TTI(可交互时间) | 略慢(需等 Hydration 完成) | 与 FCP 同时 |
| SEO | 友好(搜索引擎看到完整内容) | 不友好(需要 JS 渲染) |
| 服务器压力 | 有(每次请求都要渲染) | 无(只提供静态文件) |
1.4 Nuxt4 的 Hydration 优化
Nuxt4 采用了多项 Hydration 优化策略:
// 1. Payload 直接注入(避免客户端重新请求数据)
// 服务端获取的数据直接序列化到 HTML 中
// <script>window.__NUXT__ = { data: {...}, state: {...} }</script>
// 2. 组件级 Lazy Hydration(实验性)
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
componentIslands: true, // 岛屿组件:部分组件延迟水合
},
})<!-- 延迟水合:只有进入视口时才激活 -->
<LazyMyComponent />
<!-- 永不水合:纯展示组件,不需要客户端交互 -->
<NuxtIsland name="StaticBanner" />2. 服务端与客户端生命周期差异
2.1 生命周期钩子执行环境
服务端执行 客户端执行
─────────── ───────────
setup() ✅ setup() ✅
onServerPrefetch ✅ onServerPrefetch ❌
onBeforeMount ❌ onBeforeMount ✅
onMounted ❌ onMounted ✅
onBeforeUpdate ❌ onBeforeUpdate ✅
onUpdated ❌ onUpdated ✅
onBeforeUnmount ❌ onBeforeUnmount ✅
onUnmounted ❌ onUnmounted ✅
onErrorCaptured ✅ onErrorCaptured ✅
核心规则:服务端只执行 setup() 和 onServerPrefetch。所有 DOM 相关的钩子(mounted、updated 等)仅在客户端执行。
2.2 setup() 中的环境感知
<script setup lang="ts">
// ✅ setup 中的代码在两端都执行
const count = ref(0)
const route = useRoute()
const { data } = await useFetch('/api/data') // 服务端获取,客户端复用 Payload
// ✅ 环境检测
if (import.meta.server) {
// 仅服务端执行
console.log('Server: rendering on', process.env.NODE_ENV)
}
if (import.meta.client) {
// 仅客户端执行
console.log('Client: browser', navigator.userAgent)
}
// ✅ onMounted 保证在客户端执行
onMounted(() => {
// 安全访问 DOM 和浏览器 API
window.addEventListener('scroll', handleScroll)
const el = document.getElementById('player')
})
// ✅ onServerPrefetch 仅在服务端执行
onServerPrefetch(async () => {
// 服务端预获取数据(较少使用,useFetch 已自动处理)
await someServerOnlyFetch()
})
</script>2.3 常见错误及修复
// ❌ 错误 1:setup 中直接访问浏览器 API
const width = window.innerWidth // 💥 服务端没有 window
// ✅ 修复:在 onMounted 或 import.meta.client 中使用
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
// ❌ 错误 2:setup 中使用 localStorage
const token = localStorage.getItem('token') // 💥 服务端没有 localStorage
// ✅ 修复:使用 useCookie(两端通用)
const token = useCookie('token')
// ❌ 错误 3:setup 中操作 DOM
const el = document.querySelector('.player') // 💥 服务端没有 document
// ✅ 修复:使用模板 ref + onMounted
const playerRef = ref<HTMLElement | null>(null)
onMounted(() => {
playerRef.value?.focus()
})2.4 第三方库的 SSR 兼容
许多第三方库依赖浏览器 API,需要特殊处理:
<script setup lang="ts">
// 方式 1:动态导入 + ClientOnly
const VideoPlayer = defineAsyncComponent(() =>
import('some-video-player') // 仅客户端加载
)
</script>
<template>
<ClientOnly>
<VideoPlayer :url="video.url" />
<template #fallback>
<div class="aspect-video bg-gray-200 animate-pulse" />
</template>
</ClientOnly>
</template>// 方式 2:Nuxt Plugin 指定客户端执行
// app/plugins/chart.client.ts(.client 后缀 = 仅客户端)
import Chart from 'chart.js'
export default defineNuxtPlugin(() => {
return {
provide: { Chart },
}
})<!-- 方式 3:.client.vue 后缀的组件 -->
<!-- app/components/VideoPlayer.client.vue -->
<!-- 这个组件文件本身只在客户端渲染 -->
<template>
<div ref="playerEl" />
</template>
<script setup lang="ts">
import Plyr from 'plyr'
const playerEl = ref<HTMLElement>()
onMounted(() => {
new Plyr(playerEl.value!)
})
</script>3. ssrContext 与 Payload 序列化
3.1 ssrContext 是什么
ssrContext 是 Nuxt 在服务端渲染期间传递的上下文对象,包含当前请求的所有信息:
// 在 setup、插件、中间件中获取
const nuxtApp = useNuxtApp()
if (import.meta.server) {
const ssrContext = nuxtApp.ssrContext
// ssrContext 包含:
// - event: H3 请求事件(Nitro 的 HTTP 请求对象)
// - url: 当前请求 URL
// - payload: 要发送给客户端的数据
// - head: SEO 头部信息
// - teleports: Teleport 渲染内容
}3.2 通过 ssrContext 传递自定义头部
// app/plugins/response-headers.server.ts
export default defineNuxtPlugin((nuxtApp) => {
const event = useRequestEvent()
if (!event) return
// 设置自定义响应头
setResponseHeader(event, 'X-Powered-By', 'Nuxt4')
setResponseHeader(event, 'Cache-Control', 'public, max-age=60')
// 根据页面设置不同的缓存策略
nuxtApp.hook('page:finish', () => {
const route = useRoute()
if (route.path.startsWith('/api')) {
setResponseHeader(event, 'Cache-Control', 'no-cache')
}
})
})3.3 Payload 序列化机制
Payload 是 Nuxt SSR 的核心数据传输机制——服务端获取的数据序列化后嵌入 HTML,客户端直接复用,避免重复请求。
<!-- 服务端渲染的 HTML 中会包含 -->
<script>
window.__NUXT__ = {
data: {
"videos-123": { // useFetch 的缓存 key
id: "123",
title: "AI 视频教程",
views: 12500
}
},
state: {
"auth:user": { id: "u1", name: "Alice", role: "admin" }
}
}
</script>客户端 Hydration 时,useFetch 和 useState 直接从 Payload 读取数据,不会再次发起请求。
3.4 自定义 Payload 序列化
默认序列化使用 devalue(支持 Date、Map、Set、RegExp 等),但你可以注册自定义类型:
// app/plugins/payload-types.ts
export default defineNuxtPlugin((nuxtApp) => {
// 注册自定义类型的序列化/反序列化
definePayloadReducer('VideoTimestamp', (value) => {
if (value instanceof VideoTimestamp) {
return { seconds: value.toSeconds() }
}
})
definePayloadReviver('VideoTimestamp', (data) => {
return VideoTimestamp.fromSeconds(data.seconds)
})
})3.5 Payload 大小优化
// ✅ 使用 pick 只选取需要的字段(减少 Payload 体积)
const { data } = await useFetch('/api/videos', {
pick: ['id', 'title', 'thumbnail'], // 只传输这 3 个字段到客户端
})
// ✅ 使用 transform 精简数据
const { data } = await useFetch('/api/videos', {
transform: (videos) => videos.map(v => ({
id: v.id,
title: v.title,
thumbnail: v.thumbnail,
})),
})
// ❌ 避免:将完整的 API 响应(可能含大量无用字段)直接传输
const { data } = await useFetch('/api/videos')
// 如果 API 返回 100 个字段,全部都会序列化到 Payload4. 避免水合不匹配的最佳实践
4.1 什么是水合不匹配
水合不匹配(Hydration Mismatch)是指服务端渲染的 HTML 与客户端 Vue 生成的虚拟 DOM 不一致。
服务端 HTML:<div class="time">2026-04-12 12:00:00</div>
客户端 VDOM:<div class="time">2026-04-12 12:00:01</div>
^^^ 不一致!
⚠ [Vue warn]: Hydration text content mismatch
4.2 常见原因和解决方案
原因 1:时间/随机数
<!-- ❌ 错误:服务端和客户端的时间不同 -->
<template>
<span>{{ new Date().toLocaleString() }}</span>
</template>
<!-- ✅ 修复:使用 ClientOnly -->
<template>
<ClientOnly>
<span>{{ new Date().toLocaleString() }}</span>
<template #fallback>
<span>加载中...</span>
</template>
</ClientOnly>
</template>原因 2:浏览器 API 判断
<script setup lang="ts">
// ❌ 错误:服务端没有 window,isMobile 始终为 false
const isMobile = ref(window.innerWidth < 768)
</script>
<script setup lang="ts">
// ✅ 修复:使用 onMounted 延迟判断
const isMobile = ref(false)
onMounted(() => {
isMobile.value = window.innerWidth < 768
})
</script>原因 3:条件渲染不一致
<!-- ❌ 错误:服务端没有 localStorage,条件结果不同 -->
<template>
<div v-if="isLoggedIn">Welcome</div>
</template>
<script setup>
const isLoggedIn = !!localStorage.getItem('token')
</script>
<!-- ✅ 修复:使用 useCookie 两端一致 -->
<script setup>
const token = useCookie('token')
const isLoggedIn = computed(() => !!token.value)
</script>原因 4:HTML 标签嵌套不合法
<!-- ❌ 错误:<p> 内不能嵌套 <div> -->
<p><div>内容</div></p>
<!-- ✅ 修复:使用合法嵌套 -->
<div><div>内容</div></div>4.3 调试水合不匹配
// nuxt.config.ts — 开发环境启用详细水合不匹配警告
export default defineNuxtConfig({
debug: true, // 启用 Vue 的水合不匹配详细日志
})Vue 的水合不匹配警告会指出:
- 哪个元素不匹配
- 服务端渲染的值
- 客户端期望的值
4.4 <ClientOnly> 组件
当某些内容只能在客户端渲染时,用 <ClientOnly> 包裹:
<template>
<!-- 视频播放器:依赖浏览器 API -->
<ClientOnly>
<VideoPlayer :url="video.url" />
<template #fallback>
<div class="aspect-video bg-gray-200 flex items-center justify-center">
<LoadingSpinner />
</div>
</template>
</ClientOnly>
<!-- 实时时间显示 -->
<ClientOnly>
<LiveClock />
<template #fallback>
<span>--:--:--</span>
</template>
</ClientOnly>
</template>4.5 水合安全的最佳实践清单
| 规则 | 说明 |
|---|---|
用 useCookie 替代 localStorage | 两端一致的状态来源 |
用 onMounted 访问浏览器 API | 确保只在客户端执行 |
用 ClientOnly 包裹纯客户端组件 | 服务端跳过渲染 |
用 .client.vue 后缀命名组件 | 组件级客户端限定 |
避免在 setup 中使用 Date.now() | 两端时间可能不同 |
避免 Math.random() 用于渲染 | 两端随机值不同 |
| HTML 标签合法嵌套 | <p> 内不嵌套块元素 |
开启 debug: true 开发调试 | 查看详细不匹配信息 |
本章小结
- SSR 流程:请求 → 服务端渲染 HTML → 序列化 Payload → 响应 → 客户端 Hydration → 可交互
- Hydration:客户端 Vue 接管服务端 HTML,建立响应式绑定和事件监听
- 生命周期:服务端只执行
setup()+onServerPrefetch;DOM 相关钩子仅客户端 - ssrContext:服务端请求上下文,可访问 HTTP 事件、设置响应头
- Payload:服务端数据序列化到 HTML,客户端直接复用,避免二次请求
- 水合不匹配:服务端/客户端渲染结果不一致,用
useCookie、onMounted、ClientOnly避免
下一章讲 SSG 静态站点生成与预渲染——将 SSR 的能力用在构建时。