第 10 章

Server Components 数据获取:fetch、缓存与重验

第10章:Server Components 数据获取:fetch、缓存与重验

Next.js 拦截并扩展了原生 fetch,为其注入三个维度的缓存控制。理解请求记忆化、Data Cache 和按需重验的层次关系,是掌握数据获取的关键。

本章核心问题:Next.js 扩展的 fetch 有哪三种缓存策略?请求记忆化与 Data Cache 有何区别?ORM 直连如何获得缓存能力?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么 Next.js 要扩展原生 fetch

在浏览器中,fetch 是一个无状态的网络调用——每次调用都会发出真实的 HTTP 请求。Node.js 18 引入了全局 fetch API,但同样没有内置缓存语义。Next.js 在此基础上做了一件看似简单却影响深远的事:拦截并扩展了 fetch,为其注入了三个维度的缓存控制。

这一设计的核心动机是:Server Components 运行在服务端,可以在组件树的任意层级直接获取数据,无需通过 props 层层传递。但如果每个组件各自发起独立请求,同一个 API 端点可能在一次页面渲染中被调用数十次。Next.js 的扩展 fetch 正是为了解决这个问题而生。

三种缓存策略

force-cache:静态数据的最优解

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache', // 默认行为(Next.js 14 及之前)
  })
  if (!res.ok) throw new Error('Failed to fetch products')
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

force-cache 将响应存入 Data Cache(持久化缓存,Vercel 部署时跨请求、跨实例共享)。第一次请求后,后续所有请求直接命中缓存,不再发出真实网络调用。这正是 Next.js 实现"静态站点生成"语义的底层机制。

重要变化:Next.js 15 将 fetch 的默认行为从 force-cache 改为 no-store。这是一个破坏性变更,意味着如果你依赖默认缓存行为,升级后需要显式指定 cache: 'force-cache'

no-store:实时数据的选择

async function getLivePrice(ticker: string) {
  const res = await fetch(`https://api.finance.com/price/${ticker}`, {
    cache: 'no-store', // 每次请求都绕过缓存,直接发出网络调用
  })
  return res.json()
}

no-store 完全绕过 Data Cache,每次渲染都发起真实请求。适用于股票价格、实时库存等数据。在 Next.js 15 中,这已是 fetch 的默认行为。

revalidate:时间窗口内的缓存复用

async function getArticles() {
  const res = await fetch('https://cms.example.com/articles', {
    next: { revalidate: 3600 }, // 1 小时后重新验证
  })
  return res.json()
}

这实现了 ISR(Incremental Static Regeneration)的语义:缓存有效期内直接返回缓存内容(速度极快),过期后在后台重新获取并更新缓存,同时仍向当前请求返回旧数据。这是一种 stale-while-revalidate 策略。

Level 2 · 它是怎么运行的(3-5年经验)

按需重验:标签系统

基于时间的重验有一个局限:数据可能在 3600 秒之前就已更新,但用户仍然看到旧数据。next: { tags } 配合 revalidateTag 解决了这个问题。

// 数据获取时打标签
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: ['products', `product-${id}`] },
  })
  return res.json()
}

// Server Action 或 Route Handler 中触发重验
import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: unknown) {
  await db.product.update({ where: { id }, data })
  revalidateTag(`product-${id}`) // 只使该商品的缓存失效
  // revalidateTag('products') // 使所有商品列表的缓存失效
}

标签系统让缓存失效变得精准。电商场景中,更新单个商品只需使该商品的缓存失效,而不必清空整个商品列表的缓存。

请求记忆化:同一渲染树内的去重

Next.js 在 Data Cache 之上还有一层更短暂的机制:Request Memoization(请求记忆化)

// layout.tsx
async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

export default async function Layout({ children }: { children: React.ReactNode }) {
  const user = await getUser('123') // 第一次调用,发出真实请求
  return <div data-user={user.name}>{children}</div>
}

// page.tsx(同一渲染树中)
async function UserProfile() {
  const user = await getUser('123') // 相同 URL,直接返回记忆化结果,无网络请求
  return <div>{user.email}</div>
}

记忆化的工作原理:Next.js 在每次渲染开始时创建一个 Map,键为请求的 URL + 选项的序列化哈希,值为 Promise。同一渲染周期内相同的 fetch 调用共享同一个 Promise。渲染结束后,这个 Map 被清空。

这意味着你可以放心地在多个组件中调用相同的数据获取函数,不需要人工做"请求提升"来避免重复请求——这是对传统 React 数据获取模式的一次根本性改进。

记忆化的适用范围是同一渲染树内的相同 GET 请求。POST 请求不会被记忆化,跨请求(不同用户的页面访问)也不共享记忆化结果。

并行与串行数据获取

串行获取(瀑布式)

// 反模式:串行获取导致总延迟叠加
export default async function Page({ params }: { params: { id: string } }) {
  const user = await getUser(params.id)          // 等待 100ms
  const posts = await getPostsByUser(user.name)  // 再等待 200ms,总计 300ms
  return <PostList user={user} posts={posts} />
}

只有当第二个请求依赖第一个请求的结果时,串行才是必要的。否则,应使用并行获取。

并行获取(推荐模式)

// 推荐:并行获取,总延迟取最慢的那个
export default async function Page({ params }: { params: { id: string } }) {
  // 同时发起两个请求,总延迟约 200ms 而非 300ms
  const [user, latestPosts] = await Promise.all([
    getUser(params.id),
    getLatestPosts(),
  ])
  return <UserDashboard user={user} posts={latestPosts} />
}

Promise.all 让多个无依赖关系的请求并发执行。在实际应用中,一个页面可能需要 5-8 个不同的数据源,使用 Promise.all 可以将总延迟从各请求延迟之和降低为最慢请求的延迟。

使用 Suspense 拆分串行依赖

// 当数据间存在依赖,但不想阻塞整个页面时
export default function Page({ params }: { params: { id: string } }) {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile id={params.id} />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        {/* 这个组件内部再获取基于 user 的数据 */}
        <Recommendations userId={params.id} />
      </Suspense>
    </div>
  )
}

Level 3 · 规范怎么定义的(资深)

ORM/SDK 直连与 fetch 的选择

在 Server Components 中,你不总是通过 fetch 获取数据。很多时候直接使用 Prisma、Drizzle 或第三方 SDK 更合适。但这里有一个重要的区别:

// 通过 fetch:享有请求记忆化和 Data Cache
const data = await fetch('/api/products').then(r => r.json())

// 直接使用 Prisma:不经过 fetch,没有记忆化,没有 Data Cache
const products = await prisma.product.findMany()

直接使用 ORM 的代价:不享有 Next.js 的请求记忆化。如果组件树中多个地方调用了相同的 Prisma 查询,会发出多个真实的数据库查询。

unstable_cache:为非 fetch 数据源加上缓存

unstable_cache 是 Next.js 为非 fetch 数据源提供的缓存工具,它将任意异步函数的结果存入 Data Cache:

import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

// 将 Prisma 查询的结果缓存 1 小时,并打上标签
const getCachedProducts = unstable_cache(
  async () => {
    return prisma.product.findMany({
      where: { published: true },
      orderBy: { createdAt: 'desc' },
    })
  },
  ['products-list'],      // 缓存键(数组形式,便于命名空间管理)
  {
    revalidate: 3600,     // 1 小时后重验
    tags: ['products'],   // 支持 revalidateTag 按需失效
  }
)

export default async function ProductsPage() {
  const products = await getCachedProducts()
  return <ProductGrid products={products} />
}

unstable_cache 的内部机制与 fetch 的 Data Cache 共享同一套存储,因此 revalidateTag('products') 可以同时使 fetch 请求和 unstable_cache 的结果失效。

函数名中的 unstable_ 前缀并不意味着它不可用于生产,而是表示 API 可能在未来的 Next.js 版本中发生变化。在 Next.js 团队稳定该 API 之前,使用时需关注升级日志。

实践建议

在选择数据获取策略时,可以遵循以下决策树:

数据更新频率极低(产品介绍、文档):使用 cache: 'force-cache' + revalidateTag 按需失效。

数据每小时/每天更新(博客文章、价格):使用 next: { revalidate: N } + next: { tags: [...] }

实时数据(库存、用户个人数据):使用 cache: 'no-store',或搭配 cookies()/headers() 自动触发动态渲染。

数据库直连:使用 unstable_cache 包裹查询函数,获得与 fetch 等价的缓存能力。

理解这些缓存层次的关键在于:Next.js 并不要求你在"性能"和"新鲜度"之间二选一——通过精确的标签失效机制,你可以同时获得缓存的速度优势和数据的时效性。

Level 4 · 边界与陷阱(所有人)

陷阱1:Next.js 15 将 fetch 默认行为从 force-cache 改为 no-store——依赖隐式缓存的代码升级后可能性能下降。

陷阱2:请求记忆化只对 fetch GET 请求生效,不包括 Prisma、axios 等直接调用——后者需要 unstable_cache 包裹。

陷阱3:unstable_cache 的 unstable_ 前缀不意味着不可用于生产,而是 API 可能在未来版本变化——使用时关注升级日志。

本章评分
4.5  / 5  (33 评分)

💬 留言讨论