Next.js 数据获取与缓存策略完全指南:从 fetch 到 use cache

从 fetch 默认不缓存的范式转移到 use cache 指令,全面解析 Next.js 15+ 数据获取与缓存体系。涵盖 Server Components 数据获取模式、串行并行优化、Suspense 流式渲染、cacheLife/cacheTag 缓存管理及 Partial Prerendering 实战架构。

引言:为什么数据获取与缓存是 Next.js 最难啃的骨头

如果你已经在 Next.js 的世界里摸爬滚打了一段时间,你大概已经发现了一个残酷的事实:数据获取和缓存策略,才是真正区分初级开发者和高级开发者的分水岭。组件写得再漂亮、路由设计得再优雅,一旦数据获取的模式不对,整个应用的性能就会像多米诺骨牌一样崩塌。

说实话,我自己也在这上面栽过跟头。

Next.js 15 对缓存行为做了一次颠覆性的调整fetch 请求默认不再缓存。这看似一个小改动,实际上重新定义了整个数据获取的心智模型。再加上新引入的 use cache 指令、cacheLifecacheTagupdateTag 等一系列新 API,整个缓存体系经历了一次架构级别的演进。

这篇文章会带你从最基础的 fetch 调用一路走到最前沿的 Cache Components 架构,覆盖 Server Components 和 Client Components 两端的数据获取模式、串行与并行获取、Suspense 流式渲染、以及完整的缓存生命周期管理。如果你之前读过我们的《Next.js Server Actions 完全指南》,那篇文章聚焦的是数据的"写入"端;而这篇则专注于数据的"读取"端和缓存策略。两篇结合起来,就构成了 Next.js 全栈数据流的完整拼图。

Next.js 15+ 的范式转移:fetch 不再默认缓存

在 Next.js 14 及之前的版本中,Server Components 里的 fetch 调用默认会被缓存。同一个 URL 的请求在构建时或首次请求后会被自动存储,后续访问直接返回缓存结果。这个设计的初衷是好的——减少重复请求、提升性能——但它带来了一个让人头疼的困惑:开发者经常搞不清楚自己看到的数据到底是"新鲜的"还是"过期的"。

从 Next.js 15 开始,fetch 请求默认不再缓存。每次请求都会真实地发出网络调用。这是一个重大的心智模型变更。

如果你需要缓存,现在必须显式声明:

// 方式一:使用 force-cache 强制缓存
const res = await fetch('https://api.example.com/products', {
  cache: 'force-cache'
})

// 方式二:使用 next.revalidate 设置定时重新验证(单位:秒)
const res = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 } // 每小时重新验证一次
})

// 方式三:使用 next.tags 配合标签进行按需重新验证
const res = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})

这个改变背后的哲学很明确:默认保证正确性,性能优化需要你主动选择。与其让开发者在不知情的情况下使用过期数据,不如让他们自己决定什么该缓存、缓存多久。坦白讲,我觉得这个决定是对的。

迁移提示:如果你从 Next.js 14 升级到 15,一定要仔细检查所有 fetch 调用。那些之前依赖默认缓存行为的请求,现在需要显式添加 cache: 'force-cache'next: { revalidate: N },否则它们每次都会发起真实请求,可能导致 API 调用量暴增和性能下降。别问我是怎么知道的。

Server Components 中的数据获取模式

Server Components 是 Next.js App Router 数据获取的主战场。在服务端执行意味着你可以直接访问数据库、调用内部 API、读取文件系统——完全不用担心数据暴露到客户端。

模式一:fetch API

最经典的方式,适合调用外部 REST API 或微服务:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 600, tags: ['products'] } // 10 分钟重新验证,带标签
  })

  if (!res.ok) {
    throw new Error('获取产品列表失败')
  }

  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      <h1>产品列表</h1>
      {products.map((product: any) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

模式二:ORM / 数据库直接访问

当你用 Prisma、Drizzle 这类 ORM 直接查询数据库时,fetch 的缓存机制就派不上用场了。这时候需要请出 React 的 cache() 函数来实现请求级别的记忆化(memoization):

// lib/data/products.ts
import { cache } from 'react'
import { db } from '@/lib/db'

// cache() 确保同一个请求周期内,多次调用只执行一次数据库查询
export const getProduct = cache(async (id: string) => {
  const product = await db.product.findUnique({
    where: { id },
    include: { category: true, reviews: true }
  })

  if (!product) {
    throw new Error('产品不存在')
  }

  return product
})

export const getProductsByCategory = cache(async (categoryId: string) => {
  return db.product.findMany({
    where: { categoryId, active: true },
    orderBy: { createdAt: 'desc' }
  })
})

React 的 cache() 做的事情非常精确:它会在同一个服务端请求的生命周期内对函数调用进行去重。比如你在布局组件和页面组件中都调用了 getProduct('abc'),数据库查询实际上只会执行一次。但注意——下一个用户的请求会重新查询。这不是跨请求的缓存,只是请求内的去重。

预加载模式(Preload Pattern)

这个模式挺巧妙的。为了进一步优化性能,你可以在需要数据之前就提前发起获取:

// lib/data/products.ts
import { cache } from 'react'
import { db } from '@/lib/db'

export const getProduct = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true }
  })
})

// preload 函数:提前触发数据获取,但不 await 结果
export function preloadProduct(id: string) {
  void getProduct(id)
}
// app/products/[id]/page.tsx
import { getProduct, preloadProduct } from '@/lib/data/products'

export default async function ProductPage({ params }: { params: { id: string } }) {
  // 尽早触发数据获取
  preloadProduct(params.id)

  // ... 这里可以做一些不依赖产品数据的工作 ...

  // 当真正需要数据时,由于 cache() 的去重,会直接命中之前已发起的请求
  const product = await getProduct(params.id)

  return <div>{product.name}</div>
}

精妙之处在于:preloadProduct 调用了 getProduct 但不 await 它,数据获取在后台悄悄进行。等你真正需要的时候,cache() 的记忆化机制保证不会重复请求。简单却有效。

Client Components 中的数据获取

虽然 Server Components 应该是数据获取的首选位置,但有些场景下你仍然得在客户端获取数据——实时更新、用户交互后的动态查询、无限滚动之类的需求。

React use API

React 19 引入的 use API 可以在 Client Components 中解包从 Server Components 传递下来的 Promise:

// app/products/page.tsx (Server Component)
import { ProductList } from './ProductList'

async function getProducts() {
  const res = await fetch('https://api.example.com/products')
  return res.json()
}

export default function ProductsPage() {
  // 不 await,直接传递 Promise
  const productsPromise = getProducts()

  return (
    <Suspense fallback={<div>加载产品中...</div>}>
      <ProductList productsPromise={productsPromise} />
    </Suspense>
  )
}
// app/products/ProductList.tsx
'use client'

import { use } from 'react'

export function ProductList({ productsPromise }: { productsPromise: Promise<any[]> }) {
  // use() 会"解包" Promise,在数据准备好之前触发最近的 Suspense boundary
  const products = use(productsPromise)

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  )
}

SWR 和 React Query

对于需要自动重新获取、乐观更新、缓存管理这些高级功能的场景,SWR 和 React Query(TanStack Query)还是不可替代的:

// app/dashboard/RealtimeStats.tsx
'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then(res => res.json())

export function RealtimeStats() {
  const { data, error, isLoading } = useSWR(
    '/api/stats',
    fetcher,
    {
      refreshInterval: 5000, // 每 5 秒自动刷新
      revalidateOnFocus: true, // 切换标签页回来时刷新
    }
  )

  if (isLoading) return <div>加载统计数据中...</div>
  if (error) return <div>加载失败</div>

  return (
    <div>
      <p>活跃用户: {data.activeUsers}</p>
      <p>今日订单: {data.todayOrders}</p>
    </div>
  )
}

选择原则很简单:能在 Server Component 获取就在 Server Component 获取。只有当你需要客户端交互驱动的数据获取时(搜索、筛选、实时更新、无限滚动),才考虑 SWR 或 React Query。

串行与并行数据获取

数据获取的编排方式直接决定页面加载速度。这部分很关键,但也是很多人容易忽视的地方。

串行获取(Waterfall)——你应该避免的反模式

当多个数据请求互不依赖,但你用 await 一个接一个地执行时,就形成了"瀑布式"串行获取。这可能是 Next.js 应用中最常见的性能问题了:

// 反模式:串行获取互不依赖的数据
async function ProductPage({ params }: { params: { id: string } }) {
  // 这三个请求没有依赖关系,但被迫一个接一个执行
  const product = await getProduct(params.id)      // 200ms
  const reviews = await getReviews(params.id)       // 150ms
  const related = await getRelatedProducts(params.id) // 100ms
  // 总耗时:200 + 150 + 100 = 450ms

  return <div>{/* 渲染 */}</div>
}

并行获取——Promise.all 大法好

// 正确模式:并行获取互不依赖的数据
async function ProductPage({ params }: { params: { id: string } }) {
  // 三个请求同时发出
  const [product, reviews, related] = await Promise.all([
    getProduct(params.id),
    getReviews(params.id),
    getRelatedProducts(params.id)
  ])
  // 总耗时:max(200, 150, 100) = 200ms,快了一倍多!

  return <div>{/* 渲染 */}</div>
}

就这么一个改动,加载时间直接砍掉一半多。没有额外成本,纯收益。

存在依赖关系的情况

当然了,有些数据确实需要串行获取——后一个请求依赖前一个请求的结果。这时候正确的做法是只在必要的地方串行,其余尽量并行:

async function UserDashboard({ params }: { params: { userId: string } }) {
  // 第一步:必须先获取用户信息
  const user = await getUser(params.userId)

  // 第二步:拿到用户信息后,并行获取依赖用户数据的内容
  const [orders, recommendations, notifications] = await Promise.all([
    getOrders(user.id),
    getRecommendations(user.preferences),
    getNotifications(user.id)
  ])

  return <div>{/* 渲染 */}</div>
}

Suspense 流式渲染与 loading.js

即使你用了 Promise.all,如果其中一个请求特别慢(比如一个复杂的聚合查询),整个页面还是得等它完成才能渲染。怎么办?Suspense 登场。

组件级别的 Suspense

思路很简单:把慢数据的获取封装到独立的异步组件中,用 Suspense 包裹,实现"先展示能展示的,慢的数据准备好了再补上":

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductInfo } from './ProductInfo'
import { ProductReviews } from './ProductReviews'
import { RelatedProducts } from './RelatedProducts'

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* ProductInfo 获取快,直接展示 */}
      <Suspense fallback={<div>加载产品信息...</div>}>
        <ProductInfo id={params.id} />
      </Suspense>

      {/* 评论数据可能很多,流式加载 */}
      <Suspense fallback={<div>加载评论中...</div>}>
        <ProductReviews productId={params.id} />
      </Suspense>

      {/* 推荐产品需要复杂计算,也流式加载 */}
      <Suspense fallback={<div>加载推荐产品...</div>}>
        <RelatedProducts productId={params.id} />
      </Suspense>
    </div>
  )
}
// app/products/[id]/ProductReviews.tsx
// 这是一个异步 Server Component
import { db } from '@/lib/db'

export async function ProductReviews({ productId }: { productId: string }) {
  // 这个查询可能很慢,但不会阻塞整个页面
  const reviews = await db.review.findMany({
    where: { productId },
    include: { author: true },
    orderBy: { createdAt: 'desc' }
  })

  return (
    <div>
      <h2>用户评价 ({reviews.length})</h2>
      {reviews.map(review => (
        <div key={review.id}>
          <strong>{review.author.name}</strong>
          <p>{review.content}</p>
        </div>
      ))}
    </div>
  )
}

loading.js:路由级别的 Suspense

在 App Router 中,loading.js(或 loading.tsx)本质上是为整个路由段自动包裹了一个 Suspense boundary:

// app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
      <div className="grid grid-cols-3 gap-4">
        {[1, 2, 3, 4, 5, 6].map(i => (
          <div key={i} className="h-48 bg-gray-200 rounded"></div>
        ))}
      </div>
    </div>
  )
}

当用户导航到 /products 时,Next.js 会立即展示 loading.tsx 的骨架屏,然后在后台获取数据,准备好后用真实内容替换。用户得到的是即时的视觉反馈——不会对着空白页面干等。

use cache 指令:缓存的全新范式

好了,接下来是整篇文章的重头戏。

use cache 是 Next.js 引入的全新缓存指令,它彻底改变了我们思考缓存的方式——从"缓存某个 fetch 请求"上升到"缓存整个函数的计算结果"甚至"缓存整个组件的渲染输出"。这个跨度有点大,但理解了之后你会发现它其实非常优雅。

基本用法:三个级别

use cache 可以在三个级别使用:

// 1. 文件级别:整个文件中所有导出的函数和组件都会被缓存
'use cache'

export async function getProducts() {
  return db.product.findMany()
}

export async function getCategories() {
  return db.category.findMany()
}
// 2. 函数级别:只缓存特定函数
export async function getProducts() {
  'use cache'
  return db.product.findMany()
}

// 这个函数不会被缓存
export async function getCurrentUser() {
  const session = await auth()
  return session?.user
}
// 3. 组件级别:缓存整个组件的渲染输出
async function ProductGrid() {
  'use cache'

  const products = await db.product.findMany({
    where: { active: true }
  })

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

关键限制:运行时数据的隔离

这里有个非常重要的约束,很多人第一次会在这里踩坑:use cache 的作用域内,你不能使用运行时数据,包括 cookies()headers()searchParams 等。原因很好理解——这些数据每个请求都不同,如果允许在缓存函数里使用,缓存就没意义了。

// 错误示例:不能在 use cache 中使用运行时数据
async function UserSpecificContent() {
  'use cache'
  const cookieStore = await cookies() // 这会报错!
  const token = cookieStore.get('token')
  // ...
}

// 正确做法:将运行时数据作为参数传入(参数会成为缓存 key 的一部分)
async function UserContent({ userId }: { userId: string }) {
  'use cache'
  const user = await db.user.findUnique({ where: { id: userId } })
  return <div>{user?.name}</div>
}

connection() API:显式声明动态渲染

如果你的组件确实需要在请求时才能运行(比如读取 cookies),可以用 connection() API 明确告诉 Next.js:

import { connection } from 'next/server'

export default async function DynamicPage() {
  // 显式告诉 Next.js:这个组件必须在请求时渲染,不能被静态化
  await connection()

  const cookieStore = await cookies()
  const theme = cookieStore.get('theme')?.value ?? 'light'

  return <div data-theme={theme}>动态内容</div>
}

Cache Components 与 Partial Prerendering

要启用 Cache Components 和 Partial Prerendering(PPR),需要在 next.config 中开启:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    cacheComponents: true, // 启用 Cache Components 和 PPR
  }
}

export default nextConfig

开启后,Next.js 会在构建时创建一个静态 HTML 外壳,包含所有标记了 use cache 的组件的缓存输出。页面中的动态部分(用户特定内容、实时数据等)则通过 Suspense boundary 以"动态洞"(dynamic holes)的形式存在,在请求时通过流式渲染填充。

这就是 PPR 的核心理念:一个页面既是静态的,又是动态的。静态部分在 CDN 边缘节点缓存,毫秒级响应;动态部分在请求时流式补充。听起来像是鱼和熊掌兼得?某种意义上确实是。

// app/page.tsx — PPR 的完美示例
import { Suspense } from 'react'
import { Header } from '@/components/Header'
import { ProductGrid } from '@/components/ProductGrid'
import { UserGreeting } from '@/components/UserGreeting'
import { CartCount } from '@/components/CartCount'

export default function HomePage() {
  return (
    <div>
      <nav>
        <Header />  {/* 缓存的静态外壳 */}
        <Suspense fallback={<span>...</span>}>
          <UserGreeting />  {/* 动态洞:请求时填充 */}
        </Suspense>
        <Suspense fallback={<span>0</span>}>
          <CartCount />  {/* 动态洞:请求时填充 */}
        </Suspense>
      </nav>

      {/* 这个组件用了 use cache,会被静态化 */}
      <ProductGrid />

      <Suspense fallback={<div>加载推荐中...</div>}>
        <PersonalizedRecommendations /> {/* 动态洞 */}
      </Suspense>
    </div>
  )
}

cacheLife:精细控制缓存时长

cacheLife 让你精确控制缓存数据的存活时间。Next.js 提供了几个内置的缓存配置档案(profile),也支持完全自定义:

内置配置档案

import { cacheLife } from 'next/cache'

// 使用内置配置档案
async function FrequentlyUpdated() {
  'use cache'
  cacheLife('minutes') // 几分钟级别的缓存
  // ...
}

async function HourlyContent() {
  'use cache'
  cacheLife('hours') // 小时级别的缓存
  // ...
}

async function DailyContent() {
  'use cache'
  cacheLife('days') // 天级别的缓存
  // ...
}

async function RarelyChanging() {
  'use cache'
  cacheLife('weeks') // 周级别的缓存
  // ...
}

async function StaticContent() {
  'use cache'
  cacheLife('max') // 最大缓存时长,基本上就是"永远"
  // ...
}

自定义缓存对象

内置档案不够用的时候(老实说,实际项目中经常不够用),你可以传入自定义对象,精确控制三个关键参数:

import { cacheLife } from 'next/cache'

async function ProductCatalog() {
  'use cache'
  cacheLife({
    stale: 300,      // 数据在 300 秒(5 分钟)内被认为是"新鲜的",直接返回缓存
    revalidate: 600, // 300-600 秒之间,返回缓存但在后台重新验证(stale-while-revalidate)
    expire: 3600     // 3600 秒(1 小时)后缓存完全过期,必须重新获取
  })

  const products = await db.product.findMany({
    where: { active: true },
    orderBy: { createdAt: 'desc' }
  })

  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  )
}

这三个参数构成了一个完整的缓存生命周期:

  • stale:这段时间内缓存完全新鲜,直接使用,不做任何后台验证。
  • revalidate:stale 到 revalidate 的窗口期内,仍返回缓存数据,但后台悄悄启动重新获取(stale-while-revalidate 语义)。
  • expire:绝对过期时间。超过这个点,缓存彻底作废,下次访问必须等新数据。

cacheTag:给缓存数据打标签

cacheTag 是构建高效缓存失效策略的基石——给缓存数据打上标签,方便后续精准"狙击"需要失效的缓存。

import { cacheTag, cacheLife } from 'next/cache'

async function ProductDetail({ id }: { id: string }) {
  'use cache'
  cacheTag('products', `product-${id}`)
  cacheLife('hours')

  const product = await db.product.findUnique({
    where: { id },
    include: { category: true, reviews: true }
  })

  return (
    <div>
      <h2>{product.name}</h2>
      <p>{product.description}</p>
      <span>¥{product.price}</span>
    </div>
  )
}

async function CategorySidebar() {
  'use cache'
  cacheTag('categories')
  cacheLife('days')

  const categories = await db.category.findMany({
    where: { active: true },
    orderBy: { name: 'asc' }
  })

  return (
    <aside>
      {categories.map(cat => (
        <a key={cat.id} href={`/products?category=${cat.id}`}>{cat.name}</a>
      ))}
    </aside>
  )
}

标签的粒度设计很重要。上面的例子中,ProductDetail 同时拥有通用标签 'products' 和特定标签 'product-${id}'。这样你既可以一键失效所有产品缓存,也可以只精确失效某个产品。灵活度拉满。

缓存失效:revalidateTagupdateTagrevalidatePath

缓存做得再好,失效策略不对的话,用户看到的就是过期数据。这块儿是很多团队容易出问题的地方。Next.js 提供了三种缓存失效方式,各有其适用场景。

revalidateTag:后台重新验证

revalidateTag 会标记特定标签的缓存为"过期",但不会立即删除它。下一次请求仍然可以收到旧数据(stale-while-revalidate),同时 Next.js 在后台默默获取新数据。

// app/api/webhook/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const payload = await request.json()

  // CMS 内容更新时,触发重新验证
  if (payload.type === 'product.updated') {
    revalidateTag(`product-${payload.productId}`)
  }

  if (payload.type === 'category.updated') {
    revalidateTag('categories')
  }

  return Response.json({ revalidated: true })
}

updateTag:立即失效(仅限 Server Actions)

updateTag 只能在 Server Actions 中使用,它会立即让缓存过期。这实现了"读你自己的写"(read-your-own-writes)语义——用户更新了产品名称,应该马上看到更新后的结果,而不是还在看旧缓存。

// app/actions/products.ts
'use server'

import { updateTag } from 'next/cache'
import { db } from '@/lib/db'

export async function updateProduct(id: string, formData: FormData) {
  const name = formData.get('name') as string
  const price = parseFloat(formData.get('price') as string)

  await db.product.update({
    where: { id },
    data: { name, price }
  })

  // 立即失效——用户会看到更新后的数据
  updateTag(`product-${id}`)
  updateTag('products') // 同时失效产品列表
}
// app/actions/categories.ts
'use server'

import { updateTag } from 'next/cache'
import { db } from '@/lib/db'

export async function archiveCategory(categoryId: string) {
  await db.category.update({
    where: { id: categoryId },
    data: { active: false }
  })

  // updateTag 确保当前用户立刻看到变更
  updateTag('categories')
  updateTag('products') // 产品列表也会受影响
}

revalidateTag vs updateTag,怎么选?

  • revalidateTag:适用于后台任务、Webhook、定时任务。允许短暂返回旧数据,后台默默刷新。可以在任何服务端代码中使用。
  • updateTag:适用于 Server Actions(用户直接操作)。立即失效,确保用户看到最新数据。只能在 Server Actions 中使用。

简单来说:用户自己改的数据用 updateTag,系统后台更新的数据用 revalidateTag

revalidatePath:基于路径的缓存失效

revalidatePath 是一种更粗粒度的方式——直接让特定路径下的所有缓存失效:

'use server'

import { revalidatePath } from 'next/cache'

export async function publishBlogPost(id: string) {
  await db.post.update({
    where: { id },
    data: { publishedAt: new Date(), status: 'published' }
  })

  // 失效博客列表页
  revalidatePath('/blog')

  // 失效特定博客文章页
  revalidatePath(`/blog/${id}`)

  // 失效布局级别(如果布局中显示了文章计数等)
  revalidatePath('/blog', 'layout')
}

一般来说,优先使用 cacheTag + revalidateTag/updateTag 的组合,更精准可控。revalidatePath 当作兜底方案——适合那些你没法精确标记标签的场景。

unstable_cache 迁移到 use cache

如果你之前用的是 unstable_cache(Next.js 14 时代的实验性缓存 API),是时候迁移了。光看名字里那个 "unstable" 就知道,它注定是过渡方案。

迁移前(unstable_cache)

// 旧方式:使用 unstable_cache
import { unstable_cache } from 'next/cache'

const getCachedProducts = unstable_cache(
  async (categoryId: string) => {
    return db.product.findMany({
      where: { categoryId, active: true }
    })
  },
  ['products'],                    // 缓存 key
  { revalidate: 3600, tags: ['products'] } // 配置
)

// 使用
const products = await getCachedProducts('electronics')

迁移后(use cache)

// 新方式:使用 use cache
import { cacheTag, cacheLife } from 'next/cache'

async function getCachedProducts(categoryId: string) {
  'use cache'
  cacheTag('products', `category-${categoryId}`)
  cacheLife('hours')

  return db.product.findMany({
    where: { categoryId, active: true }
  })
}

// 使用方式完全一样
const products = await getCachedProducts('electronics')

迁移的核心变化:

  1. 声明式 vs 包装式use cache 是声明式指令,直接写在函数体内,不用再把函数包在另一个函数里。清爽多了。
  2. 缓存 key 自动生成use cache 会自动根据函数参数生成缓存 key,不需要手动指定。
  3. 配置更灵活cacheLife 支持预设档案和自定义对象,比单纯的 revalidate: N 表达力强得多。
  4. 组件级支持use cache 可以直接用在组件上,缓存整个渲染输出——这是 unstable_cache 做不到的。

实战架构:静态、缓存与动态内容的混合

来,让我们用一个完整的电商产品页面把前面所有概念串起来。这可能是整篇文章最值得收藏的部分:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    cacheComponents: true,
  }
}

export default nextConfig
// lib/data/products.ts
import { cache } from 'react'
import { cacheTag, cacheLife } from 'next/cache'
import { db } from '@/lib/db'

// 请求级别去重(React cache)
export const getProduct = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true }
  })
})

// 跨请求缓存(use cache)
export async function getCachedProduct(id: string) {
  'use cache'
  cacheTag(`product-${id}`, 'products')
  cacheLife('hours')

  return db.product.findUnique({
    where: { id },
    include: { category: true, images: true }
  })
}

export async function getProductReviews(productId: string) {
  'use cache'
  cacheTag(`reviews-${productId}`)
  cacheLife('minutes')

  return db.review.findMany({
    where: { productId },
    include: { author: { select: { name: true, avatar: true } } },
    orderBy: { createdAt: 'desc' },
    take: 20
  })
}

export async function getRelatedProducts(categoryId: string, excludeId: string) {
  'use cache'
  cacheTag('products', `category-${categoryId}`)
  cacheLife('days')

  return db.product.findMany({
    where: {
      categoryId,
      active: true,
      id: { not: excludeId }
    },
    take: 6
  })
}
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { getCachedProduct } from '@/lib/data/products'
import { ProductImages } from './ProductImages'
import { ProductReviews } from './ProductReviews'
import { RelatedProducts } from './RelatedProducts'
import { AddToCartButton } from './AddToCartButton'
import { RecentlyViewed } from './RecentlyViewed'

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getCachedProduct(params.id)

  if (!product) {
    return <div>产品不存在</div>
  }

  return (
    <div>
      {/* 静态/缓存区域:产品信息 */}
      <section>
        <ProductImages images={product.images} />
        <div>
          <h1>{product.name}</h1>
          <p>{product.description}</p>
          <span className="price">¥{product.price}</span>
          <p>分类: {product.category.name}</p>
        </div>
      </section>

      {/* 动态区域:加入购物车(需要用户状态) */}
      <Suspense fallback={<button disabled>加载中...</button>}>
        <AddToCartButton productId={product.id} />
      </Suspense>

      {/* 缓存区域:评论(短时缓存) */}
      <Suspense fallback={<div>加载评论中...</div>}>
        <ProductReviews productId={product.id} />
      </Suspense>

      {/* 缓存区域:相关产品(长时缓存) */}
      <Suspense fallback={<div>加载推荐中...</div>}>
        <RelatedProducts
          categoryId={product.categoryId}
          excludeId={product.id}
        />
      </Suspense>

      {/* 动态区域:最近浏览(用户特定) */}
      <Suspense fallback={<div>加载浏览记录...</div>}>
        <RecentlyViewed />
      </Suspense>
    </div>
  )
}
// app/products/[id]/AddToCartButton.tsx
import { connection } from 'next/server'
import { cookies } from 'next/headers'
import { AddToCartForm } from './AddToCartForm'

export async function AddToCartButton({ productId }: { productId: string }) {
  // 显式声明需要请求时渲染
  await connection()

  const cookieStore = await cookies()
  const cartId = cookieStore.get('cart-id')?.value

  return <AddToCartForm productId={productId} cartId={cartId} />
}
// app/products/[id]/RecentlyViewed.tsx
import { connection } from 'next/server'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'

export async function RecentlyViewed() {
  await connection()

  const cookieStore = await cookies()
  const userId = cookieStore.get('user-id')?.value

  if (!userId) return null

  const recentProducts = await db.viewHistory.findMany({
    where: { userId },
    include: { product: true },
    orderBy: { viewedAt: 'desc' },
    take: 4
  })

  return (
    <section>
      <h3>最近浏览</h3>
      <div className="grid grid-cols-4 gap-4">
        {recentProducts.map(item => (
          <a key={item.product.id} href={`/products/${item.product.id}`}>
            {item.product.name}
          </a>
        ))}
      </div>
    </section>
  )
}
// app/actions/cart.ts — Server Action 配合 updateTag 实现即时反馈
'use server'

import { updateTag } from 'next/cache'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'

export async function addToCart(productId: string, cartId: string | undefined) {
  const cookieStore = await cookies()
  let currentCartId = cartId

  if (!currentCartId) {
    const cart = await db.cart.create({ data: {} })
    currentCartId = cart.id
    cookieStore.set('cart-id', currentCartId)
  }

  await db.cartItem.upsert({
    where: {
      cartId_productId: { cartId: currentCartId, productId }
    },
    create: { cartId: currentCartId, productId, quantity: 1 },
    update: { quantity: { increment: 1 } }
  })

  // 立即让购物车相关缓存失效
  updateTag('cart')
}

这个架构的精妙之处在于——同一个页面上,不同区域有完全不同的缓存策略:

  • 产品基本信息use cache + cacheLife('hours'),小时级缓存。
  • 用户评论use cache + cacheLife('minutes'),分钟级缓存,保持相对新鲜。
  • 相关产品use cache + cacheLife('days'),天级缓存,不常变化。
  • 加入购物车按钮connection(),每次请求动态渲染。
  • 最近浏览:完全动态,每个用户都不一样。

PPR 会把所有 use cache 的部分预渲染为静态 HTML 外壳,动态部分在请求时流式填充。用户几乎瞬间看到产品信息,购物车和个性化内容紧随其后。这种体验,说真的,用过就回不去了。

最佳实践与性能优化清单

聊了这么多,最后整理一份可以直接落地的实践清单。建议收藏备查。

数据获取原则

  • 优先在 Server Components 中获取数据:减少客户端 JS 体积,避免暴露 API 密钥和数据库连接信息。这应该是默认选择。
  • Promise.all 并行化无依赖的请求:这是最容易获得的性能提升,零额外成本。
  • 用 Suspense 拆分慢数据:别让一个慢查询拖垮整个页面。放进独立异步组件,Suspense 包裹,搞定。
  • 使用 React cache() 去重 ORM 查询:布局和页面都需要同一份数据?cache() 保证数据库只查一次。
  • 善用 preload 模式:知道待会儿要用但现在还不急?先 void 一下,让请求飞一会儿。

缓存策略原则

  • 明确区分静态、缓存和动态内容:不是所有数据都需要实时新鲜,也不是所有数据都该长期缓存。根据业务需求分类。
  • 标签设计要有层次:通用标签('products')搭配特定标签('product-123'),支持不同粒度的缓存失效。
  • Server Actions 中用 updateTag:用户操作后立即反馈。Webhook 和后台任务用 revalidateTag
  • 合理设置 cacheLife:价格和库存?分钟级。产品描述?小时级。静态页面?天级。按实际更新频率来。
  • revalidatePath 是兜底方案:精确标签搞不定时再用。粒度太粗,别过度依赖。

架构层面的建议

  • 数据获取函数集中管理:创建 lib/data/ 目录,按领域组织(products.ts、users.ts、orders.ts)。缓存配置和标签有了统一归属。
  • 运行时数据与缓存数据严格分离:永远别在 use cache 里碰 cookies()headers()。用户特定逻辑放独立的动态组件。
  • 开启 PPRcacheComponents: true 让静态外壳在 CDN 边缘缓存,动态内容流式填充。目前 Web 性能的最优解,没有之一。
  • 监控缓存命中率:用 Next.js 内置日志追踪命中率和 revalidate 频率。某个标签被频繁失效?那缓存策略可能需要调整。

常见陷阱

  • 别在 use cache 中用 Date.now()Math.random():这些值会被"冻住",每次返回同一个"随机"值或"当前"时间。踩过这个坑的人应该不少。
  • 注意缓存 key 的稳定性use cache 根据函数参数自动生成缓存 key。如果你每次传入不同的对象引用(哪怕内容相同),缓存就永远命中不了。确保传入原始值或稳定的序列化结果。
  • 写入后别忘记失效缓存:Server Actions 写入数据后一定要调 updateTagrevalidateTag。不调的后果?用户盯着过期数据,你盯着 bug report。
  • 注意安全边界:缓存数据可能被多个用户共享。确保缓存内容不含用户敏感信息。需要缓存用户特定数据的话,把 userId 作为参数传入,让它成为缓存 key 的一部分。

结语

Next.js 的数据获取和缓存体系已经从 14 时代的"默认缓存一切"演进到 15+ 时代的"默认不缓存,显式选择"。多了点样板代码?是的。但换来的是更可预测、更可控的数据流。我觉得这笔买卖很划算。

use cache 和 Cache Components 代表了缓存的下一个阶段——从"缓存网络请求"上升到"缓存计算结果和渲染输出"。配合 PPR,你能在同一个页面精细地混合静态、缓存和动态内容,让每一部分都以最优方式送达。

掌握这些模式需要时间和实践。我的建议是从最简单的场景入手——给一个频繁访问的列表页加上 use cachecacheTag,在对应的 Server Action 中加上 updateTag——然后逐步扩展。数据获取和缓存也许是 Next.js 最难啃的部分,但一旦你吃透了它的设计哲学,它就会成为你手里最趁手的武器。

关于作者 Editorial Team

Our team of expert writers and editors.