Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

Vue 生态 / Vue Ecosystem

1. Vue 生态知识体系 / Vue Ecosystem Knowledge System

2. Vue 组件、模板与编译原理 / Vue Components, Templates, and Compilation

3. Vue 3 响应式系统与 Composition API / Vue 3 Reactivity System and Composition API

4. Vue Router、表单与组件架构 / Vue Router, Forms, and Component Architecture

5. Pinia 状态管理与持久化 / Pinia State Management and Persistence

6. Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure

7. Nuxt 数据获取、Server API 与缓存 / Nuxt Data Fetching, Server APIs, and Caching

8. Vue 测试、性能与生产工程 / Vue Testing, Performance, and Production Engineering

本页目录

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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

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) │           │
│  └──────────┘   └──────────┘   └──────────────┘           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

关键细节:useFetch 的 URL 参数支持响应式 getter 函数。当路由参数或 query 变化时,Nuxt 自动触发重新请求。但须注意:若传入静态字符串,参数变化不会触发刷新。

typescript
// ✅ 响应式 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
2
3
4
5
6
7

1.3 useAsyncData 的缓存 Key 策略 ​

useAsyncData 的第一个参数是缓存 key,用于在 payload 中唯一标识这份数据。Key 必须稳定且包含所有影响结果的参数,否则会出现错误的数据共享或重复缓存。

typescript
// ✅ 正确: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

1.4 状态管理与方法 ​

useFetch 和 useAsyncData 返回统一的状态模型,包含 data、pending、error、status、refresh 和 execute。

typescript
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}`)
1
2
3
4
5
6
7
8
9

refresh 与 execute 的区别:refresh 使用相同的参数重新发起请求,而 execute 会重新运行整个 handler(对 useAsyncData 而言,这意味着重新执行你传入的异步函数,其中可以读取最新的响应式依赖)。

vue
<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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

1.5 await 与 lazy 的选择框架 ​

┌─────────────────────────────────────────────────────────────┐
│                  await vs lazy 决策框架                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  │ 场景                          │ 策略         │ 原因      │
│  ├──────────────────────────────┼─────────────┼───────────┤
│  │ 页面主体内容(文章/产品详情) │ await        │ 数据是    │
│  │                              │              │ 页面核心  │
│  ├──────────────────────────────┼─────────────┼───────────┤
│  │ 侧栏/推荐/次要模块           │ lazy         │ 不阻塞    │
│  │                              │              │ 主要内容  │
│  ├──────────────────────────────┼─────────────┼───────────┤
│  │ 登录后用户专属数据           │ server:false │ 避免 SSR  │
│  │                              │              │ 缓存泄漏  │
│  ├──────────────────────────────┼─────────────┼───────────┤
│  │ 高频变化数据(股价/实时)     │ lazy +       │ 服务端    │
│  │                              │ server:false │ 无意义    │
│                                                             │
│  await 阻塞导航直到数据就绪                                   │
│  lazy 先完成导航,页面展示 pending 状态后再填充数据           │
│  server: false 只在客户端执行,首屏 data 为 null              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

第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、失效策略和缓存键设计                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

2.2 cachedEventHandler 与 Route Rules ​

Nitro 提供 cachedEventHandler 对单个事件处理函数进行缓存包装,以及 routeRules 在 nuxt.config.ts 中声明路由级缓存策略。

typescript
// 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
  }
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

nuxt.config.ts 中的 routeRules 提供了声明式的路由级缓存配置:

typescript
// 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 },
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

2.3 自定义存储后端(Redis) ​

生产环境中,缓存应存储到 Redis 而非进程内存,以支持多实例共享和持久化。

typescript
// 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)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typescript
// 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 }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

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,               // 过期后先返回旧版本   │   │
│  │   }                                                  │   │
│  │ }                                                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

2.5 ISR vs SSG vs SSR 选择框架 ​

维度SSG (Static)ISRSSR (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  │   │         │ │
│  └──────────┘   └──────────┘   └──────────┘   └─────────┘ │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

3.2 请求校验:h3 + Zod ​

TypeScript 的编译时类型检查无法验证运行时网络输入。所有来自客户端的数据(params、query、body、headers)必须在服务端入口处进行运行时校验。

typescript
// 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript
// 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 }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

3.3 请求体校验与错误处理 ​

typescript
// 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',
    })
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

3.4 错误响应规范 ​

typescript
// 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
    }
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

3.5 中间件链 ​

typescript
// 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)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typescript
// 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
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

第4部分:Database 集成 ​

4.1 Drizzle ORM + Nuxt ​

Drizzle ORM 是类型安全的 TypeScript ORM,零运行时依赖,schema 定义即类型。与 Nuxt 的 Nitro 服务端天然契合。

typescript
// 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],
  }),
}))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
typescript
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
typescript
// 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
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

4.2 Prisma + Nuxt ​

Prisma 提供声明式 schema、自动迁移和类型安全的查询客户端。

prisma
// 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typescript
// 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()
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typescript
// 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) },
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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 generateprisma 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)            │  │
│  └──────────────────────────────────────────────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

5.2 Cookie/Session 管理 ​

Nuxt 服务端通过 h3 的 useCookie / getCookie / setCookie 管理 cookie。

typescript
// 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
typescript
// 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 },
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

5.3 JWT 在 Server Routes 中的使用 ​

对于无状态的 API 认证或微服务间通信,JWT 是更合适的选择。

typescript
// 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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typescript
// 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
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

会话选择框架:

策略适用场景优点缺点
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 元素树,支持任意嵌套       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

6.2 适用场景与示例 ​

vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

适用 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 中存在什么安全隐患? ​

typescript
export default defineEventHandler(async (event) => {
  const { id } = getQuery(event)
  const project = await db.query(`SELECT * FROM projects WHERE id = ${id}`)
  return project
})
1
2
3
4
5

测试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答案 ​

存在两个严重安全隐患:

  1. SQL 注入:id 直接拼接到 SQL 字符串中,攻击者可通过 ?id=1;DROP TABLE projects;-- 执行任意 SQL。应使用参数化查询(db.query('SELECT * FROM projects WHERE id = ?', [id]))或 ORM。
  2. 缺少输入校验:id 来自 getQuery,未经任何校验。应使用 Zod 校验 id 的类型和格式。
  3. 缺少认证和授权:未检查请求者是否有权访问该项目,任何人可以查询任意项目。

测试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答案 ​

typescript
// nuxt.config.ts
routeRules: {
  '/api/projects': {
    swr: true,
    cache: {
      maxAge: 60 * 5,        // 5 分钟新鲜
      swrMaxAge: 60 * 30,    // 最多 30 分钟 stale
      varies: ['cookie'],    // 基于 cookie 区分用户
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11

不需要缓存的层:

  • CDN 层:因为数据包含用户私有内容,不应在共享 CDN 中缓存(除非使用 varies: ['cookie'] 使 CDN 按 cookie 区分缓存条目,但这通常效率低下)
  • 浏览器 HTTP 缓存:用户专属数据不应被浏览器缓存为公共资源
  • 实际上,用户专属数据的最佳缓存位置是 Nitro 层(带 varies: ['cookie'])或应用层 useAsyncData key 缓存——保证数据隔离的同时获得缓存收益

相关笔记 ​

  • [[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

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇6. Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure
下一篇8. Vue 测试、性能与生产工程 / Vue Testing, Performance, and Production Engineering

持续记录,持续成长

Copyright © Tidenflow