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

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

Next.js 15数据获取与缓存指南:use cache实战2026

引言:为什么数据获取与缓存是 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 最难啃的部分,但一旦你吃透了它的设计哲学,它就会成为你手里最趁手的武器。

关于作者 Marcus Okonkwo

Marcus picked up React in 2017 and has been writing it professionally ever since. Five years at Stripe on the Dashboard team, where he led the migration of the billing UI off a legacy webpack 4 setup onto Next.js with module federation for the embedded merchant components. He shipped the first internal RSC prototype there in late 2023, which mostly worked. Now he's a staff engineer at a logistics startup in Austin, owning the customer-facing Next.js app and the design system that backs it. He spends a disproportionate amount of time thinking about route group conventions, parallel routes for modal UX, and why next/image keeps surprising people on Cloudflare's image resizing. His writing favors annotated code over prose. Seven years of React, two of those deep in App Router land.