引言:为什么数据获取与缓存是 Next.js 最难啃的骨头
如果你已经在 Next.js 的世界里摸爬滚打了一段时间,你大概已经发现了一个残酷的事实:数据获取和缓存策略,才是真正区分初级开发者和高级开发者的分水岭。组件写得再漂亮、路由设计得再优雅,一旦数据获取的模式不对,整个应用的性能就会像多米诺骨牌一样崩塌。
说实话,我自己也在这上面栽过跟头。
Next.js 15 对缓存行为做了一次颠覆性的调整:fetch 请求默认不再缓存。这看似一个小改动,实际上重新定义了整个数据获取的心智模型。再加上新引入的 use cache 指令、cacheLife、cacheTag、updateTag 等一系列新 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}'。这样你既可以一键失效所有产品缓存,也可以只精确失效某个产品。灵活度拉满。
缓存失效:revalidateTag、updateTag 和 revalidatePath
缓存做得再好,失效策略不对的话,用户看到的就是过期数据。这块儿是很多团队容易出问题的地方。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')
迁移的核心变化:
- 声明式 vs 包装式:
use cache是声明式指令,直接写在函数体内,不用再把函数包在另一个函数里。清爽多了。 - 缓存 key 自动生成:
use cache会自动根据函数参数生成缓存 key,不需要手动指定。 - 配置更灵活:
cacheLife支持预设档案和自定义对象,比单纯的revalidate: N表达力强得多。 - 组件级支持:
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()。用户特定逻辑放独立的动态组件。 - 开启 PPR:
cacheComponents: true让静态外壳在 CDN 边缘缓存,动态内容流式填充。目前 Web 性能的最优解,没有之一。 - 监控缓存命中率:用 Next.js 内置日志追踪命中率和 revalidate 频率。某个标签被频繁失效?那缓存策略可能需要调整。
常见陷阱
- 别在
use cache中用Date.now()或Math.random():这些值会被"冻住",每次返回同一个"随机"值或"当前"时间。踩过这个坑的人应该不少。 - 注意缓存 key 的稳定性:
use cache根据函数参数自动生成缓存 key。如果你每次传入不同的对象引用(哪怕内容相同),缓存就永远命中不了。确保传入原始值或稳定的序列化结果。 - 写入后别忘记失效缓存:Server Actions 写入数据后一定要调
updateTag或revalidateTag。不调的后果?用户盯着过期数据,你盯着 bug report。 - 注意安全边界:缓存数据可能被多个用户共享。确保缓存内容不含用户敏感信息。需要缓存用户特定数据的话,把 userId 作为参数传入,让它成为缓存 key 的一部分。
结语
Next.js 的数据获取和缓存体系已经从 14 时代的"默认缓存一切"演进到 15+ 时代的"默认不缓存,显式选择"。多了点样板代码?是的。但换来的是更可预测、更可控的数据流。我觉得这笔买卖很划算。
use cache 和 Cache Components 代表了缓存的下一个阶段——从"缓存网络请求"上升到"缓存计算结果和渲染输出"。配合 PPR,你能在同一个页面精细地混合静态、缓存和动态内容,让每一部分都以最优方式送达。
掌握这些模式需要时间和实践。我的建议是从最简单的场景入手——给一个频繁访问的列表页加上 use cache 和 cacheTag,在对应的 Server Action 中加上 updateTag——然后逐步扩展。数据获取和缓存也许是 Next.js 最难啃的部分,但一旦你吃透了它的设计哲学,它就会成为你手里最趁手的武器。