Skip to content

L29 · SSR 与 Nuxt:首屏优化 ​

🎯 本节目标:区分 CSR / SSR / SSG / ISR,用 Nuxt 4 实现可验证的 SSR 商品页
📦 本节产出:独立的 SSR 商品目录 + SEO meta + useAsyncData + 混合渲染实验
🔗 前置钩子:L20 商品 API,L28 已完成的商城 SPA
🔗 后续钩子:L30 回到 client/ 测量性能并测试完整购物流程

1. 四种渲染模式 ​

1.1 CSR:客户端渲染(Client-Side Rendering) ​

目前 client/ 中的 Vite SPA 把 HTML 外壳发给浏览器,再由 JavaScript 请求数据、渲染页面。这里说的“外壳”不等于整个 HTML 文件为空:

html
<!-- 示例:初始响应还没有商品内容 -->
<html>
  <body>
    <div id="app"></div>
    <script type="module" src="/assets/index-example.js"></script>
  </body>
</html>

能执行 JavaScript 的搜索引擎可能继续渲染页面;不能执行、执行受限或只读取初始 HTML 的抓取器与分享预览服务,可能拿不到商品内容。不能一概说 CSR 无法被索引。

1.2 SSR:服务端渲染(Server-Side Rendering) ​

请求到达时,服务端执行组件、获取数据并生成 HTML。浏览器收到的初始响应可以包含商品名称、描述和价格,然后下载脚本完成 hydration。

html
<!-- 示意,不是 Nuxt 构建产物的逐字复制 -->
<div id="__nuxt">
  <h1>示例商品</h1>
  <p>¥299.00</p>
  <p>商品描述已经在初始 HTML 中。</p>
</div>

SSR 能让内容更早出现,但服务端计算、上游 API 延迟、缓存和网络都会影响结果;SSR 不保证比 CSR 更快,也不自动减少客户端脚本。

1.3 SSG:静态站点生成(Static Site Generation) ​

构建时为选定、枚举或爬取到的路由生成 HTML,随后作为静态文件提供。动态参数路由需要给出可生成的路径;不能仅写一个 [id].vue 就假定所有商品都已生成。

1.4 ISR:增量静态再生(Incremental Static Regeneration) ​

ISR 在请求时按策略生成、缓存或重新生成页面。Nuxt 的 isr 还依赖部署平台支持;swr 表示 stale-while-revalidate 缓存策略,不能把所有 swr 示例都当作平台 ISR。缓存过期后可能先返回旧内容,再在后台更新;首次没有缓存时仍要等待生成。

1.5 四种模式对比 ​

CSRSSRSSGISR / SWR
初始 HTML 的业务内容通常没有可包含可包含可包含
首屏主要成本脚本、数据、客户端渲染服务端渲染、数据、网络静态资源传输缓存命中或重新生成
运行时服务静态托管 + 业务 API渲染服务 + 业务 API静态托管;交互仍可能调用 API支持缓存与再生成的运行环境
内容时效取决于请求与缓存取决于请求与缓存构建时快照可在一段时间内陈旧
常见用途管理后台公开商品或内容页文档、营销页允许短暂陈旧的公开内容

这些模式不决定业务数据是否实时。下单仍要由 L25 服务端重新检查价格与库存。Nuxt 渲染模式


2. Hydration 详解 ​

Hydration(水合)是客户端 Vue 使用服务端生成的 DOM 和序列化数据建立组件状态、事件监听和后续更新关系的过程。客户端预期与 HTML 一致时,可以复用已有 DOM;不一致时可能修复或重建部分节点。

水合前,普通链接、原生表单输入等浏览器行为仍然存在;依赖 Vue 点击处理器的功能尚未就绪。Nuxt 的 payload 是框架传输机制,不需要自己向 window.__NUXT__ 写入数据。


3. Nuxt 4 基础 ​

3.1 创建项目 ​

本节在商城仓库内新增 vue-shop-ssr/ 独立实验,不覆盖 client/。它只展示公开商品;登录、购物车、订单、上传和 Socket 继续由原 SPA 承担。不能把读取 localStorage、window 的 SPA 单例直接搬进 SSR 服务端。

Nuxt 3 已结束常规支持。本例固定 Nuxt 4.5.2,与其依赖匹配使用 Vue 3.5.43 / Vue Router 5.3.1。此独立项目使用 Node 22.19+ 的 22.x;原商城 Node 22.12+、Router 4 的基线不受影响。不要在这里另装商城使用的 Router 4。Nuxt 支持计划、Nuxt 4.5.2 包元数据

bash
# 商城仓库根目录
mkdir vue-shop-ssr
cd vue-shop-ssr

创建下面的 package.json,而后按后续小节创建文件:

json
{
  "name": "vue-shop-ssr",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.19.0 <23" },
  "scripts": {
    "dev": "nuxt dev --host 127.0.0.1 --port 3001",
    "build": "nuxt build",
    "postinstall": "nuxt prepare",
    "typecheck": "nuxt typecheck",
    "preview": "nuxt preview --port 3001"
  },
  "dependencies": { "nuxt": "4.5.2", "vue": "3.5.43", "vue-router": "5.3.1", "ofetch": "1.5.1" },
  "devDependencies": { "typescript": "5.9.3", "vue-tsc": "3.3.11" }
}
typescript
// vue-shop-ssr/nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2026-09-23',
  devtools: { enabled: false },
  nitro: { prerender: { crawlLinks: false } }, // 只预渲染明确配置的首页
  runtimeConfig: {
    apiBase: 'http://127.0.0.1:3000/api', // 私有:只由 Nuxt server 使用
    public: { shopOrigin: 'http://localhost:5173' },
  },
})

文件:vue-shop-ssr/tsconfig.json

json
{
  "files": [],
  "references": [
    { "path": "./.nuxt/tsconfig.app.json" },
    { "path": "./.nuxt/tsconfig.server.json" },
    { "path": "./.nuxt/tsconfig.shared.json" },
    { "path": "./.nuxt/tsconfig.node.json" }
  ]
}
text
# vue-shop-ssr/.gitignore
node_modules/
.nuxt/
.output/
.env

完成文件后执行 npm install 并提交生成的 package-lock.json。先按 L28 启动 server/,确保商品 API 至少有一条可用商品;再运行这里的 npm run dev,打开 http://127.0.0.1:3001。部署时用 NUXT_API_BASE 和 NUXT_PUBLIC_SHOP_ORIGIN 覆盖运行时配置,public 中不能放密钥。

3.2 文件路由 ​

Nuxt 4 默认把 Vue 应用放在 app/,server/ 和 shared/ 仍在项目根。本节所有页面都在后文给出,不假设已经迁移整个商城:

vue-shop-ssr/
├── app/
│   ├── app.vue
│   ├── layouts/default.vue
│   ├── components/RealTimeClock.vue
│   └── pages/
│       ├── index.vue               → /
│       ├── compare.vue             → /compare
│       ├── request-scope.vue       → /request-scope
│       └── products/
│           ├── index.vue           → /products
│           └── [id].vue            → /products/:id
├── shared/types/catalog.ts
├── server/
│   ├── utils/catalog.ts
│   └── api/products/
│       ├── index.get.ts            → GET /api/products
│       └── [id].get.ts             → GET /api/products/:id
├── nuxt.config.ts
├── package.json
└── tsconfig.json

3.3 布局系统 ​

vue
<!-- vue-shop-ssr/app/app.vue -->
<template><NuxtLayout><NuxtPage /></NuxtLayout></template>
vue
<!-- vue-shop-ssr/app/layouts/default.vue -->
<template>
  <div class="layout">
    <header><nav aria-label="实验导航">
      <NuxtLink to="/">首页</NuxtLink> · <NuxtLink to="/products">商品</NuxtLink> ·
      <NuxtLink to="/compare">数据组合</NuxtLink> · <NuxtLink to="/request-scope">请求隔离</NuxtLink>
    </nav></header>
    <main><slot /></main>
    <footer>SSR 商品目录实验</footer>
  </div>
</template>
<style>
body { margin: 0; font-family: system-ui, sans-serif; color: #223; }
.layout { max-width: 960px; margin: auto; padding: 24px; }
main { min-height: 60vh; padding-block: 24px; }
img { max-width: 100%; height: auto; }
a { color: #237a5b; }
</style>
vue
<!-- vue-shop-ssr/app/pages/index.vue -->
<script setup lang="ts">
useSeoMeta({ title: 'SSR 商品目录实验', description: '比较公开商品页的初始 HTML 与客户端交互。' })
</script>
<template>
  <section><h1>SSR 商品目录实验</h1><NuxtLink to="/products">查看商品</NuxtLink></section>
</template>

4. SSR 数据获取 ​

4.1 useFetch ​

先让 Nuxt 的同源 API 读取 L20 的公开商品接口。浏览器只访问 Nuxt,SSR 也走这层固定目标的读取函数。这里没有转发 Cookie 或 Authorization,也没有实现 SSR 登录。

typescript
// vue-shop-ssr/shared/types/catalog.ts
export interface Product {
  _id: string; name: string; description: string; price: number
  images: string[]; category: string; stock: number
  rating: number; reviewCount: number; isActive: boolean; createdAt: string; updatedAt: string
}
export interface ApiResponse<T> { success: true; data: T }
export interface ProductList extends ApiResponse<Product[]> {
  pagination: { page: number; limit: number; total: number; totalPages: number }
}
typescript
// vue-shop-ssr/server/utils/catalog.ts
import { ofetch } from 'ofetch'
export async function readCatalog<T>(path: string, query?: Record<string, string | number>): Promise<T> {
  const config = useRuntimeConfig()
  try {
    return await ofetch<T>(path, { baseURL: config.apiBase, query, timeout: 5000, retry: 0 })
  } catch (error) {
    const status = (error as { statusCode?: number }).statusCode
    throw createError({
      statusCode: status && status >= 400 && status < 500 ? status : 502,
      statusMessage: status === 404 ? 'Product not found' : 'Catalog request failed',
    })
  }
}

这里用 ofetch 访问外部 Express API,并明确返回类型,避免 Nuxt 的内部路由类型推导反过来依赖本函数。泛型描述约定的响应结构,不会自动校验返回的 JSON。

typescript
// vue-shop-ssr/server/api/products/index.get.ts
import type { ProductList } from '#shared/types/catalog'
import { readCatalog } from '../../utils/catalog'
export default defineEventHandler(event => {
  const incoming = getQuery(event)
  const query: Record<string, string> = {}
  for (const key of ['page', 'limit', 'sort', 'category', 'search']) {
    const value = incoming[key]
    if (value !== undefined && typeof value !== 'string') throw createError({ statusCode: 400, statusMessage: 'Invalid query' })
    if (typeof value === 'string') query[key] = value
  }
  return readCatalog<ProductList>('/products', query)
})
typescript
// vue-shop-ssr/server/api/products/[id].get.ts
import type { ApiResponse, Product } from '#shared/types/catalog'
import { readCatalog } from '../../utils/catalog'
export default defineEventHandler(event => {
  const id = getRouterParam(event, 'id') || ''
  if (!/^[a-f\d]{24}$/i.test(id)) throw createError({ statusCode: 400, statusMessage: 'Invalid product id' })
  return readCatalog<ApiResponse<Product>>(`/products/${id}`)
})

先补能进入详情页的列表。这里只展示第一页,继续分页留在原商城:

vue
<!-- vue-shop-ssr/app/pages/products/index.vue -->
<script setup lang="ts">
import type { ProductList } from '#shared/types/catalog'
const { data, status, error, refresh } = await useFetch<ProductList>('/api/products', {
  key: 'catalog-first-page', query: { page: 1, limit: 12 },
})
useSeoMeta({ title: '商品目录' })
</script>
<template>
  <section>
    <h1>商品目录</h1>
    <p v-if="status === 'pending'">加载中…</p>
    <div v-else-if="error"><p role="alert">商品加载失败</p><button @click="refresh()">重试</button></div>
    <ul v-else-if="data?.data.length">
      <li v-for="product in data.data" :key="product._id">
        <NuxtLink :to="`/products/${product._id}`">{{ product.name }}</NuxtLink>
        — ¥{{ product.price.toFixed(2) }}
      </li>
    </ul>
    <p v-else>暂无商品,请先在原商城添加商品。</p>
  </section>
</template>
vue
<!-- vue-shop-ssr/app/pages/products/[id].vue -->
<script setup lang="ts">
import type { ApiResponse, Product } from '#shared/types/catalog'
const route = useRoute()
const config = useRuntimeConfig()
const id = computed(() => String(route.params.id || ''))
const { data: response, status, error, refresh } = await useFetch<ApiResponse<Product>>(
  () => `/api/products/${encodeURIComponent(id.value)}`,
  { key: computed(() => `catalog-product:${id.value}`) },
)
const product = computed(() => response.value?.data)
// 直接打开不存在的商品时,让初始 HTTP 响应保留真实错误状态。
if (error.value) throw createError({ statusCode: error.value.statusCode || 502, statusMessage: 'Product unavailable' })
const shopUrl = computed(() => new URL(`/products/${id.value}`, config.public.shopOrigin).href)
// 第 5 节的 SEO 代码追加在这个 script setup 中。
</script>
<template>
  <section>
    <p v-if="status === 'pending'">加载中…</p>
    <div v-else-if="error"><p role="alert">商品加载失败</p><button @click="refresh()">重试</button></div>
    <article v-else-if="product">
      <h1>{{ product.name }}</h1>
      <img v-if="product.images[0]" :src="product.images[0]" :alt="product.name" width="480" height="360">
      <p v-else>暂无图片</p>
      <p>¥{{ product.price.toFixed(2) }}</p>
      <p>{{ product.description }}</p>
      <p>{{ product.stock > 0 ? `有货(当前快照 ${product.stock} 件)` : '暂无库存' }}</p>
      <p>评分 {{ product.rating.toFixed(1) }}({{ product.reviewCount }} 条评价)</p>
      <a :href="shopUrl">在商城查看并购买</a>
    </article>
    <NuxtLink to="/products">返回商品目录</NuxtLink>
  </section>
</template>

useFetch 把正常 SSR 取得的数据放进 payload,供初次 hydration 复用;响应式 URL/key 变化、显式 refresh 等仍会触发新请求。这里使用响应式 key,避免路由 id 变化后沿用旧商品的键。不要把它理解为永久 HTTP 缓存。useFetch

4.2 useAsyncData ​

组合请求仍使用已有公开接口,不引用课程中不存在的 /api/user/stats:

vue
<!-- vue-shop-ssr/app/pages/compare.vue -->
<script setup lang="ts">
import type { ProductList } from '#shared/types/catalog'
const { data, error } = await useAsyncData('catalog-comparison', async () => {
  const [newest, cheapest] = await Promise.all([
    $fetch<ProductList>('/api/products', { query: { limit: 3, sort: '-createdAt' } }),
    $fetch<ProductList>('/api/products', { query: { limit: 3, sort: 'price' } }),
  ])
  return { newest: newest.data, cheapest: cheapest.data }
})
useSeoMeta({ title: '商品数据组合' })
</script>
<template>
  <section>
    <h1>商品数据组合</h1>
    <p v-if="error" role="alert">组合请求失败</p>
    <template v-else-if="data">
      <h2>最近上架</h2><ul><li v-for="p in data.newest" :key="p._id">{{ p.name }}</li></ul>
      <h2>价格较低</h2><ul><li v-for="p in data.cheapest" :key="p._id">{{ p.name }} — ¥{{ p.price.toFixed(2) }}</li></ul>
    </template>
  </section>
</template>

同一 key 的 data/error/status 可共享,因此对应的 handler、transform、deep 等选项也应保持一致。这里的 key 管理 Nuxt 数据状态与请求去重,不代表所有访客共用一份用户数据缓存。

4.3 useFetch vs useAsyncData ​

useFetchuseAsyncData
适用HTTP 请求多个请求或其他异步组合
语法useFetch(url, options)useAsyncData(key, handler)
底层基于 useAsyncData 与 $fetch管理异步状态与 payload
key4.5.2 默认由 URL、选项与调用位置生成;可显式指定本例显式指定,便于核对共享范围

不同组件请求相同 URL,不应直接推断一定共享状态;需要共享时显式约定相同 key 与选项。SSR 相对路径 useFetch 会使用当前请求上下文;外部 $fetch 不会自动获得浏览器的登录身份。若以后加入私有页面,要明确允许转发哪些凭证、转给哪个目标,并禁用跨用户页面缓存。数据获取与请求转发


5. SEO 优化 ​

把下面代码加到详情页已有的 script setup 末尾,不再新增第二个 script setup。getter 保证切换商品后 title/meta 随数据更新:

typescript
useSeoMeta({
  title: () => product.value?.name || '商品详情',
  description: () => product.value?.description || '',
  ogTitle: () => product.value?.name || '商品详情',
  ogDescription: () => product.value?.description || '',
  ogImage: () => product.value?.images[0],
  ogType: 'website',
  twitterCard: 'summary_large_image',
  twitterTitle: () => product.value?.name || '商品详情',
})
useHead(() => ({
  script: product.value ? [{
    key: 'product-jsonld', type: 'application/ld+json',
    // 把 < 转义为 JSON Unicode,避免商品文本提前结束 script 标签。
    innerHTML: JSON.stringify({
      '@context': 'https://schema.org', '@type': 'Product',
      name: product.value.name, description: product.value.description,
      image: product.value.images,
      offers: {
        '@type': 'Offer', price: product.value.price.toFixed(2), priceCurrency: 'CNY',
        availability: product.value.stock > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
      },
    }).replace(/</g, '\\u003c'),
  }] : [],
}))

Nuxt 的响应式 head API 会更新标签;JSON-LD 原始字符串仍要安全序列化。SEO 标签和结构化数据帮助抓取器理解页面,但不保证收录、排名或富媒体搜索结果。useSeoMeta、useHead

验证时用浏览器“查看页面源代码”或 curl 检查原始响应中是否已经有商品名称、title 和 JSON-LD,不能只看执行完脚本后的 Elements 面板。


6. 混合渲染策略(Route Rules) ​

在现有 nuxt.config.ts 中追加 routeRules,与 runtimeConfig 同级。这里只为已经建立的页面配置规则:

typescript
routeRules: {
  '/': { prerender: true },
  '/products': { swr: 60 }, // 公开列表允许短暂陈旧,首次访问仍需渲染
  '/products/**': { ssr: true }, // 默认 SSR;本例没有额外页面缓存
  '/compare': { ssr: false }, // 用同一套页面观察 CSR 的初始 HTML
  '/request-scope': { ssr: true }, // 请求隔离实验,不能缓存整个页面
},

ssr: true 本身不承诺库存实时:HTML 只代表读取时的快照。ssr: false 也不是鉴权措施;私有 API 仍要验证身份。swr 使用 Nitro 的响应缓存策略,isr 则需要部署适配器支持(如支持该选项的 Vercel/Netlify 环境)。本课 Node 部署只验证 SWR,不声称启用了平台 ISR。混合渲染

bash
# 在 vue-shop-ssr/,保持前面的 Express API 运行
npm run typecheck
npm run build
npm run preview

preview 支持 --port,不支持 dev 命令的 --host。仅在本机预览时,可在本实验的 .env 中设置 NITRO_HOST=127.0.0.1;不要把主机名作为位置参数,否则 CLI 会把它当作项目目录。

以生产 preview 检查:首次 /products 返回内容 HTML,缓存存续期内可返回快照;/compare 的内容需要客户端渲染;首页已预渲染。静态 generate 不适合验证依赖运行时服务的全部混合规则。本课不填写未测得的加载时间或提升百分比。


7. Hydration Mismatch 常见问题 ​

服务端与客户端分别执行 new Date().toLocaleString(),时间或时区可能不同。先让初始输出一致,再在 mounted 后更新浏览器时间:

vue
<!-- vue-shop-ssr/app/components/RealTimeClock.vue -->
<script setup lang="ts">
const now = ref('')
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
  const update = () => { now.value = new Date().toLocaleString() }
  update()
  timer = setInterval(update, 1000)
})
onUnmounted(() => { if (timer) clearInterval(timer) })
</script>
<template><time>{{ now || '等待客户端时钟' }}</time></template>

另一个边界是服务端请求隔离。把 ref 放在模块顶层并导出,可能让不同请求共享它;组件 setup 内的局部 ref 没有这个问题。需要 Nuxt 共享、序列化的状态时,用请求范围的 useState:

vue
<!-- vue-shop-ssr/app/pages/request-scope.vue -->
<script setup lang="ts">
// 随机值只用于观察:每次新的服务端页面请求各自生成,hydration 复用 payload。
const marker = useState<string>('request-marker', () => Math.random().toString(36).slice(2))
const inputId = useId() // 为标签关联生成稳定 ID,不用来修复日期/随机业务内容
</script>
<template>
  <section>
    <h1>请求隔离实验</h1><p data-request-marker>{{ marker }}</p>
    <label :for="inputId">备注</label><input :id="inputId">
    <ClientOnly><RealTimeClock /><template #fallback><span>等待客户端时钟</span></template></ClientOnly>
  </section>
</template>

分别直接请求两次 /request-scope,应得到两个独立 marker;同一页面 hydration 后 marker 保持一致。ClientOnly 适合依赖浏览器 API 的组件,不应把整页都包进去掩盖 SSR 不一致。useId 解决稳定标识符,不能让两个不同时间或随机值自动相等。Nuxt 状态管理、Vue SSR 注意事项


8. 本节总结 ​

检查清单 ​

  • [ ] 能说明 CSR / SSR / SSG / ISR 的成本与缓存边界
  • [ ] 理解初始 HTML 与 hydration 后 DOM 的区别
  • [ ] 能按 Nuxt 4 的 app/、server/、shared/ 目录创建独立项目
  • [ ] 能用 useFetch / useAsyncData 读取真实商品接口,并处理参数变化与错误
  • [ ] 能让 SEO meta / JSON-LD 随商品变化,且安全序列化文本
  • [ ] 能在生产 preview 检查 prerender / SWR / CSR 的差异
  • [ ] 能核对 SSR 请求隔离,避免复制依赖 window/localStorage 的 SPA 单例
  • [ ] 能解释 ClientOnly、useId 与 hydration mismatch 的适用范围

Git 提交 ​

bash
git add .
git commit -m "L29: Nuxt 4 SSR 商品目录与渲染模式实验"

🔗 → 下一节 ​

L30 回到原商城 client/,测量实际构建与运行结果,并用 Playwright 验证购物流程;本节独立 Nuxt 目录保留作对照。