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 应用

具体过程:

  1. 服务端渲染出完整 HTML,浏览器直接展示(用户可以看到内容,但还不能交互)
  2. 浏览器下载并执行 JavaScript 打包文件
  3. Vue 在客户端重新创建虚拟 DOM 树
  4. Vue 对比虚拟 DOM 和真实 DOM,建立事件绑定和响应式关联
  5. 从此刻起,页面成为完整的 Vue 应用
// Nuxt 内部简化逻辑
// 服务端:
const html = await renderToString(app)  // 渲染为 HTML 字符串
const payload = serializePayload(state) // 序列化数据状态
 
// 客户端:
const app = createSSRApp(App)
app.mount('#__nuxt')  // Hydration:不会重新创建 DOM,而是"接管"已有 DOM

1.3 Hydration vs 传统 SPA

方面SSR + Hydration纯 SPA(CSR)
首屏 HTML包含完整内容空的 &lt;div id="app"&gt;
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 时,useFetchuseState 直接从 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 个字段,全部都会序列化到 Payload

4. 避免水合不匹配的最佳实践

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 &lt;ClientOnly&gt; 组件

当某些内容只能在客户端渲染时,用 &lt;ClientOnly&gt; 包裹:

<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 标签合法嵌套&lt;p&gt; 内不嵌套块元素
开启 debug: true 开发调试查看详细不匹配信息

本章小结

  • SSR 流程:请求 → 服务端渲染 HTML → 序列化 Payload → 响应 → 客户端 Hydration → 可交互
  • Hydration:客户端 Vue 接管服务端 HTML,建立响应式绑定和事件监听
  • 生命周期:服务端只执行 setup() + onServerPrefetch;DOM 相关钩子仅客户端
  • ssrContext:服务端请求上下文,可访问 HTTP 事件、设置响应头
  • Payload:服务端数据序列化到 HTML,客户端直接复用,避免二次请求
  • 水合不匹配:服务端/客户端渲染结果不一致,用 useCookieonMountedClientOnly 避免

下一章讲 SSG 静态站点生成与预渲染——将 SSR 的能力用在构建时。