Nuxt 数据获取、Server API 与缓存 / Nuxt Data Fetching, Server APIs, and Caching
📅 创建时间:2026-07-28 🏷️ 标签:#Nuxt #DataFetching #Nitro #Cache #ServerAPI #ISR #Auth 📚 前置知识:[[./05-nuxt-routing-and-rendering]]
📋 本章目标
- 深入理解
useFetch和useAsyncData的内部执行机制、SSR payload 传递与缓存 key 策略 - 掌握 Nitro 多层缓存架构:cachedEventHandler、Route Rules、SWR 与自定义存储后端
- 理解 ISR(增量静态再生)的工作方式及其与 SSG/SSR 的选择框架
- 能够设计类型安全、带校验和错误映射的 Server Routes API
- 掌握 Drizzle ORM 和 Prisma 在 Nuxt 服务端的集成模式与连接池管理
- 建立服务端认证体系:JWT、Cookie/Session 与中间件认证链
- 了解 Nuxt 4 Server Components 的概念、与 React RSC 的差异及适用场景
第1部分:useFetch 与 useAsyncData 深度机制
1.1 三种数据 API 的执行边界
在 Nuxt 中,每一次数据请求都必须明确:它发生在服务器端、客户端、还是两者皆有。Nuxt 提供三层数据 API,各自对应不同的执行边界。
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 数据 API 的执行边界与选择决策 │
├─────────────────────────────────────────────────────────────┤
│ │
│ $fetch (ofetch) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 客户端:浏览器直接发 HTTP 请求 │ │
│ │ 服务端:Nitro 服务端 fetch(不会回环到自身路由) │ │
│ │ 无 SSR payload 去重 → 可能重复请求 │ │
│ │ 适用:用户事件触发的 Mutation、Server API 内部调用 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ useFetch (封装 $fetch + payload 传递) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ SSR 阶段:在服务端执行 → 结果写入 nitro payload │ │
│ │ 客户端水合:从 window.__NUXT__ 读取 payload 复用 │ │
│ │ URL/参数响应式变化 → 自动重新请求 │ │
│ │ 适用:页面级数据、直接访问 URL 的列表/详情 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ useAsyncData (任意异步函数 + 手动 key) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 不限于 HTTP 请求:可包裹 ORM 查询、多个 fetch 组合 │ │
│ │ 必须显式提供唯一 key → 控制缓存去重 │ │
│ │ 适用:组合多个数据源、需要 transform、非 REST 数据 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 核心原则:URL 直出数据用 useFetch,组合/转换用 useAsyncData│
│ 用户交互触发的写操作用 $fetch,永远不要混用 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 useFetch 内部执行流程
useFetch 并非简单的 $fetch 包装。它的核心价值在于 SSR payload 的自动提取和复用机制。
┌─────────────────────────────────────────────────────────────┐
│ useFetch 内部执行流程(SSR + CSR) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 首次请求(SSR) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 服务端 │──→│ $fetch │──→│ 写入 │──→│ 渲染 │ │
│ │ 调用 │ │ 请求 API │ │ payload │ │ HTML │ │
│ └──────────┘ └──────────┘ └──────────┘ └────┬────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┘ │
│ │ HTML 中包含: │
│ │ <script>window.__NUXT__ = { │
│ │ payload: { │
│ │ "/api/projects/42": { data: {...}, fetchedAt: ... } │
│ │ } │
│ │ }</script> │
│ │ │
│ 客户端水合 │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ 读取 │──→│ 命中 payload │──→│ 跳过 │ │
│ │ __NUXT__ │ │ (同 key) │ │ 网络请求 │ │
│ └──────────┘ └──────────────┘ └──────────┘ │
│ │
│ 导航到新路由(CSR) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ URL 变化 │──→│ $fetch │──→│ 写入内存缓存 │ │
│ │ │ │ 新请求 │ │ (非 payload) │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘关键细节:useFetch 的 URL 参数支持响应式 getter 函数。当路由参数或 query 变化时,Nuxt 自动触发重新请求。但须注意:若传入静态字符串,参数变化不会触发刷新。
// ✅ 响应式 URL:route.params.id 变化时自动重新请求
const { data, status, refresh } = await useFetch(
() => `/api/projects/${route.params.id}`
)
// ❌ 静态字符串:只在首次 setup 时执行一次
const { data } = await useFetch(`/api/projects/${route.params.id}`)1.3 useAsyncData 的缓存 Key 策略
useAsyncData 的第一个参数是缓存 key,用于在 payload 中唯一标识这份数据。Key 必须稳定且包含所有影响结果的参数,否则会出现错误的数据共享或重复缓存。
// ✅ 正确:key 包含所有区分因子
const { data } = await useAsyncData(
() => `project:${route.params.id}:v2`,
() => projectService.get(String(route.params.id))
)
// ❌ 错误:不同项目共享同一个 key
const { data } = await useAsyncData(
'project-detail', // 所有项目共享!第二个项目会读到第一个的缓存
() => projectService.get(String(route.params.id))
)
// ✅ 多数据源组合 + 自定义 transform
const { data } = await useAsyncData(
() => `dashboard:${teamId.value}`,
async () => {
const [projects, members, stats] = await Promise.all([
$fetch(`/api/teams/${teamId.value}/projects`),
$fetch(`/api/teams/${teamId.value}/members`),
$fetch(`/api/teams/${teamId.value}/stats`),
])
return { projects, members, stats }
},
{
transform: (raw) => ({
...raw,
projectCount: raw.projects.length,
activeMembers: raw.members.filter(m => m.status === 'active'),
}),
}
)1.4 状态管理与方法
useFetch 和 useAsyncData 返回统一的状态模型,包含 data、pending、error、status、refresh 和 execute。
const {
data, // Ref<T | null> — 成功后的数据
pending, // Ref<boolean> — 是否正在请求(含首次)
error, // Ref<Error | null> — 请求失败的错误对象
status, // Ref<'idle' | 'pending' | 'success' | 'error'>
refresh, // () => Promise<void> — 重新请求并更新
execute, // () => Promise<void> — 重新执行 handler
clear, // () => void — 清除 data 和 error,回到 idle
} = await useFetch(() => `/api/projects/${id}`)refresh 与 execute 的区别:refresh 使用相同的参数重新发起请求,而 execute 会重新运行整个 handler(对 useAsyncData 而言,这意味着重新执行你传入的异步函数,其中可以读取最新的响应式依赖)。
<script setup lang="ts">
const route = useRoute()
const { data: project, status, error, refresh } = await useFetch(
() => `/api/projects/${route.params.id}`
)
// Mutation 后刷新数据
async function updateName(newName: string) {
await $fetch(`/api/projects/${route.params.id}`, {
method: 'PATCH',
body: { name: newName },
})
await refresh() // 重新获取最新数据
}
</script>
<template>
<div>
<div v-if="status === 'pending'">加载中...</div>
<div v-else-if="status === 'error'">
加载失败:{{ error?.message }}
<button @click="refresh()">重试</button>
</div>
<div v-else-if="status === 'success' && project">
<h1>{{ project.name }}</h1>
</div>
</div>
</template>1.5 await 与 lazy 的选择框架
┌─────────────────────────────────────────────────────────────┐
│ await vs lazy 决策框架 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 场景 │ 策略 │ 原因 │
│ ├──────────────────────────────┼─────────────┼───────────┤
│ │ 页面主体内容(文章/产品详情) │ await │ 数据是 │
│ │ │ │ 页面核心 │
│ ├──────────────────────────────┼─────────────┼───────────┤
│ │ 侧栏/推荐/次要模块 │ lazy │ 不阻塞 │
│ │ │ │ 主要内容 │
│ ├──────────────────────────────┼─────────────┼───────────┤
│ │ 登录后用户专属数据 │ server:false │ 避免 SSR │
│ │ │ │ 缓存泄漏 │
│ ├──────────────────────────────┼─────────────┼───────────┤
│ │ 高频变化数据(股价/实时) │ lazy + │ 服务端 │
│ │ │ server:false │ 无意义 │
│ │
│ await 阻塞导航直到数据就绪 │
│ lazy 先完成导航,页面展示 pending 状态后再填充数据 │
│ server: false 只在客户端执行,首屏 data 为 null │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:Nitro 缓存策略与 ISR
2.1 Nitro 多层缓存架构
Nitro 是 Nuxt 的服务端引擎,内置多层缓存能力。理解每一层的职责和边界,是设计高性能 Nuxt 应用的基础。
┌─────────────────────────────────────────────────────────────┐
│ Nitro 多层缓存架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 1: 浏览器 HTTP 缓存 │ │
│ │ Cache-Control / ETag / Last-Modified │ │
│ │ 命中即返回 304,无需服务端计算 │ │
│ └─────────────────────────┬───────────────────────────┘ │
│ ↓ (miss) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 2: CDN / Edge 缓存 │ │
│ │ Cloudflare / Vercel Edge / Fastly │ │
│ │ 基于地理位置就近响应,大幅降低延迟 │ │
│ └─────────────────────────┬───────────────────────────┘ │
│ ↓ (miss) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 3: Nitro Route Rules 缓存 │ │
│ │ cachedEventHandler + routeRules 配置 │ │
│ │ SWR (Stale-While-Revalidate) 策略 │ │
│ └─────────────────────────┬───────────────────────────┘ │
│ ↓ (miss) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 4: 应用层缓存 │ │
│ │ SSR payload 去重 / useAsyncData key 缓存 │ │
│ │ 客户端内存缓存(导航间复用) │ │
│ └─────────────────────────┬───────────────────────────┘ │
│ ↓ (miss) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 5: 数据源缓存 │ │
│ │ Redis / Database query cache │ │
│ │ 最底层、最昂贵,尽量在上层命中 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 原则:越靠近用户缓存,响应越快、成本越低 │
│ 每层都需要独立的 TTL、失效策略和缓存键设计 │
│ │
└─────────────────────────────────────────────────────────────┘2.2 cachedEventHandler 与 Route Rules
Nitro 提供 cachedEventHandler 对单个事件处理函数进行缓存包装,以及 routeRules 在 nuxt.config.ts 中声明路由级缓存策略。
// server/api/projects/[id].get.ts
import { cachedEventHandler } from '#imports'
export default cachedEventHandler(
async (event) => {
const id = getRouterParam(event, 'id')
const project = await db.query.projects.findFirst({
where: eq(tables.projects.id, id!),
})
return project
},
{
// 缓存 key:包含路由参数和租户/语言等区分因子
getKey: (event) => {
const id = getRouterParam(event, 'id')
const tenant = event.context.tenant ?? 'default'
return `projects:${tenant}:${id}`
},
// 缓存 TTL
maxAge: 60 * 5, // 5 分钟
// SWR:过期后返回旧数据,后台刷新
swr: true,
// 最大 SWR 容忍时间
swrMaxAge: 60 * 60, // 最多返回 1 小时前的数据
// 是否缓存非 200 响应
varies: ['accept-language', 'authorization'],
// 自定义存储后端
// base: 'redis', // 需要配置 nitro.storage.redis
}
)nuxt.config.ts 中的 routeRules 提供了声明式的路由级缓存配置:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 静态生成:构建时预渲染
'/': { prerender: true },
'/about': { prerender: true },
// ISR:按时间增量重新生成
'/blog/**': { isr: { expirationTime: 60 * 60 } }, // 1 小时
// SWR:缓存 + 后台重新验证
'/api/trending': {
swr: true,
cache: { maxAge: 60 * 5, swrMaxAge: 60 * 30 },
},
// 完全静态 headers
'/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
// SPA 模式(仅客户端渲染)
'/admin/**': { ssr: false },
},
})2.3 自定义存储后端(Redis)
生产环境中,缓存应存储到 Redis 而非进程内存,以支持多实例共享和持久化。
// server/plugins/redis-cache.ts
import { createStorage } from 'unstorage'
import redisDriver from 'unstorage/drivers/redis'
export default defineNitroPlugin(() => {
const redisStorage = createStorage({
driver: redisDriver({
url: useRuntimeConfig().redisUrl,
ttl: 60 * 60, // 默认 TTL
}),
})
// 挂载到 Nitro 存储系统
useStorage().mount('cache:redis', redisStorage)
})// server/api/products/recommended.get.ts
export default cachedEventHandler(
async (event) => {
const products = await fetchRecommendedProducts()
return products
},
{
base: 'cache:redis', // 使用 Redis 存储
name: 'recommended', // 缓存组名(用于批量失效)
group: 'products',
maxAge: 60 * 15, // 15 分钟
swr: true,
}
)
// 缓存失效:通过编程方式清除特定缓存组
// server/api/admin/cache/invalidate.post.ts
export default defineEventHandler(async (event) => {
await useStorage('cache:redis').clear('nitro:cache:products:')
return { success: true }
})2.4 ISR:增量静态再生
ISR (Incremental Static Regeneration) 是 SSG 和 SSR 之间的折中方案:页面在构建时预渲染为静态 HTML,但在设定的过期时间后,下一次请求会触发后台重新生成。
┌─────────────────────────────────────────────────────────────┐
│ ISR 增量静态再生生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ T=0 构建时 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 预渲染 /blog/post-1 → post-1.html (静态文件) │ │
│ │ 预渲染 /blog/post-2 → post-2.html │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ T=30min 用户请求 /blog/post-1 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ expirationTime=1h → 未过期 → 直接返回静态 HTML │ │
│ │ 响应时间 ~5ms(CDN/静态文件) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ T=70min 用户请求 /blog/post-1 (已过期) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 返回旧版本 HTML(stale) │ │
│ │ 同时触发后台重新生成 → 下次请求使用新版本 │ │
│ │ 用户不会等待生成完成 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键配置: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ routeRules: { │ │
│ │ '/blog/**': { │ │
│ │ isr: { │ │
│ │ expirationTime: 60 * 60, // 过期时间(秒) │ │
│ │ }, │ │
│ │ swr: true, // 过期后先返回旧版本 │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.5 ISR vs SSG vs SSR 选择框架
| 维度 | SSG (Static) | ISR | SSR (Dynamic) |
|---|---|---|---|
| 构建时间 | 随页面数量线性增长 | 仅预渲染关键页面 | 无构建负担 |
| 首字节时间 | 极快(静态文件) | 快(陈旧版本)/ 慢(首次生成) | 取决于服务端性能 |
| 数据新鲜度 | 仅构建时 | 按过期时间窗口 | 每次请求最新 |
| 服务端成本 | 几乎为零(CDN) | 低(按需生成) | 高(每次请求) |
| 适用场景 | 文档、博客、营销页 | 电商商品页、内容平台 | 个性化数据、实时仪表盘 |
选择决策:数据变化频率低于每小时一次且对所有用户相同,优先 ISR。数据每次请求都可能不同或包含用户私有信息,必须 SSR。数据几乎不变且页面数量可控,使用纯 SSG。
第3部分:Server Routes API 设计
3.1 目录结构与路由约定
┌─────────────────────────────────────────────────────────────┐
│ Server Routes 目录结构与中间件链 │
├─────────────────────────────────────────────────────────────┤
│ │
│ server/ │
│ ├── api/ # /api/* 路由 │
│ │ ├── projects/ │
│ │ │ ├── index.get.ts # GET /api/projects │
│ │ │ ├── index.post.ts # POST /api/projects │
│ │ │ └── [id].get.ts # GET /api/projects/:id │
│ │ └── auth/ │
│ │ ├── login.post.ts # POST /api/auth/login │
│ │ └── logout.post.ts # POST /api/auth/logout │
│ │ │
│ ├── routes/ # 非 /api 前缀路由 │
│ │ └── health.get.ts # GET /health │
│ │ │
│ ├── middleware/ # 服务端中间件 │
│ │ ├── auth.ts # 认证中间件 │
│ │ ├── rate-limit.ts # 限流中间件 │
│ │ └── audit.ts # 审计日志中间件 │
│ │ │
│ └── utils/ # 服务端工具函数 │
│ ├── db.ts # 数据库客户端 │
│ ├── session.ts # 会话管理 │
│ └── validation.ts # 校验工具 │
│ │
│ 请求进入顺序: │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Nitro │──→│ Server │──→│ API │──→│ 响应 │ │
│ │ HTTP │ │ Middleware│ │ Handler │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘3.2 请求校验:h3 + Zod
TypeScript 的编译时类型检查无法验证运行时网络输入。所有来自客户端的数据(params、query、body、headers)必须在服务端入口处进行运行时校验。
// server/utils/validation.ts
import { z } from 'zod'
export const createProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
status: z.enum(['draft', 'active', 'archived']).default('draft'),
dueDate: z.string().datetime().optional(),
})
export const projectQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['draft', 'active', 'archived']).optional(),
search: z.string().max(200).optional(),
sort: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
})
export type CreateProjectInput = z.infer<typeof createProjectSchema>
export type ProjectQuery = z.infer<typeof projectQuerySchema>// server/api/projects/index.get.ts
import { projectQuerySchema } from '~~/server/utils/validation'
export default defineEventHandler(async (event) => {
// 1. 读取并校验 query 参数
const rawQuery = getQuery(event)
const parsed = projectQuerySchema.safeParse(rawQuery)
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid query parameters',
data: parsed.error.flatten().fieldErrors,
})
}
// 2. 现在 parsed.data 是完全类型安全的
const { page, limit, status, search, sort, order } = parsed.data
const projects = await db.query.projects.findMany({
where: (fields, { and, eq, like }) => {
const conditions = []
if (status) conditions.push(eq(fields.status, status))
if (search) conditions.push(like(fields.name, `%${search}%`))
return and(...conditions)
},
limit,
offset: (page - 1) * limit,
orderBy: (fields, { asc, desc }) => [
order === 'asc' ? asc(fields[sort]) : desc(fields[sort]),
],
})
return { data: projects, page, limit }
})3.3 请求体校验与错误处理
// server/api/projects/index.post.ts
import { createProjectSchema } from '~~/server/utils/validation'
export default defineEventHandler(async (event) => {
// 1. 认证检查
const session = await requireAuth(event)
// 2. 读取并校验 body
const body = await readBody(event)
const parsed = createProjectSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422, // Unprocessable Entity
statusMessage: 'Validation failed',
data: parsed.error.flatten(),
})
}
// 3. 业务逻辑
try {
const project = await db.insert(tables.projects)
.values({
...parsed.data,
ownerId: session.userId,
createdAt: new Date(),
})
.returning()
.get()
// 4. 审计日志(不阻塞响应)
event.waitUntil(
auditLog.create({
action: 'project.create',
userId: session.userId,
resourceId: project.id,
})
)
setResponseStatus(event, 201)
return project
} catch (err) {
if (isUniqueConstraintError(err)) {
throw createError({
statusCode: 409,
statusMessage: 'A project with this name already exists',
})
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to create project',
})
}
})3.4 错误响应规范
// server/utils/errors.ts
import type { H3Error } from 'h3'
// 统一错误响应格式
export interface ApiErrorResponse {
error: {
code: string
message: string
details?: unknown
correlationId: string
}
}
// 全局错误处理器
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error: H3Error, event) => {
// 记录完整错误信息到日志系统
const correlationId = event?.context?.correlationId ?? crypto.randomUUID()
console.error(`[${correlationId}]`, {
statusCode: error.statusCode,
message: error.statusMessage,
path: event?.path,
method: event?.method,
// 不在生产日志中记录 body(可能包含敏感数据)
})
// 确保错误响应不泄露内部信息
if (error.statusCode >= 500) {
error.statusMessage = 'Internal Server Error'
error.data = undefined // 不暴露堆栈/SQL
}
})
})3.5 中间件链
// server/middleware/auth.ts
import { getServerSession } from '#auth'
export default defineEventHandler(async (event) => {
// 跳过公开路由
const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health']
if (publicPaths.some(p => event.path.startsWith(p))) {
return
}
const session = await getServerSession(event)
if (!session) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required',
})
}
// 将会话信息注入 event.context,后续 handler 可直接使用
event.context.session = session
event.context.userId = session.userId
event.context.correlationId = crypto.randomUUID()
// 设置响应头
setResponseHeader(event, 'X-Correlation-Id', event.context.correlationId)
})// server/middleware/rate-limit.ts
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
export default defineEventHandler(async (event) => {
const clientIp = getRequestIP(event) ?? 'unknown'
const key = `rate:${clientIp}`
const now = Date.now()
const record = rateLimitStore.get(key)
if (record && now < record.resetAt) {
if (record.count >= 100) { // 100 requests per window
setResponseHeader(event, 'Retry-After', String(Math.ceil((record.resetAt - now) / 1000)))
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
record.count++
} else {
rateLimitStore.set(key, { count: 1, resetAt: now + 60_000 }) // 1 minute window
}
})第4部分:Database 集成
4.1 Drizzle ORM + Nuxt
Drizzle ORM 是类型安全的 TypeScript ORM,零运行时依赖,schema 定义即类型。与 Nuxt 的 Nitro 服务端天然契合。
// server/utils/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text('email').notNull().unique(),
name: text('name').notNull(),
avatarUrl: text('avatar_url'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
})
export const projects = sqliteTable('projects', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
description: text('description'),
status: text('status', { enum: ['draft', 'active', 'archived'] })
.notNull()
.default('draft'),
ownerId: text('owner_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
})
// 关系定义
export const projectsRelations = relations(projects, ({ one, many }) => ({
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
}),
}))// server/utils/db/index.ts
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import * as schema from './schema'
// 连接池管理:服务端全局单例
let dbInstance: ReturnType<typeof drizzle> | null = null
export function getDb() {
if (!dbInstance) {
const sqlite = new Database(
useRuntimeConfig().databaseUrl ?? './data/app.db'
)
// WAL 模式提升并发读取性能
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('busy_timeout = 5000')
dbInstance = drizzle(sqlite, { schema })
}
return dbInstance
}
// 便捷导出
export const db = () => getDb()
export const tables = schema// server/api/projects/[id].get.ts
import { eq } from 'drizzle-orm'
import { db, tables } from '~~/server/utils/db'
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const id = getRouterParam(event, 'id')!
const project = await db()
.select()
.from(tables.projects)
.where(eq(tables.projects.id, id))
.get()
if (!project) {
throw createError({ statusCode: 404, statusMessage: 'Project not found' })
}
if (project.ownerId !== session.userId) {
throw createError({ statusCode: 403, statusMessage: 'Access denied' })
}
return project
})4.2 Prisma + Nuxt
Prisma 提供声明式 schema、自动迁移和类型安全的查询客户端。
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
avatarUrl String?
projects Project[]
createdAt DateTime @default(now())
}
model Project {
id String @id @default(uuid())
name String
description String?
status String @default("draft")
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}// server/utils/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
// 全局单例,避免开发时热重载创建多个实例
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'warn', 'error']
: ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
// 优雅关闭
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('close', async () => {
await prisma.$disconnect()
})
})// server/api/projects/index.get.ts (Prisma 版本)
import { prisma } from '~~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Math.min(Number(query.limit) || 20, 100)
const [projects, total] = await Promise.all([
prisma.project.findMany({
where: { ownerId: session.userId },
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.project.count({ where: { ownerId: session.userId } }),
])
return {
data: projects,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
}
})4.3 连接池管理对比
| 维度 | Drizzle (better-sqlite3) | Prisma |
|---|---|---|
| 连接池 | SQLite 单连接(WAL 模式并发读) | 内置连接池(connection_limit) |
| Serverless | 不适合(无状态连接) | @prisma/adapter-pg + @prisma/client/edge |
| 冷启动 | 极快(无 WASM/生成) | 较慢(引擎二进制 + schema 验证) |
| 类型安全 | Schema = TypeScript 类型 | prisma generate 生成类型 |
| 迁移 | drizzle-kit generate | prisma migrate dev |
选择建议:Serverless 部署 + PostgreSQL 选 Prisma(官方 edge adapter 支持)。单机部署 + SQLite/LibSQL 选 Drizzle(零开销、极简部署)。需要复杂关联查询和 ORM 级缓存时 Prisma 更成熟;需要完全控制 SQL 和最小 bundle 时 Drizzle 更合适。
第5部分:认证与会话管理
5.1 服务端认证中间件模式
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 服务端认证与会话架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户请求 │
│ ┌──────────────┐ │
│ │ HTTP Request │ │
│ │ + Cookie: │ │
│ │ session=.. │ │
│ │ + Auth: │ │
│ │ Bearer .. │ │
│ └──────┬───────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ server/middleware/auth.ts │ │
│ │ 1. 提取 Cookie (h3 useCookie / getCookie) │ │
│ │ 2. 提取 Authorization header (可选 JWT) │ │
│ │ 3. 验证 session / token │ │
│ │ 4. 注入 event.context.session │ │
│ └──────────────────────────┬───────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ server/api/** Handler │ │
│ │ 可直接使用 event.context.session / event.context.user│ │
│ │ 实现对象级授权 (row-level access control) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘5.2 Cookie/Session 管理
Nuxt 服务端通过 h3 的 useCookie / getCookie / setCookie 管理 cookie。
// server/utils/session.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
interface SessionData {
userId: string
email: string
expiresAt: number
}
const ALGORITHM = 'aes-256-gcm'
const COOKIE_NAME = 'nuxt_session'
function getEncryptionKey(): Buffer {
const key = useRuntimeConfig().sessionSecret as string
// 派生 32 字节密钥
return Buffer.from(key.padEnd(32).slice(0, 32))
}
export function encryptSession(data: SessionData): string {
const key = getEncryptionKey()
const iv = randomBytes(12)
const cipher = createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(data), 'utf8'),
cipher.final(),
])
const tag = cipher.getAuthTag()
// iv + tag + encrypted → base64
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
export function decryptSession(token: string): SessionData | null {
try {
const key = getEncryptionKey()
const buf = Buffer.from(token, 'base64url')
const iv = buf.subarray(0, 12)
const tag = buf.subarray(12, 28)
const encrypted = buf.subarray(28)
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
])
const data = JSON.parse(decrypted.toString('utf8')) as SessionData
// 检查是否过期
if (Date.now() > data.expiresAt) return null
return data
} catch {
return null
}
}
export async function createSession(
event: H3Event,
userId: string,
email: string,
): Promise<void> {
const sessionData: SessionData = {
userId,
email,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 天
}
const token = encryptSession(sessionData)
setCookie(event, COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60, // 7 天(秒)
})
}
export async function getSession(event: H3Event): Promise<SessionData | null> {
// 优先从 event.context 读取(避免重复解密)
if (event.context._session !== undefined) {
return event.context._session
}
const token = getCookie(event, COOKIE_NAME)
if (!token) {
event.context._session = null
return null
}
const session = decryptSession(token)
event.context._session = session
return session
}
export async function destroySession(event: H3Event): Promise<void> {
deleteCookie(event, COOKIE_NAME, { path: '/' })
event.context._session = null
}// server/api/auth/login.post.ts
import { z } from 'zod'
import { createSession } from '~~/server/utils/session'
import { prisma } from '~~/server/utils/prisma'
import { verifyPassword } from '~~/server/utils/crypto'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = loginSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 422, statusMessage: 'Invalid input' })
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
})
if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
await createSession(event, user.id, user.email)
return {
user: { id: user.id, email: user.email, name: user.name },
}
})5.3 JWT 在 Server Routes 中的使用
对于无状态的 API 认证或微服务间通信,JWT 是更合适的选择。
// server/utils/jwt.ts
import { SignJWT, jwtVerify } from 'jose'
const getSecret = () => {
const secret = useRuntimeConfig().jwtSecret as string
return new TextEncoder().encode(secret)
}
export async function signJwt(payload: Record<string, unknown>): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('2h')
.sign(getSecret())
}
export async function verifyJwt(token: string) {
try {
const { payload } = await jwtVerify(token, getSecret())
return payload
} catch {
return null
}
}// server/middleware/jwt-auth.ts
import { verifyJwt } from '~~/server/utils/jwt'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
if (!authHeader?.startsWith('Bearer ')) {
// 不强制所有路由都需要 JWT——让具体 handler 决定
return
}
const token = authHeader.slice(7)
const payload = await verifyJwt(token)
if (!payload) {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired token' })
}
event.context.jwtPayload = payload
event.context.userId = payload.sub as string
})会话选择框架:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Cookie Session | 浏览器应用 | httpOnly 防 XSS、自动携带 | CSRF 风险、跨域不便 |
| JWT Bearer | 移动 App / API | 无状态、跨域友好 | 无法强制失效、payload 可见 |
| 双 token (Access + Refresh) | 高安全要求 | 短命 access + 长命 refresh | 实现复杂、需轮换逻辑 |
第6部分:Nuxt 4 Server Components
6.1 概念与工作机制
Nuxt 4 引入了 Server Components(实验性),允许组件只在服务端渲染,其 JavaScript 永远不会发送到客户端。这与 React Server Components 概念类似,但实现路径不同。
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 4 Server Components vs 传统组件 vs RSC │
├─────────────────────────────────────────────────────────────┤
│ │
│ 传统 Nuxt 组件(Universal) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ SSR 渲染 HTML → 客户端水合 → JS bundle 包含组件代码 │ │
│ │ 组件逻辑同时在服务端和客户端运行 │ │
│ │ 所有组件代码最终进入客户端 bundle │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Nuxt Server Components (.server.vue) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 只在服务端渲染 → 输出纯 HTML → 零 JS 到客户端 │ │
│ │ 可直接访问数据库、文件系统、私密环境变量 │ │
│ │ 无法使用 onClick/useState/浏览器 API │ │
│ │ 适用:Markdown 渲染、数据库直查展示、大依赖组件 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ React Server Components (RSC) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 通过流式传输序列化后的组件树(非 HTML) │ │
│ │ 客户端组件与 Server 组件可在同一树中任意交织 │ │
│ │ 需要 RSC 专用框架(Next.js App Router 等) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键区别: │
│ Nuxt SC → 输出 HTML 片段,服务端组件不能嵌套客户端组件 │
│ React RSC → 输出可序列化的 React 元素树,支持任意嵌套 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 适用场景与示例
<!-- components/UserDashboard.server.vue -->
<script setup lang="ts">
// 此组件的代码永远不会发送到客户端
// 可以直接访问数据库和私密环境变量
import { prisma } from '~~/server/utils/prisma'
const stats = await prisma.$transaction([
prisma.project.count(),
prisma.project.count({ where: { status: 'active' } }),
prisma.user.count(),
])
const [totalProjects, activeProjects, totalUsers] = stats
// 不需要担心这些查询逻辑暴露给客户端
const expensiveReport = await generateMonthlyReport()
</script>
<template>
<div class="dashboard-stats">
<div class="stat-card">
<span class="stat-value">{{ totalProjects }}</span>
<span class="stat-label">Total Projects</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ activeProjects }}</span>
<span class="stat-label">Active</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ totalUsers }}</span>
<span class="stat-label">Users</span>
</div>
<!-- 复杂报告渲染,可能依赖大量服务端依赖 -->
<div class="report" v-html="expensiveReport" />
</div>
</template><!-- pages/dashboard.vue -->
<script setup lang="ts">
// 客户端交互逻辑放在父组件
const showDetails = ref(false)
</script>
<template>
<div>
<!-- Server Component:零 JS 传递 -->
<UserDashboard />
<!-- 客户端交互区域 -->
<button @click="showDetails = !showDetails">
Toggle Details
</button>
<ClientOnly>
<InteractiveChart v-if="showDetails" />
</ClientOnly>
</div>
</template>适用 Server Components 的场景:
- 渲染 Markdown/MDX 内容(避免 syntax highlighter 库进入客户端 bundle)
- 直接从数据库查询并渲染的统计面板
- 依赖大型 Node.js 库(PDF 生成、图像处理)的渲染
- 需要服务端私密配置的展示组件
不适用:任何需要交互(事件监听、表单输入、动画)的组件必须使用传统组件或 ClientOnly 包裹。
核心总结
总结1:数据 API 的选择不是语法偏好,而是架构决策
$fetch 只做请求,useFetch 自动 payload 去重,useAsyncData 支持任意异步组合。三者对应三种不同的数据边界:Mutation、页面级数据的端到端传递、多源数据组合和自定义缓存。混用的代价是重复请求、水合不一致或缓存泄漏。将 useFetch 用于所有场景不是"保守",而是忽视 SSR payload 传递机制的设计意图。
总结2:缓存是多层防御,不是单点优化
从浏览器 HTTP 缓存到 CDN Edge、Nitro Route Rules、SSR payload 去重,再到 Redis 和数据源缓存——每一层都承担不同的职责。有效的缓存策略需要在每一层回答相同的问题:缓存键包含哪些维度?TTL 基于数据变化频率还是业务容忍度?失效是时间驱动还是事件驱动?是否允许在错误时返回 stale 数据?
总结3:Server Routes 是安全边界,不是简单的函数导出
服务端 API 是客户端与服务端的信任边界。所有来自网络的数据(params、query、body、headers)必须经运行时校验(Zod);所有错误必须映射为安全的状态码和数据(不泄露堆栈/SQL);所有认证必须在中间件层统一处理而非在每个 handler 中重复实现。TypeScript 的类型系统止步于编译时——运行时安全需要显式校验。
总结4:ISR 填补了 SSG 与 SSR 之间的空白
纯 SSG 的数据新鲜度受限于构建频率,纯 SSR 的服务端成本随流量线性增长。ISR 通过"过期后后台重新生成"提供了折中:用户永远不被阻塞(返回旧版本),数据在可配置的时间窗口内更新。关键在于 expirationTime 的选择——应该基于业务对数据陈旧度的容忍度,而非技术便利性。
总结5:认证是纵深防御,不是单点门禁
Cookie Session(httpOnly、secure、sameSite)保护浏览器应用,JWT 保护无状态 API——两者不是互斥的替代品,而是不同场景的互补方案。中间件层负责提取和验证凭证、注入 event.context;Handler 层负责对象级授权(这条数据是否属于当前用户)。缺失任何一层都会产生安全漏洞。
章节测试
测试1:以下关于 useFetch 和 useAsyncData 的说法,哪个是正确的?
A. useFetch 内部调用 useAsyncData,所以两者的缓存机制完全相同 B. useAsyncData 可以包裹非 HTTP 的异步函数(如 ORM 查询),useFetch 只能发起 HTTP 请求 C. useFetch 的 URL 参数不支持响应式 getter 函数 D. useAsyncData 不需要手动指定 key,Nuxt 会自动生成
测试2:在 routeRules 中配置 { isr: { expirationTime: 3600 }, swr: true } 的含义是什么?
A. 页面每小时完全重新构建一次 B. 页面过期后,用户请求会等待服务端重新生成完成再返回 C. 页面过期后,先返回旧的缓存版本,同时后台触发重新生成 D. 页面永远不会过期,始终返回构建时的版本
测试3:以下 Server API handler 中存在什么安全隐患?
export default defineEventHandler(async (event) => {
const { id } = getQuery(event)
const project = await db.query(`SELECT * FROM projects WHERE id = ${id}`)
return project
})测试4:Drizzle ORM 和 Prisma 在 Nuxt 中的主要选择差异是什么?什么场景下选 Drizzle?
测试5:Cookie Session 和 JWT Bearer 在认证中各适合什么场景?能否同时使用?
测试6:Nuxt 4 Server Components 与 React Server Components 的根本区别是什么?
测试7:设计一个"项目列表"页面的缓存策略。需求:列表每 5 分钟更新一次,允许在更新期间返回旧数据,不同用户看到不同数据(基于 ownerId)。写出 routeRules 配置并说明哪些层不需要缓存。
参考答案
测试1答案
答案:B。useAsyncData 接受任意异步函数,可以包裹 ORM 查询、多个 $fetch 组合、文件读取等。useFetch 是 useAsyncData + $fetch 的便捷封装,专门用于 HTTP 请求。useFetch 的 URL 参数支持响应式 getter 函数(这是推荐用法),而 useAsyncData 必须手动指定唯一 key。
测试2答案
答案:C。isr.expirationTime 定义缓存有效期(秒),swr: true 表示过期后先返回 stale 数据,同时在后台重新生成。A 错误(ISR 是按需触发重新生成,不是定时全量构建),B 错误(SWR 允许先返回旧数据),D 描述的是纯 SSG。
测试3答案
存在两个严重安全隐患:
- SQL 注入:
id直接拼接到 SQL 字符串中,攻击者可通过?id=1;DROP TABLE projects;--执行任意 SQL。应使用参数化查询(db.query('SELECT * FROM projects WHERE id = ?', [id]))或 ORM。 - 缺少输入校验:
id来自getQuery,未经任何校验。应使用 Zod 校验id的类型和格式。 - 缺少认证和授权:未检查请求者是否有权访问该项目,任何人可以查询任意项目。
测试4答案
选择 Drizzle:SQLite/LibSQL 部署、需要最小 bundle size、偏好 SQL-like 查询语法、需要完全控制生成的 SQL、Serverless 单函数部署。选择 Prisma:需要成熟的关系映射和迁移工具、复杂多表关联查询、团队对声明式 schema 更熟悉、需要官方 edge runtime 支持。两者都能在 Nuxt 中良好工作——关键不是"谁更好",而是"谁更匹配当前项目的数据库类型、部署环境和团队经验"。
测试5答案
Cookie Session 适合浏览器应用:httpOnly cookie 自动携带、防 XSS 窃取、服务端可强制失效。JWT Bearer 适合移动 App、第三方 API、微服务间通信:无状态验证、跨域友好、不依赖 Cookie。可以同时使用:例如,浏览器主应用使用 Cookie Session,同时为移动端和第三方集成提供 JWT API 端点。两者共享同一套用户身份验证逻辑,只是凭证传递和存储方式不同。
测试6答案
根本区别:Nuxt Server Components 输出的是纯 HTML 片段(渲染结果),React Server Components 输出的是可序列化的 React 元素树(中间表示)。这导致两个关键差异:(1) Nuxt SC 不能嵌套客户端组件——因为它输出的是最终 HTML,无法在 HTML 中"打孔"插入客户端组件;(2) RSC 的客户端组件和 Server 组件可以在同一组件树中任意交织,因为 RSC 传输的是元素树描述而非渲染结果。
测试7答案
// nuxt.config.ts
routeRules: {
'/api/projects': {
swr: true,
cache: {
maxAge: 60 * 5, // 5 分钟新鲜
swrMaxAge: 60 * 30, // 最多 30 分钟 stale
varies: ['cookie'], // 基于 cookie 区分用户
},
},
}不需要缓存的层:
- CDN 层:因为数据包含用户私有内容,不应在共享 CDN 中缓存(除非使用
varies: ['cookie']使 CDN 按 cookie 区分缓存条目,但这通常效率低下) - 浏览器 HTTP 缓存:用户专属数据不应被浏览器缓存为公共资源
- 实际上,用户专属数据的最佳缓存位置是 Nitro 层(带
varies: ['cookie'])或应用层useAsyncDatakey 缓存——保证数据隔离的同时获得缓存收益
相关笔记
- [[05-nuxt-routing-and-rendering]] — Nuxt 路由、渲染模式与项目结构
- [[04-pinia-state-management]] — Pinia 状态管理(客户端状态与 useAsyncData 的职责边界)
- [[../02-react-and-nextjs/05-nextjs-data-fetching]] — Next.js 数据获取对比参考
- [[../../05-database/00-overview]] — 数据库知识体系
下一步学习
- [ ] 在本地项目中实现一个完整的 Server API:Zod 校验 + Drizzle/Prisma 查询 + 缓存策略
- [ ] 配置 Nitro Route Rules,对比 SSG / ISR / SSR 三种模式在同一路由上的性能差异
- [ ] 实现基于 Cookie Session 的登录流程,包含 CSRF 保护和会话轮换
- [ ] 阅读 07-testing-performance-production — 测试体系、性能优化与生产部署
- [ ] 探索 Nuxt 4 Server Components 的实验性功能,尝试将一个 Markdown 渲染组件改为
.server.vue
学习状态:🟡 开始学习