Next.js: 数据获取:fetch 与 RSC

最后更新:2026-08-26

RSC 中 fetch 不再是浏览器的 fetch——它扩展了缓存层,让你以声明式的方式控制数据生命周期。

1. 你将学到


2. 一个全栈开发者的真实故事

(1) 痛点:Dashboard 加载要 8 秒

Bob 是 TaskFlow 团队的技术主管。Dashboard 页面需要加载 5 个数据源:用户统计、项目总数、最近任务、活动日志、系统通知。最初代码写了 5 个串行的 await fetch(...),每个等待前一个完成——总耗时 2.1s + 1.8s + 1.5s + 0.9s + 1.7s = 8 秒。用户投诉页面"白屏太久"。更糟的是,每次刷新都重新请求 API,数据库压力飙升到 5,000 QPS。

(2) Next.js fetch 的解法

Promise.all() 并行请求 + next: { revalidate: 60 } 缓存 60 秒。

TSX
// app/dashboard/page.tsx
export default async function DashboardPage() {
  const [users, projects, tasks, logs, notifs] = await Promise.all([
    fetch('https://api.example.com/stats/users', { next: { revalidate: 60 } }),
    fetch('https://api.example.com/stats/projects', { next: { revalidate: 60 } }),
    fetch('https://api.example.com/stats/tasks', { next: { revalidate: 30 } }),
    fetch('https://api.example.com/activity/logs', { cache: 'no-store' }),
    fetch('https://api.example.com/notifications', { next: { revalidate: 10 } }),
  ]).then(responses => Promise.all(responses.map(r => r.json())))

  return <DashboardView {...{ users, projects, tasks, logs, notifs }} />
}

(3) 收益

维度 优化前 优化后
页面加载时间 8 秒(串行) 2.1 秒(并行)
数据库 QPS 5,000 83(缓存 60 秒)
用户投诉 每天 12 起 0 起
代码行数 35 行(5 个独立 fetch) 10 行

3. fetch 的三种缓存模式

Next.js 16 扩展了 Web fetch API,添加了三种缓存模式。所有 RSC 中的 fetch 都默认使用 force-cache(自动缓存),除非显式指定其他模式。

100%
graph LR
    A[RSC fetch] --> B{缓存模式}
    B --> C[force-cache<br/>默认值]
    B --> D[no-store<br/>每次刷新]
    B --> E[revalidate:N<br/>时间窗口]
    C --> F[Data Cache<br/>持久化存储]
    D --> G[实时数据<br/>不缓存]
    E --> H[N 秒内缓存<br/>过期后重新获取]
    
    style C fill:#d4edda
    style D fill:#f8d7da
    style E fill:#fff3cd
模式 写法 行为 适用场景
force-cache(默认) fetch(url)fetch(url, { cache: 'force-cache' }) 只在构建/首次请求时获取,结果永久缓存 极少变化的数据(文档、静态配置)
no-store fetch(url, { cache: 'no-store' }) 每次请求都重新获取,不缓存 实时数据(用户信息、库存)
revalidate:N fetch(url, { next: { revalidate: 60 } }) 60 秒内缓存,过期后后台触发更新 半实时数据(新闻、排行榜)

(1) force-cache 默认行为

如果不传任何 options,Next.js 会自动缓存 fetch 结果——相同的 URL 和 options 在构建期间只会请求一次。

TSX
// app/products/page.tsx — force-cache 默认
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products').then(r => r.json())
  // 构建时获取一次,之后使用缓存
  return <ProductList data={products} />
}

(2) no-store 动态数据

TSX
// app/profile/page.tsx — 每次请求都获取最新数据
export default async function ProfilePage() {
  const user = await fetch('https://api.example.com/me', {
    cache: 'no-store',
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` }
  }).then(r => r.json())
  return <ProfileView user={user} />
}

(3) revalidate 时间窗口

TSX
// app/blog/[slug]/page.tsx — ISR 风格缓存
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
    next: { revalidate: 3600 }  // 1 小时内使用缓存
  }).then(r => r.json())
  return <article><h1>{post.title}</h1><div>{post.content}</div></article>
}

▶ 示例:三种缓存模式对比(难度⭐)

TSX
// app/cache-demo/page.tsx
export default async function CacheDemoPage() {
  const staticData = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
    cache: 'force-cache'
  }).then(r => r.json())

  const liveData = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
    cache: 'no-store'
  }).then(r => r.json())

  return (
    <div>
      <p>Static (force-cache): {staticData.datetime}</p>
      <p>Live (no-store): {liveData.datetime}</p>
    </div>
  )
}

输出:

TEXT 📖 仅展示
Static (force-cache): 2026-07-06T10:00:00.000Z  ← 一直不变
Live (no-store): 2026-07-06T10:00:05.123Z       ← 每次刷新都变

4. 按需重验证:tags 与 revalidateTag

next: { tags: [...] } 为 fetch 请求打上标签,随后通过 revalidateTag(tag)Server Action 或 Route Handler 中按需刷新缓存。

100%
sequenceDiagram
    participant A as Server Action
    participant Cache as Data Cache
    participant DB as Database

    A->>DB: 写入新数据(创建任务)
    A->>Cache: revalidateTag('tasks')
    Cache->>Cache: 清除 tags 匹配的所有缓存
    Note over Cache: 下次 fetch 重新获取
API 用途 调用位置
next: { tags: ['tasks', 'projects'] } 给 fetch 打标签 fetch() 选项
revalidateTag('tasks') 按标签清除所有相关缓存 Server Action / Route Handler
revalidatePath('/dashboard') 按路径清除缓存 Server Action / Route Handler

▶ 示例:使用 tags 与 revalidateTag(难度⭐⭐)

TSX
// app/tasks/data.ts — 数据获取函数
export async function getTasks() {
  return fetch('https://api.example.com/tasks', {
    next: { tags: ['tasks'] }
  }).then(r => r.json())
}
TSX
// app/tasks/actions.ts — Server Action 写入后刷新缓存
'use server'
import { revalidateTag } from 'next/cache'

export async function createTask(formData: FormData) {
  const title = formData.get('title') as string
  await fetch('https://api.example.com/tasks', {
    method: 'POST',
    body: JSON.stringify({ title, status: 'todo' })
  })
  revalidateTag('tasks')  // 清除 tasks 标签的所有缓存
}

▶ 示例:revalidatePath 清除整页(难度⭐⭐)

TSX
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function publishArticle() {
  await db.article.update({ where: { id: 1 }, data: { published: true } })
  revalidatePath('/blog')       // 刷新 /blog 页面
  revalidatePath('/blog/[slug]') // 刷新所有文章详情
}

5. 并行数据获取与瀑布流避免

瀑布流(Waterfall)是性能头号杀手——每个 await 串行等待上一个完成。使用 Promise.all() 可以同时发起所有请求。

100%
graph LR
    subgraph "瀑布流(慢)"
        A1[fetch A] --> A2[fetch B] --> A3[fetch C]
        A1 -.- t1[2s]
        A2 -.- t2[+2s = 4s]
        A3 -.- t3[+2s = 6s]
    end
    subgraph "并行(快)"
        B1[fetch A] -.- u1[2s]
        C1[fetch B] -.- u2[2s]
        D1[fetch C] -.- u3[2s]
        B1 & C1 & D1 --> M[Promise.all<br/>总耗时 ~2s]
    end
模式 写法 总耗时(每个 2s) 适用场景
串行瀑布流 await A; await B; await C ~6s 有依赖关系的请求
并行请求 Promise.all([A, B, C]) ~2s 独立不相关的请求
分阶段并行 const a = await A; const [b, c] = await Promise.all([B(a.id), C]) ~4s 部分依赖的请求

▶ 示例:串行瀑布流识别(难度⭐)

TSX
// app/waterfall/page.tsx — ❌ 串行瀑布流
export default async function WaterfallPage() {
  const user = await fetch('https://api.example.com/user').then(r => r.json())          // 1s
  const tasks = await fetch(`https://api.example.com/tasks?userId=${user.id}`).then(r => r.json())  // 等上面完成 + 2s = 3s
  const details = await Promise.all(tasks.map(t =>
    fetch(`https://api.example.com/tasks/${t.id}/details`).then(r => r.json())  // 等上面完成 + 2s = 5s
  ))

  return <div>Total: ~5s</div>
}

▶ 示例:并行优化(难度⭐⭐)

TSX
// app/no-waterfall/page.tsx — ✅ 并行优化
export default async function NoWaterfallPage() {
  // 阶段 1:并行获取用户和初始化数据
  const [user, initialData] = await Promise.all([
    fetch('https://api.example.com/user', { next: { revalidate: 10 } }).then(r => r.json()),
    fetch('https://api.example.com/initial', { cache: 'no-store' }).then(r => r.json()),
  ])

  // 阶段 2:依赖 user.id 的请求(仍有小瀑布,但已是最优)
  const tasks = await fetch(`https://api.example.com/tasks?userId=${user.id}`).then(r => r.json())

  return <div>Total: ~2s (1s + 1s parallel, then 1s)</div>
}

▶ 示例:Suspense 边界分割加载(难度⭐⭐⭐)

TSX
// app/suspense-demo/page.tsx — 每个独立区域用 Suspense 包裹
import { Suspense } from 'react'

export default function SuspenseDemoPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading profile...</div>}>
        <ProfileSection />
      </Suspense>
      <Suspense fallback={<div>Loading tasks...</div>}>
        <TaskSection />
      </Suspense>
    </div>
  )
}

async function ProfileSection() {
  const user = await fetch('https://api.example.com/user', { cache: 'no-store' }).then(r => r.json())
  return <div>Welcome, {user.name}</div>
}

async function TaskSection() {
  const tasks = await fetch('https://api.example.com/tasks', { next: { revalidate: 30 } }).then(r => r.json())
  return <ul>{tasks.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
}

6. 完整示例:优化后的 Dashboard

TSX
// app/dashboard-optimized/page.tsx
import { Suspense } from 'react'
import { revalidateTag } from 'next/cache'

// ======== 数据函数 ========
const API = 'https://jsonplaceholder.typicode.com'

async function getData<T>(endpoint: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${API}${endpoint}`, {
    ...options,
    next: { tags: [endpoint.split('/')[1] ?? 'default'], ...(options as any)?.next },
  })
  if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`)
  return res.json()
}

// ======== 并行获取所有数据 ========
export default function DashboardOptimizedPage() {
  return (
    <div>
      <h1>Optimized Dashboard</h1>
      <div style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 1fr' }}>
        <Suspense fallback={<Skeleton label="Users" />}>
          <DataCard title="Users" endpoint="/users" />
        </Suspense>
        <Suspense fallback={<Skeleton label="Posts" />}>
          <DataCard title="Posts" endpoint="/posts" revalidate={120} />
        </Suspense>
        <Suspense fallback={<Skeleton label="Comments" />}>
          <DataCard title="Comments" endpoint="/comments" />
        </Suspense>
        <Suspense fallback={<Skeleton label="Todos" />}>
          <DataCard title="Todos" endpoint="/todos" revalidate={30} />
        </Suspense>
      </div>
    </div>
  )
}

async function DataCard({ title, endpoint, revalidate }: {
  title: string
  endpoint: string
  revalidate?: number
}) {
  const data = await getData<any[]>(endpoint, revalidate
    ? { next: { revalidate } }
    : { cache: 'no-store' }
  )
  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
      <h2>{title} <span style={{ fontSize: 14, color: '#666' }}>({data.length})</span></h2>
      <ul>{data.slice(0, 5).map((item: any) => (
        <li key={item.id}>{item.title ?? item.name ?? item.email}</li>
      ))}</ul>
    </div>
  )
}

function Skeleton({ label }: { label: string }) {
  return <div style={{ border: '1px solid #eee', borderRadius: 8, padding: 16, opacity: 0.5 }}>
    Loading {label}...
  </div>
}

// app/dashboard-optimized/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function refreshSection(tag: string) {
  revalidateTag(tag)
  return { success: true }
}

❓ 常见问题

Q fetch 的 'force-cache' 和 'no-store' 在开发模式和生产模式行为一样吗?
A 不一样。开发模式(npm run dev)下,force-cache 仍会每次请求(方便调试)。生产模式(next start 或构建后)才会真正缓存。这是 Next.js 的设计决定——开发阶段始终获取最新数据。
Q revalidateTag 和 revalidatePath 有什么区别?
A revalidateTag 按标签清除缓存(跨越不同页面的相同数据),revalidatePath 按路径清除(精确到页面或路由模式)。前者适合细粒度数据层控制,后者适合页面级刷新。推荐优先使用 revalidateTag。
Q 如果两个 fetch 用相同 URL 但不同 options,会共享缓存吗?
A 不会。缓存 key 由 URL + method + headers + body 共同计算。相同 URL 但不同 cachenext.revalidate 设置会被视为不同缓存条目。
Q Promise.all 怎么处理某个请求失败?
A Promise.all 是"全或无"——任何一个失败都会 reject 整个。如果需要容错,使用 Promise.allSettled 或 try-catch 包装每个 fetch。常见模式:const results = await Promise.all(urls.map(u => fetch(u).catch(() => null)))
Q fetch 在 RSC 中的超时如何处理?
A fetch 没有内置超时。可以在 AbortController 包装:const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 5000); fetch(url, { signal: ctrl.signal })。推荐在 app 层封装统一的 fetch 客户端。
Q 在 RSC 中可以用第三方 HTTP 客户端(如 axios)吗?
A 可以,但会失去 Next.js fetch 的自动缓存、tags、revalidate 等扩展功能。如果使用 axios,需要手动实现缓存逻辑,或在 axios 上层包装 fetch 兼容层。推荐优先使用原生 fetch。

📖 小节


📝 作业

  1. 基础题(⭐):创建一个 app/time-demo/page.tsx,使用 cache: 'no-store'cache: 'force-cache' 分别请求 World Time API,对比两个时间戳的差异,验证缓存行为。

  2. 进阶题(⭐⭐):构建一个 app/parallel-demo/page.tsx,使用 Promise.all 并行获取 /users/posts/comments 三个端点(使用 JSONPlaceholder API),将每个数据渲染到独立的 <Suspense> 边界中,展示流式加载效果。

  3. 挑战题(⭐⭐⭐):创建一个支持 CRUD 的任务列表页面:app/tasks/page.tsx(显示任务列表,使用 tags 缓存)和 app/tasks/actions.ts(添加/删除任务后调用 revalidateTag('tasks') 刷新列表)。添加乐观更新体验,确保写入后立即刷新。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏