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>
<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。
<!-- 示意,不是 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 四种模式对比
| CSR | SSR | SSG | ISR / 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 包元数据
# 商城仓库根目录
mkdir vue-shop-ssr
cd vue-shop-ssr创建下面的 package.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" }
}// 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
{
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}# 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.json3.3 布局系统
<!-- vue-shop-ssr/app/app.vue -->
<template><NuxtLayout><NuxtPage /></NuxtLayout></template><!-- 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-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 登录。
// 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 }
}// 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。
// 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)
})// 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-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-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-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
| useFetch | useAsyncData | |
|---|---|---|
| 适用 | HTTP 请求 | 多个请求或其他异步组合 |
| 语法 | useFetch(url, options) | useAsyncData(key, handler) |
| 底层 | 基于 useAsyncData 与 $fetch | 管理异步状态与 payload |
| key | 4.5.2 默认由 URL、选项与调用位置生成;可显式指定 | 本例显式指定,便于核对共享范围 |
不同组件请求相同 URL,不应直接推断一定共享状态;需要共享时显式约定相同 key 与选项。SSR 相对路径 useFetch 会使用当前请求上下文;外部 $fetch 不会自动获得浏览器的登录身份。若以后加入私有页面,要明确允许转发哪些凭证、转给哪个目标,并禁用跨用户页面缓存。数据获取与请求转发
5. SEO 优化
把下面代码加到详情页已有的 script setup 末尾,不再新增第二个 script setup。getter 保证切换商品后 title/meta 随数据更新:
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 同级。这里只为已经建立的页面配置规则:
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。混合渲染
# 在 vue-shop-ssr/,保持前面的 Express API 运行
npm run typecheck
npm run build
npm run previewpreview 支持 --port,不支持 dev 命令的 --host。仅在本机预览时,可在本实验的 .env 中设置 NITRO_HOST=127.0.0.1;不要把主机名作为位置参数,否则 CLI 会把它当作项目目录。
以生产 preview 检查:首次 /products 返回内容 HTML,缓存存续期内可返回快照;/compare 的内容需要客户端渲染;首页已预渲染。静态 generate 不适合验证依赖运行时服务的全部混合规则。本课不填写未测得的加载时间或提升百分比。
7. Hydration Mismatch 常见问题
服务端与客户端分别执行 new Date().toLocaleString(),时间或时区可能不同。先让初始输出一致,再在 mounted 后更新浏览器时间:
<!-- 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-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 提交
git add .
git commit -m "L29: Nuxt 4 SSR 商品目录与渲染模式实验"🔗 → 下一节
L30 回到原商城 client/,测量实际构建与运行结果,并用 Playwright 验证购物流程;本节独立 Nuxt 目录保留作对照。