Vue 基础体系 · 第 20/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue SSR 与 Nuxt:水合、数据获取、缓存、SEO 和部署边界

1. 先划清边界:SSR、CSR、SSG 和 Nuxt

服务端渲染(Server-Side Rendering,SSR)是指:一次 HTTP 请求到达服务器后,服务器执行 Vue 应用,得到包含页面内容的 HTML,再把 HTML 返回给浏览器。浏览器先显示这份 HTML,随后加载 JavaScript,将现有 DOM 与 Vue 组件树连接起来,这个过程叫水合(Hydration)

**客户端渲染(Client-Side Rendering,CSR)**则通常只返回一个空的 HTML 容器,例如:

<div id="app"></div>
<script type="module" src="/assets/app.js"></script>

页面内容需要等 JavaScript 下载、执行并请求数据后才出现。

**静态生成(Static Site Generation,SSG)**是在构建阶段执行页面渲染,把结果保存为静态 HTML。请求到来时由 CDN 或静态服务器直接返回,不需要为每次请求执行 Vue。

Nuxt 并不等于“只能 SSR”。它基于 Vue 和 Nitro 提供了多种交付模式:

模式 页面生成时间 请求时是否执行服务端代码 常见用途
SSR 每次请求 个性化页面、实时内容
SSG 构建时 否,通常只读静态文件 文档、营销页、博客
SWR/缓存化 SSR 首次或缓存失效时 视缓存状态而定 访问量较大的内容页
CSR 浏览器运行时 管理后台、强交互应用
混合渲染 按路由分别决定 按路由决定 大多数生产 Nuxt 应用

Nuxt 的价值不只是“让 Vue 在服务器运行”,还包括:

  • 文件系统路由;
  • 服务端 API 和 Nitro 运行时;
  • SSR 数据获取与序列化;
  • 页面元数据管理;
  • 路由级缓存和渲染规则;
  • Node、Serverless、Edge、静态托管等部署适配。

因此,选择 Nuxt 时必须同时考虑渲染方式、数据生命周期、缓存边界和部署运行时,而不是只看页面是否能输出 HTML。


2. SSR 的完整请求链路

一次典型的 Nuxt SSR 请求可以抽象为:

sequenceDiagram
    participant B as 浏览器
    participant S as Nuxt/Nitro 服务端
    participant A as 后端 API 或数据库
    participant C as 客户端 Vue 应用

    B->>S: GET /articles/vue-ssr
    S->>A: 获取文章数据
    A-->>S: 返回文章数据
    S->>S: 执行 Vue setup、渲染组件、生成 head
    S-->>B: HTML + Nuxt payload + JS 资源地址
    B->>B: 解析并显示 HTML
    B->>S: 请求 JS、CSS 等静态资源
    B->>C: 创建 Vue 应用并水合已有 DOM
    C->>C: 使用序列化数据恢复响应式状态
    C-->>B: 页面进入可交互状态

其中有三个容易混淆的结果:

  1. HTML负责让用户和爬虫尽早看到内容;
  2. payload负责把服务端已经获取的数据交给客户端,避免水合时无条件重复请求;
  3. JavaScript负责恢复事件监听、响应式更新和后续交互。

SSR 并不意味着浏览器永远不请求 API。它通常意味着:

  • 首次请求由服务端获取数据并生成 HTML;
  • 客户端水合时复用服务端数据;
  • 后续路由切换、刷新或交互可能在浏览器中继续请求数据。

3. 水合到底做了什么

3.1 水合不是“重新渲染一遍页面”

Vue SSR 阶段会在服务器创建应用并执行组件树,生成 HTML:

<template>
  <h1>{{ title }}</h1>
</template>

服务器可能生成:

<h1>Vue SSR</h1>

客户端启动时,Vue 会创建同样的组件树,并检查已有 DOM 是否符合预期。若符合,Vue 会复用这些节点并安装事件监听,而不是简单地删除所有内容再重新创建。

因此,水合要求服务器和客户端在初始状态下具有足够一致的渲染结果。

可以把要求形式化为:

Ds=R(Ss,Es)D_s = R(S_s, E_s)

Dc=R(Sc,Ec)D_c = R(S_c, E_c)

其中:

  • RR 是 Vue 的渲染过程;
  • SsS_s 是服务端初始状态;
  • ScS_c 是客户端水合前恢复的初始状态;
  • EsE_sEcE_c 是服务端和客户端环境;
  • DsD_sDcD_c 是最终用于比较的 DOM 结构。

理想条件是:

normalize(Ds)=normalize(Dc)\operatorname{normalize}(D_s) = \operatorname{normalize}(D_c)

这里的 normalize 表示忽略浏览器可能自动补全的部分细节后进行结构比较。这个等式不要求服务端和客户端的所有运行环境相同,但要求首屏输出所依赖的状态和结构一致

3.2 常见水合不一致来源

下面的代码在 SSR 中是不稳定的:

<script setup lang="ts">
const now = new Date().toLocaleString()
const random = Math.random()
</script>

<template>
  <p>{{ now }}</p>
  <p>{{ random }}</p>
</template>

服务端和客户端分别执行时:

  • new Date() 可能跨越了时间边界;
  • toLocaleString() 可能使用不同的时区或语言环境;
  • Math.random() 几乎必然不同。

于是服务器输出:

<p>2025/01/01 10:00:00</p>
<p>0.123</p>

客户端预期却可能是:

<p>2025/01/01 18:00:01</p>
<p>0.845</p>

这就违反了水合一致性条件。

其他常见原因包括:

  • 在模板或 setup() 中直接访问 windowdocumentlocalStorage
  • 服务端和客户端使用不同的时区、语言或用户代理判断;
  • 依赖对象属性枚举顺序或不稳定排序;
  • SSR 时数据为空,客户端首次执行时数据已经改变;
  • 服务端输出一个结构,客户端根据权限或浏览器能力输出另一个结构;
  • 无效 HTML 导致浏览器在解析阶段自动修正 DOM;
  • 组件随机生成 ID,但没有把 ID 作为服务端状态传给客户端。

3.3 正确处理浏览器专属逻辑

浏览器专属逻辑应推迟到客户端挂载后:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const width = ref<number | null>(null)

onMounted(() => {
  width.value = window.innerWidth
})
</script>

<template>
  <p v-if="width === null">正在读取窗口尺寸</p>
  <p v-else>窗口宽度:{{ width }}</p>
</template>

服务端和客户端首次水合时都输出“正在读取窗口尺寸”,所以结构一致;挂载完成后,客户端再更新为真实宽度。

Nuxt 还提供了客户端专属组件:

<ClientOnly>
  <BrowserChart />
  <template #fallback>
    <p>图表加载中</p>
  </template>
</ClientOnly>

ClientOnly 的代价是:其内容不会作为正常 SSR HTML 输出。因此,不能把 SEO 关键标题、正文和主要链接全部放进 ClientOnly,否则爬虫和禁用 JavaScript 的访问者可能看不到它们。

3.4 “忽略水合警告”不是修复

某些框架版本支持对特定节点抑制水合警告,但这只是在隐藏症状,并不会让服务端和客户端状态一致。若节点中包含价格、权限、订单状态等业务数据,静默不一致可能产生更严重的问题:

  • 用户看到的 HTML 与交互状态不同;
  • 点击事件绑定到了错误的元素;
  • 客户端重绘导致页面闪烁;
  • 服务器展示了用户无权看到的缓存内容。

正确顺序应是先找出不一致来源,再决定是否把该区域改成客户端专属内容。


4. Nuxt 的数据获取:服务端首屏与客户端导航

4.1 为什么不应在 SSR 页面中直接写裸 $fetch

例如:

<script setup lang="ts">
const article = await $fetch('/api/article')
</script>

这段代码可能在服务端执行一次,并在客户端水合时再次执行一次。对于简单场景它可以工作,但它没有直接表达:

  • 服务端结果如何交给客户端;
  • 请求状态如何管理;
  • 错误如何进入 Nuxt 页面状态;
  • 路由参数变化时是否重新请求;
  • 多个组件如何避免重复请求。

Nuxt 推荐使用 useFetchuseAsyncData。它们是 SSR 感知的数据获取组合式 API,能够把服务端结果放入 Nuxt 的 payload,使客户端水合时复用结果。

4.2 一个可运行的最小示例

先创建一个 Nuxt 项目:

npx nuxi@latest init nuxt-ssr-demo
cd nuxt-ssr-demo
npm install
npm run dev

下面创建一个服务端 API:

// server/api/articles/[slug].get.ts
import { createError, defineEventHandler, getRouterParam } from 'h3'

interface Article {
  slug: string
  title: string
  description: string
  content: string
}

const articles: Record<string, Article> = {
  'vue-ssr': {
    slug: 'vue-ssr',
    title: 'Vue SSR 入门',
    description: '理解服务端渲染和水合。',
    content: '这是一篇示例文章。'
  }
}

export default defineEventHandler((event) => {
  const slug = getRouterParam(event, 'slug')
  const article = slug ? articles[slug] : undefined

  if (!article) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Article Not Found'
    })
  }

  return article
})

再创建页面:

<!-- pages/articles/[slug].vue -->
<script setup lang="ts">
interface Article {
  slug: string
  title: string
  description: string
  content: string
}

const route = useRoute()

const {
  data: article,
  error,
  status,
  refresh
} = await useFetch<Article>(
  () => `/api/articles/${encodeURIComponent(String(route.params.slug))}`,
  {
    server: true,
    dedupe: 'cancel'
  }
)

useSeoMeta(() => ({
  title: article.value?.title ?? '文章不存在',
  description: article.value?.description ?? '文章页面',
  ogTitle: article.value?.title,
  ogDescription: article.value?.description
}))
</script>

<template>
  <main>
    <p v-if="status === 'pending'">加载中……</p>

    <div v-else-if="error">
      <h1>文章加载失败</h1>
      <p>{{ error.statusMessage || error.message }}</p>
      <button type="button" @click="refresh()">重试</button>
    </div>

    <article v-else-if="article">
      <h1>{{ article.title }}</h1>
      <p>{{ article.description }}</p>
      <div>{{ article.content }}</div>
    </article>
  </main>
</template>

访问:

http://localhost:3000/articles/vue-ssr

预期过程如下:

  1. 请求到达 Nuxt 服务端;
  2. useFetch 在 SSR 阶段调用 /api/articles/vue-ssr
  3. API 返回文章;
  4. 服务端输出包含文章标题和正文的 HTML;
  5. 数据同时进入 Nuxt 的服务端到客户端传输状态;
  6. 浏览器加载 JavaScript 后,客户端复用该数据完成水合;
  7. 通常不会因为水合而再次发起同一个首屏请求;
  8. 请求不存在的 slug 时,API 返回 404,页面进入 error 分支。

useFetch 的 URL 可以是响应式函数,因此路由参数变化时会根据依赖重新请求。dedupe: 'cancel' 表示相同数据键存在并发请求时,倾向于取消旧请求,避免旧响应覆盖新状态。具体去重行为受 Nuxt 版本和数据键影响,不能把它当作任意请求的全局竞态管理器。

4.3 useFetchuseAsyncData 的分工

useFetch 适合 HTTP 请求:

const { data, error } = await useFetch<User>('/api/me')

useAsyncData 更通用,适合组合多个来源:

const { data, error } = await useAsyncData('dashboard', async () => {
  const [user, notifications] = await Promise.all([
    $fetch('/api/me'),
    $fetch('/api/notifications')
  ])

  return { user, notifications }
})

这段代码的意义是:

  • useAsyncData 负责 SSR 数据生命周期、状态和 payload;
  • $fetch 负责实际异步调用;
  • Promise.all 使两个独立请求并行,而不是串行等待;
  • 任意一个请求失败,整个组合结果失败。

useAsyncData 的 handler 应返回有效结果。某些 Nuxt 版本在 handler 返回 undefinednull 时,客户端可能认为服务端没有得到有效结果并再次请求。因此,空数据最好明确返回结构,例如:

return {
  items: [],
  total: 0
}

而不是隐式返回 undefined

4.4 服务端数据和客户端数据必须隔离

不要在模块顶层保存会被请求修改的可变状态:

// 错误风险示例
let currentUser: User | null = null

export default defineEventHandler(async (event) => {
  currentUser = await getUserFromRequest(event)
  return currentUser
})

在长生命周期 Node 进程中,模块级变量可能被多个请求共享。请求 A 的用户数据可能残留给请求 B,形成严重的数据泄露。

应把请求相关状态放在请求上下文、组合式函数实例或明确的服务端存储中。例如,使用事件对象读取当前请求:

export default defineEventHandler((event) => {
  const authorization = getHeader(event, 'authorization')
  return {
    authenticated: Boolean(authorization)
  }
})

同理,不能把用户身份、购物车、权限判断结果放进全局单例缓存,除非缓存键明确包含租户、用户、权限版本等隔离维度。


5. 数据请求的并发、取消和错误恢复

SSR 和客户端导航的并发模型不同。

5.1 SSR 请求失败

在服务端渲染期间,如果关键数据请求失败,常见选择有两种:

  1. 让页面返回错误状态,例如 404 或 500;
  2. 保留页面壳体,显示降级内容。

文章详情这种资源通常应该区分“资源不存在”和“暂时失败”:

if (response.status === 404) {
  throw createError({
    statusCode: 404,
    statusMessage: '文章不存在'
  })
}

if (!response.ok) {
  throw createError({
    statusCode: 502,
    statusMessage: '上游文章服务暂时不可用'
  })
}

如果把所有失败都渲染成 HTTP 200 的“加载失败页面”,搜索引擎和监控系统会误判页面可用,缓存层也可能缓存错误 HTML。

5.2 客户端导航竞态

假设用户快速点击:

/articles/a
/articles/b
/articles/c

如果请求 A、B、C 同时发出,而响应顺序为:

C 先返回,B 后返回,A 最后返回

没有竞态控制时,A 的旧响应可能覆盖当前页面 C 的数据。

处理竞态至少需要满足:

只接受仍然属于当前请求上下文的响应\text{只接受仍然属于当前请求上下文的响应}

常见手段包括:

  • 使用稳定且包含参数的数据键;
  • 使用 dedupe: 'cancel' 或适合场景的 defer
  • 为每次请求创建 AbortController
  • 在响应落地前检查请求序号;
  • 在 Vue 版本支持时使用 onWatcherCleanup 清理旧请求。

若直接使用 $fetch 编写自定义 watcher,需要显式传递取消信号:

import { ref, watch } from 'vue'

const slug = ref('vue-ssr')
const article = ref<Article | null>(null)
const loading = ref(false)
const error = ref<unknown>(null)

let requestId = 0

watch(slug, async (currentSlug) => {
  const id = ++requestId
  loading.value = true
  error.value = null

  try {
    const result = await $fetch<Article>(
      `/api/articles/${encodeURIComponent(currentSlug)}`
    )

    if (id === requestId) {
      article.value = result
    }
  } catch (err) {
    if (id === requestId) {
      error.value = err
    }
  } finally {
    if (id === requestId) {
      loading.value = false
    }
  }
})

这里的 requestId 是逻辑取消:旧请求仍可能在网络层继续,但旧结果不会写入当前状态。生产代码还可以配合 AbortController 真正终止支持取消的请求。


6. Nuxt 中的缓存:缓存的不是同一个东西

“Nuxt 有缓存”不是一个完整命题。至少要区分以下层次:

浏览器 HTTP 缓存
        ↓
CDN / 反向代理缓存
        ↓
Nitro 路由或响应缓存
        ↓
Nuxt 数据缓存与 payload
        ↓
应用内部缓存
        ↓
数据库或上游 API 缓存

每一层的键、生命周期和失效方式都不同。

6.1 浏览器和 CDN 缓存

如果响应包含:

Cache-Control: public, max-age=60, s-maxage=600

通常可以理解为:

  • 浏览器最多使用 60 秒的新鲜响应;
  • 共享缓存(例如 CDN)最多使用 600 秒的新鲜响应。

但带有用户身份、Cookie、权限或购物车信息的 HTML 不应直接使用公共缓存:

Cache-Control: private, no-store

否则可能发生:

  1. 用户 A 的 SSR HTML 被 CDN 缓存;
  2. 用户 B 请求相同 URL;
  3. CDN 直接返回 A 的内容;
  4. B 看到错误的用户信息。

6.2 Nuxt/Nitro 的路由缓存

Nuxt 支持通过 routeRules 为不同路由设置渲染和缓存策略。一个示意配置如下:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/docs/**': { swr: 300 },
    '/account/**': {
      ssr: true,
      headers: {
        'cache-control': 'private, no-store'
      }
    },
    '/api/**': {
      headers: {
        'cache-control': 'private, no-store'
      }
    }
  }
})

这里的 swr: 300 表示允许缓存内容,并在一定时间后重新生成或更新。它适合公开、可容忍短暂陈旧的文章内容,不适合用户余额、权限和订单状态。

routeRules 的具体缓存实现取决于 Nitro 适配器和部署平台:

  • Node 部署可能使用进程内或运行时缓存;
  • Serverless 环境中的实例可能随时销毁;
  • 多实例部署必须考虑缓存是否共享;
  • Edge 平台可能有自己的区域缓存和失效机制。

因此,不能仅凭本地开发环境验证结果,就推断生产环境的缓存行为。

6.3 数据缓存与 HTML 缓存不是一回事

缓存整份 HTML:

URL -> 已渲染 HTML

缓存 API 数据:

API 参数 -> JSON 数据

二者的失效后果不同。若文章 HTML 缓存 300 秒,编辑发布后用户可能继续看到旧标题;若只缓存 API 数据,页面壳体可能实时生成,但数据仍可能陈旧。

还要注意缓存键必须包含所有影响输出的变量。例如页面结果依赖:

页面 URL + 语言 + 租户 + 用户权限

却只用 URL 作为缓存键,就会造成跨语言、跨租户或跨权限污染。

6.4 Nuxt payload 不是长期业务缓存

Nuxt 会将 SSR 阶段的数据传给客户端,客户端水合时复用。这种数据传输的主要目的,是避免首屏数据重复获取,而不是替代 Redis、CDN 或数据库缓存。

不要把以下内容放进会序列化到客户端的状态:

  • 数据库密码;
  • 服务端 API Token;
  • 内部网络地址;
  • 不应暴露给用户的权限信息;
  • 其他用户的隐私数据。

服务端返回给浏览器的任何 payload 都应视为用户可读取的数据。若某个数据只服务于服务器渲染,就应在服务端完成处理,只把必要的公开结果发送给客户端。


7. SSR 为什么有利于 SEO,但不等于自动做好 SEO

**SEO(Search Engine Optimization,搜索引擎优化)**关注的是搜索引擎能否发现、抓取、理解并正确展示页面。

SSR 的直接优势是:首个 HTTP 响应中已经包含正文、标题和链接。例如:

<title>Vue SSR 入门</title>
<meta name="description" content="理解服务端渲染和水合。">
<h1>Vue SSR 入门</h1>
<p>这是一篇示例文章。</p>

这比“HTML 只有一个空容器,正文等 JavaScript 执行后才出现”更容易被抓取,也更有利于首屏可见内容。

但 SSR 不自动解决以下问题:

  • 页面没有唯一、准确的 <title>
  • 多个 URL 返回相同内容;
  • canonical 地址错误;
  • 关键正文被 ClientOnly 包裹;
  • 服务器返回 200,但正文实际是“资源不存在”;
  • 没有站点地图和可抓取链接;
  • robots 配置阻止了抓取;
  • 页面加载很慢,首屏数据请求经常超时。

Nuxt 页面可以使用 useSeoMeta

useSeoMeta(() => ({
  title: article.value?.title ?? '文章',
  description: article.value?.description ?? '',
  ogTitle: article.value?.title ?? '文章',
  ogDescription: article.value?.description ?? '',
  ogType: 'article'
}))

如果页面数据来自 SSR,服务器生成的 HTML 中应包含对应的 head 标签。若数据只在 onMounted 中获取,服务器无法生成准确标题和描述。

7.1 SEO 数据失败时要返回正确状态

以文章详情为例:

  • slug 不存在:返回 404;
  • 数据库暂时不可用:返回 5xx 或可监控的降级状态;
  • 有权限但未登录:返回 401/403 或重定向;
  • 页面公开且数据为空:才考虑正常返回空状态。

错误页面的 HTTP 状态和 HTML 内容必须一致。否则会产生软 404:用户看到“文章不存在”,但服务器却返回 200。

7.2 结构化数据和 SEO 的关系

结构化数据,例如 JSON-LD,可以帮助搜索引擎理解文章、产品或面包屑:

useHead(() => ({
  script: article.value
    ? [
        {
          type: 'application/ld+json',
          children: JSON.stringify({
            '@context': 'https://schema.org',
            '@type': 'Article',
            headline: article.value.title,
            description: article.value.description
          })
        }
      ]
    : []
}))

这里的 JSON.stringify 内容必须经过可信数据处理。若把未经处理的用户输入直接拼入脚本字符串,应注意序列化和脚本注入风险。


8. SSR 的部署边界

8.1 Node SSR 部署

构建:

npm run build

Nuxt 会生成 .output 目录。Node 运行时通常这样启动:

node .output/server/index.mjs

启动前需要准备:

export NITRO_PORT=3000
export NITRO_HOST=0.0.0.0
node .output/server/index.mjs

预期结果是进程监听 0.0.0.0:3000,反向代理或负载均衡器再把公网请求转发到它。

部署验证至少包括:

curl -i http://127.0.0.1:3000/articles/vue-ssr

需要检查:

  • 状态码是否为 200;
  • HTML 是否已经包含文章标题;
  • 是否返回正确的 Content-Type
  • 资源 URL 是否能正常访问;
  • 不存在的文章是否返回 404;
  • API 失败时是否有可监控的 5xx。

Node SSR 的优势是能在请求时访问数据库、读取 Cookie、执行权限判断。代价是需要维护长期运行的服务,并处理:

  • 进程重启;
  • 多实例扩容;
  • 内存泄漏;
  • 连接池;
  • 日志和健康检查;
  • 多实例之间的缓存一致性。

8.2 静态生成部署

如果页面内容只在构建阶段确定,可以使用静态生成:

npx nuxt generate

生成后通常得到可由静态服务器托管的产物。此时部署到对象存储或 CDN 即可,不需要运行 Node SSR 进程。

静态生成的边界是:

  • 构建后新增的动态 slug 不会自动出现;
  • 依赖请求 Cookie 的页面无法在构建阶段生成个性化结果;
  • 实时库存、订单状态、用户通知不适合直接静态化;
  • 内容更新通常需要重新构建和发布,除非额外接入运行时数据请求。

静态化不是“SSR 更快的版本”,而是把渲染时机从请求阶段提前到了构建阶段。

8.3 Serverless 和 Edge

Nitro 可以将应用适配到不同运行时,但适配并不代表所有 Node 能力都存在。

Serverless 常见边界:

  • 实例可能冷启动;
  • 本地文件写入不可靠;
  • 进程内缓存不稳定;
  • 长连接和长任务可能受平台限制;
  • 每次请求的执行时间可能有上限。

Edge 常见边界:

  • 运行时可能不是完整 Node.js;
  • 某些 Node 内置模块不可用;
  • 数据库连接方式需要适配;
  • 请求会在离用户较近的区域执行,但数据源未必也在附近;
  • 全局变量更不能被视为可靠的持久缓存。

因此,代码应依赖部署目标支持的运行时 API,而不是默认服务器一定具备完整 Node 环境。


9. 渐进发布、缓存和回滚的相互影响

前端发布不仅是替换 JavaScript 文件,还涉及 HTML、payload、API 和 CDN 缓存之间的兼容性。

一次发布可能产生:

HTML 版本 A
引用的 JS 版本 B
payload 结构版本 A
API 响应版本 B

如果旧 HTML 被 CDN 缓存,而旧 HTML 引用的资源已经被删除,用户会得到 404;如果新客户端代码读取旧 payload 中不存在的字段,也可能在水合或导航阶段失败。

更稳妥的发布策略是:

  1. 静态资源文件使用内容哈希,例如 app.8f31.js
  2. 新资源先上传,再切换 HTML;
  3. 保留旧版本资源一段时间;
  4. API 做向后兼容,先支持旧客户端;
  5. HTML 和页面缓存设置合理的短 TTL;
  6. 回滚时同时恢复应用版本和必要的路由配置;
  7. 通过真实 URL 检查 SSR HTML、资源和 API 状态。

灰度发布还要考虑缓存命中。若 CDN 在灰度判断前就返回了缓存 HTML,那么用户未必真的访问了目标版本。需要明确:

  • 灰度依据是 Cookie、Header、用户 ID 还是实例;
  • CDN 是否会把灰度维度纳入缓存键;
  • 是否允许不同版本共享静态资源;
  • 回滚后旧客户端还能否访问旧 API。

10. 常见误解与诊断路径

误解一:SSR 页面就不会有白屏

SSR 只能保证服务器成功生成 HTML 时,浏览器有机会先显示内容。如果:

  • SSR 等待上游 API 超时;
  • 服务端 JavaScript 运行异常;
  • CDN 缓存了错误响应;
  • 页面关键内容被 ClientOnly 包裹;

用户仍可能看到空白页、错误页或长时间等待。

诊断时先执行:

curl -sS https://example.com/articles/vue-ssr | grep -E '<h1>|<title>'

若响应 HTML 中没有正文,问题发生在 SSR、数据获取或缓存层,不是水合阶段。

误解二:水合警告只是开发环境噪声

水合警告说明服务端和客户端对同一节点的初始判断不同。应在浏览器开发者工具、服务端日志和实际 HTML 之间对比:

  1. 查看服务器返回的 HTML;
  2. 查看客户端水合前的状态;
  3. 搜索随机数、当前时间、时区、浏览器 API;
  4. 检查异步数据是否在服务端和客户端使用了同一个键;
  5. 检查条件渲染是否依赖只存在于浏览器的值。

误解三:useFetch 会自动缓存所有请求

useFetch 主要解决 SSR 数据传递、响应式状态和请求去重,不等于跨请求、跨实例、跨部署区域的业务缓存。

要实现跨请求缓存,必须明确使用:

  • HTTP Cache-Control
  • CDN;
  • Nitro 路由规则;
  • Redis 或其他共享缓存;
  • 上游 API 的缓存机制。

每种缓存都要明确缓存键、TTL、失效方式和隐私边界。

误解四:把所有页面改成 SSR 就能提升 SEO

如果页面没有可抓取的链接、正确的状态码、稳定的 canonical、准确的标题和正文,SSR 只是把问题更早地生成在服务器上。

SEO 的有效条件是:

可发现可抓取内容正确状态码正确\text{可发现} \land \text{可抓取} \land \text{内容正确} \land \text{状态码正确}

SSR 主要改善“内容正确地出现在初始 HTML 中”这一部分,不能替代站点结构和内容策略。


11. 选择渲染策略的判断方法

可以按数据和交互边界做判断:

适合 SSG

  • 内容在构建时已知;
  • 更新可以接受重新构建;
  • 页面面向所有用户相同;
  • 希望最大化 CDN 静态分发。

例如文档、版本化教程、公司介绍页。

适合 SSR

  • 页面必须根据请求实时生成;
  • 需要读取 Cookie、地域或权限;
  • 首屏内容依赖实时或准实时数据;
  • 页面需要为搜索引擎提供动态正文。

例如公开文章详情、商品详情、区域化内容页。

适合 CSR

  • 页面主要是登录后的管理界面;
  • SEO 不重要;
  • 内容高度依赖用户交互;
  • SSR 会增加复杂度,却不能带来实际收益。

例如内部报表、拖拽编辑器和复杂工作台。

适合混合渲染

实际应用通常同时包含:

/                 -> 静态生成
/articles/**      -> SSR 或 SWR
/search           -> SSR + 查询参数
/account/**       -> SSR/CSR,禁止公共缓存
/admin/**         -> CSR
/api/**           -> 按接口权限和数据新鲜度配置

关键不在于选一个全局模式,而在于让每条路由的数据敏感性、SEO 要求、缓存策略和部署成本相互匹配。

当以下条件同时成立时,Nuxt SSR 才真正适合某个页面:

需要首屏 HTML服务端可访问数据源能保证水合初始一致能定义正确缓存边界\text{需要首屏 HTML} \land \text{服务端可访问数据源} \land \text{能保证水合初始一致} \land \text{能定义正确缓存边界}

缺少任意一项,都可能出现相应问题:

  • 不需要首屏 HTML:SSR 增加了服务器成本;
  • 服务端无法访问数据源:SSR 只能返回加载壳体或失败;
  • 无法保证一致:出现水合警告和状态覆盖;
  • 无法定义缓存边界:出现陈旧数据或隐私泄露。

Vue SSR 与 Nuxt 的核心并不是把所有页面都搬到服务器,而是明确每一段代码在哪个运行时执行、数据在哪个阶段获取、结果在哪一层缓存,以及服务器生成的 DOM 如何安全地交给客户端继续运行。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。