假设我们正在做一个课程商店。商品详情来自数据库,库存来自另一个内部服务,评价接口偶尔要等两秒,运营人员刚改完价格就希望自己立刻看到新值。页面既要让搜索引擎读到正文,也不能因为评价慢就整页白屏。
这类需求真正难的地方,不是会不会写 await fetch(),而是要同时回答四个问题:数据在哪个环境读取、这次读取能否复用、页面要等到什么时候才开始返回、数据改变后谁负责让旧结果失效。
本章会围绕 /products/[slug] 这条商品路由,把这四个问题串成一套可以落地的判断方法。先把版本基线说清楚:当前项目实际安装的是 Next.js 16.1.6 和 React 19.2.3,但 next.config.mjs 没有开启 cacheComponents。所以我们会先写当前项目能够直接运行的方案,再单独介绍开启 Cache Components 后的 Next.js 16 新模型。

如果你记得“App Router 里的 fetch 默认长期缓存”,请先把这条旧结论放下。从 Next.js 15 开始,裸 fetch 默认不进入跨请求的持久 Data Cache;Next.js 16 仍然如此。一次服务端渲染内的相同请求可能被 React 记忆化去重,但那不是“下一位用户也会命中”的持久缓存。
一张页面的数据通常要经过四层:数据源、服务器数据层、React 组件树、浏览器。很多缓存和安全问题,都是因为我们把这四层揉成了一个模糊的“后端”。
Server Component 可以等待任意服务端异步 I/O,常见来源包括:
fetch 只是其中一种传输方式。直接查数据库并不会天然比调用 API 更“高级”,调用 API 也不一定多余。已有独立后端、统一网关和成熟权限体系时,Server Component 继续调用 HTTP API 很合理;新项目若前后端就在一个部署单元里,服务器数据层直接调用 ORM 往往更短。
App Router 的 page.tsx 和 layout.tsx 默认是 Server Components,可以直接写成异步函数。
// app/products/page.tsx
type ProductSummary = {
id: string
slug: string
name: string
priceInCents: number
}
export default async function ProductsPage() {
const response = await fetch('https://api.example.com/products')
if (!response.ok) {
throw new Error(`读取商品失败:${response.status}`)
}
const products = (await response.json()) as ProductSummary[]
return (
<main>
<h1>课程商店</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} · ¥{(product.priceInCents / 100).toFixed(2)}
</li>
))}
</ul>
</main>
)
}这里没有 useEffect,也不需要先把一个空壳发给浏览器,再由浏览器发第二个请求。服务器取得数据后直接渲染 React 树,首屏 HTML 和 RSC Payload 都能携带渲染结果。
fetch 不会替你处理 HTTP 错误网络断开时,fetch 会拒绝 Promise;但上游返回 404 或 500 时,Promise 通常仍然成功兑现。要不要把它当错误,由应用检查 response.ok 后决定。
export async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`上游请求失败:${response.status} ${response.statusText}`)
}
return response.
如果“找不到商品”是业务上可预期的分支,可以返回 null,再由页面调用 notFound();连接失败或上游 500 则交给 error.tsx。不要把空数据、业务不存在和系统故障都压成一个空数组,否则页面看似正常,运维却失去了故障信号。
Server Component 的模块不会进入浏览器包,所以数据库凭据和查询客户端可以留在服务器。不过,课程项目不应该把查询散落在所有组件里。更稳妥的做法是建立一个服务器专属数据层。
// app/data/products.ts
import 'server-only'
import { db } from '@/lib/db'
export type PublicProduct = Readonly<{
id: string
slug: string
name: string
priceInCents: number
}>
export async function getPublicProduct(
slug: string,
): Promise<
这里有三个细节。
第一,server-only 会在这个模块被 Client Component 误导入时让构建失败。第二,查询用 select 明确列出公开字段,没有把成本价、供应商备注和内部状态顺手带出来。第三,函数返回的是页面需要的 DTO,而不是 ORM 的整行模型。
“代码只在服务器运行”不等于“数据自动安全”。Server Component 仍可能把对象作为 props 传给 Client Component,而这些 props 会进入浏览器可观察的传输结果。认证、对象级授权、参数校验和字段最小化都必须在数据层完成。
讨论缓存前,我建议先不用“缓存”这个大词,而是问:我们到底想在什么范围内复用结果?
React 会在一次服务端渲染的组件树内,对相同的 GET fetch 请求做记忆化。页头和正文都请求同一个 URL 时,通常只会真正发出一次上游请求,两处共享同一个结果。
它的边界很窄:本次渲染结束后,这份记忆就没有了;下一次用户请求会有自己的范围。Route Handler 也不处在 React 组件树渲染范围里,不能把这条规则无条件套过去。
React.cache 给非 fetch 工作做请求内去重数据库查询、ORM 方法和自定义 SDK 不会自动获得 fetch 的请求记忆化。可以把函数在模块顶层包进 React 的 cache。
// app/data/products.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db'
export const getProduct = cache(async (slug: string) => {
return db.product.findUnique({
where: { slug },
select: {
id: true,
slug: true,
name: true
generateMetadata 和页面都调用同一个 getProduct('nextjs-course') 时,可以共享本次请求里的查询结果。要让它命中,二者必须 import 同一个 memoized 函数实例;如果每次调用时临时执行 cache(originalFn),得到的是互不相干的缓存。
React cache 还会记住该参数组合抛出的错误。它不是重试器,也不是跨用户的持久缓存。换个请求,函数仍会重新查数据库。
如果商品目录一天只改几次,我们可能希望不同用户共享结果。这才是 Next.js Data Cache 或 Cache Components 要解决的范围。
持久缓存必须同时说明:
个人订单、未发布草稿、权限相关报价不能因为“查询很慢”就塞进公共共享缓存。先判断数据能否共享,再谈性能。

当前项目没有开启 cacheComponents,所以这一节使用 Next.js 16 的“previous model”。它仍然受支持,但要显式选择缓存,不能沿用早期版本的默认结论。
fetch 默认不持久缓存下面这次读取默认不会进入 Data Cache,因此不会因为上一位用户访问过同一个 URL,就自动命中一份跨请求的数据缓存。
const response = await fetch('https://api.example.com/products')但“没有进入 Data Cache”不等于“必然只在用户请求时执行”。如果整条路由符合静态预渲染条件,这次 fetch 仍可能在 next build 时执行一次,生成的路由输出随后由 Full Route Cache 提供;路由动态渲染时,它才会随请求重新读取。数据有没有持久缓存,与路由在构建期还是请求期渲染,是两个相关但不同的问题。
如果数据必须每次读取最新值,可以写出明确意图:
const response = await fetch('https://api.example.com/inventory', {
cache: 'no-store',
})在当前默认行为下,裸 fetch 与 no-store 都不会进入持久 Data Cache,但渲染时机并不完全等价。显式写出 no-store 会让这次读取明确发生在请求时,也让读代码的人知道:这里的实时性是设计,而不是遗漏了缓存配置。
force-cache 选择跨请求复用公开且很少变化的分类字典可以进入 Data Cache。
const response = await fetch('https://api.example.com/categories', {
cache: 'force-cache',
})第一次缓存未命中时,Next.js 读取上游并保存结果;后续请求可以复用。它解决的是数据读取复用,不等于所有页面 HTML 都永远不再渲染。
商品目录允许在十分钟内短暂陈旧,可以给请求设置重新验证间隔。
const response = await fetch('https://api.example.com/products', {
next: { revalidate: 600 },
})revalidate 的单位是秒。它表达“缓存结果多久需要检查更新”,不是精确到秒的定时任务,也不保证到第 600 秒时上游立刻被请求。通常要等缓存过期后的访问触发重新验证。
不要把互相矛盾的配置写在一起:
// 错误示例:既要求不缓存,又要求一小时重新验证
await fetch('https://api.example.com/products', {
cache: 'no-store',
next: { revalidate: 3600 },
})Next.js 会把这视为冲突配置,并在开发环境给出警告。
如果后台保存商品后就能通知前台,那么标签通常比很短的 TTL 更合适。
export async function getProducts() {
const response = await fetch('https://api.example.com/products', {
cache: 'force-cache',
next: { tags: ['products'] },
})
if (!response.ok) {
throw new Error('读取商品目录失败')
}
return response.json()
}因为裸 fetch 默认不持久缓存,这里同时写 force-cache 和 tags,让“缓存什么”与“以后如何失效”都清楚可见。
fetch 数据可以使用 unstable_cache当前未开启 Cache Components 时,如果 ORM 结果需要跨请求复用,可以使用 unstable_cache。名字里的 unstable 不是装饰:它属于旧模型的 API,准备迁移到 Cache Components 的项目不应再到处新增这一层抽象。
// app/data/catalog.ts
import 'server-only'
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getCatalog = unstable_cache(
async () =>
db.product.findMany({
where: { published: true },
select: { id: true, slug: true, name: true, priceInCents: true },
}),
['public-product-catalog'
unstable_cache 是持久层,React cache 是请求层。二者看起来都能少查一次数据库,生命周期和安全边界却完全不同。
Next.js 16 把 Cache Components 整合为一个显式配置。开启后,缓存的中心从“猜这条路由算静态还是动态”转向“明确标出哪段输出可以缓存”。
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig当前课程仓库还没有这项配置,所以后面的代码是迁移目标,不能直接和上一节的 route segment 配置随意拼在一起。开启 Cache Components 后,旧的 dynamic、revalidate、fetchCache 等 route segment 配置不再是主控制面;应使用 use cache、cacheLife、cacheTag 和 Suspense 表达边界。
use cache 标出可共享工作// app/data/catalog.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
export async function getCatalog() {
'use cache'
cacheLife('hours')
cacheTag('products')
return db.product.findMany({
where: { published: true },
select: { id: true, slug: true, name:
指令可以放在异步函数、组件或文件级。数据层函数通常最容易推理:调用者仍然只拿数据,寿命和标签与数据读取放在一起。
cacheLife('hours') 使用内置的寿命档位。除了 hours,内置档位还有 default、seconds、minutes、days、weeks 和 max;没有显式调用 cacheLife 时会采用默认 profile。档位名称不是对业务的承诺,仍要根据数据更新频率和可接受陈旧时间选择。
自定义 profile 可以写 stale、revalidate 和 expire。
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
catalog: {
stale: 300,
revalidate: 600,
expire: 86_400,
},
},
}
export default nextConfigstale 控制客户端路由缓存可以直接使用多久。revalidate 控制服务器经过多久应重新验证;后台更新期间仍可以先提供陈旧值。expire 是硬过期点。达到它以后,下一次访问必须等待新值生成,不能继续先返回旧值;因此它应当长于 revalidate。不要把 stale 当成响应头里的普通 CDN max-age。Cache Components 会把这套语义协调到客户端与服务器层,但外部 CDN 仍有自己的缓存和清除规则。
开启 Cache Components 后,未缓存的数据读取不应偷偷混进预渲染的静态外壳。把实时库存组件放在 Suspense 边界里,外面的标题和商品说明可以先形成静态 shell,库存到请求时再读取并流式送达。
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { getCachedProduct } from '@/app/data/catalog'
import { LiveInventory } from './live-inventory'
import { InventorySkeleton } from './inventory-skeleton'
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const
cookies()、headers() 这类请求时 API 不应在普通 use cache 范围内读取。公开内容可以缓存;用户购物车、草稿权限和地区化合同价通常应保持请求时读取。
有些场景可以先在缓存外读取一个安全、有限的值,再作为参数传入缓存函数。但参数会参与缓存键,不能把访问令牌、完整 Cookie 或敏感个人数据当成普通缓存参数。需要私有缓存时要专门评估 use cache: private,不能把它当作默认捷径。

下面的实验台把“当前项目模式”和“Cache Components 模式”分开。切换数据类型和新鲜度要求,观察推荐 API、复用范围与失效方式如何变化。
“静态页面”和“动态页面”容易让人误以为整个路由只能二选一。更实用的做法是从数据的新鲜度与用户差异出发。
这些数据通常适合在构建期或缓存命中时进入可复用输出:
动态路由可以用 generateStaticParams 提前生成已知 slug,但它不是缓存策略的替代品。是否预生成路径和数据多久更新,是两个问题。
这些内容通常不应进入公共共享缓存:
请求时渲染也不等于“完全没有优化”。同一次渲染可以用请求记忆化去重,页面可以用 Suspense 流式返回,数据库仍可建立索引和连接池。
商品标题和描述适合缓存,库存需要较新,评价可以稍晚显示,管理按钮还依赖当前用户权限。把整页粗暴标成“静态”或“动态”,会迫使所有区域服从最极端的需求。
Cache Components 的静态 shell 加动态区域,或者未开启时的组件级 Suspense,都在鼓励同一页面按数据边界拆分。边界越贴近真实数据依赖,等待和失效就越容易控制。
表里没有“数据量很大所以缓存”这一列。数据量影响成本,但可共享性和陈旧容忍度先决定能不能缓存。
服务器取数减少了浏览器往返,却不会自动把串行 await 变成并行。两个各耗时 800 毫秒的独立请求如果首尾相接,等待时间接近 1600 毫秒;同时启动则更接近较慢的那一个。
下面的第二次查询必须使用第一次得到的 sellerId,它是真实依赖链。
const product = await getProduct(slug)
const seller = await getSeller(product.sellerId)不能为了追求 Promise.all 把逻辑关系抹掉。真正该优化的是第一个阻塞查询:索引、缓存或把数据源设计成一次返回需要的字段。
商品、推荐和评价只共同依赖 slug,可以并行。
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const productPromise = getProduct(slug)
const recommendationsPromise = getRecommendations(slug)
const reviewsPromise = getReviews
如果其中任一请求失败,Promise.all 会整体拒绝。这适合“任何一块失败都不能展示页面”的情况。若评价失败仍允许商品主体正常出现,更好的结构是让评价组件拥有自己的 Suspense 和错误边界,而不是用 Promise.allSettled 在页面里堆分支。
当页面要先处理参数或渲染其他部分时,可以提前调用 memoized 函数启动 Promise,真正需要结果的组件再调用同一个函数。
// app/data/products.ts
import { cache } from 'react'
export const getProduct = cache(async (slug: string) => {
return db.product.findUnique({ where: { slug } })
})
export function preloadProduct(slug: string) {
void getProduct(slug)
}// app/products/[slug]/page.tsx
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params
preloadProduct(slug)
return <ProductDetails slug={slug} />
}
async function ProductDetails({ slug }: { slug: string }) {
const
第一次调用把查询启动并把 Promise 放进请求记忆;第二次调用取回同一个 Promise。不要把 preload 写在模块顶层,React cache 在组件渲染之外没有相同的请求缓存语义。

下面的时间线允许你调节三条请求的耗时与依赖关系。先观察三个连续 await 的总时长,再把独立请求并行,最后给真正依赖的请求保留串行。
并行请求缩短总时间,Streaming 解决的是另一个问题:用户是否必须等所有数据完成后,才能看见任何内容。
loading.tsx 是路由段级边界在商品路由旁放置 loading.tsx,Next.js 会为这个路由段建立 Suspense 边界。
// app/products/[slug]/loading.tsx
export default function ProductLoading() {
return (
<main aria-busy="true" aria-label="正在加载商品">
<div className="h-8 w-64 animate-pulse rounded bg-neutral-200" />
<div className="mt-4 h-24 animate-pulse rounded bg-neutral-100" />
</main>
)
}客户端导航到该路由时,共享 layout 可以继续显示,加载 UI 先出现,页面完成后再替换。fallback 应尽量保持最终布局尺寸,告诉用户“哪块内容正在来”,而不是只放一个没有上下文的旋转图标。
同一路由段的 loading.tsx 主要包住 page.tsx 及其下方内容。若 layout 自己先等待未缓存数据,它可能在 fallback 出现前阻塞导航。把慢读取下移到 page 或给它建立更近的 Suspense 边界。
商品标题很快,评价很慢,就不要让评价决定整页何时可见。
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params
const product = await getProduct(slug)
return (
<main>
<h1>{product.name}</h1>
<
服务器可以先发送边界外的内容和 fallback,评价准备好后再发送后续块。用户更早得到有用页面,慢接口也没有被伪装成“已经更快”。
每个小组件都套 Suspense 会让页面闪烁、骨架碎片化,也增加设计负担。适合独立边界的区域通常满足至少一项:
商品价格和购买按钮若必须一致,就不应拆成两个独立到达的边界;评价和相关推荐则通常可以稍后出现。
fallback 只表示 Promise 尚未完成。完成后返回空数组,需要真正的“暂无评价”;Promise 拒绝,需要最近的错误边界;商品不存在,需要 notFound()。把三种状态分开,用户才能知道应该等待、重试还是返回。

下面的边界设计器允许你决定商品页哪些区域一起等待、哪些区域独立流式返回。注意首屏时间之外,还要观察布局稳定性和错误影响范围。
Streaming 改善的是感知等待与分块交付,不会减少慢查询本身的耗时。数据库仍要做索引,上游仍要设超时,昂贵的公共结果仍应评估缓存。
Server Component 是首屏数据的好起点,但不是“所有请求只能在服务器发出”。判断标准是数据在什么时候、由什么事件驱动变化。
文章正文、商品详情、价格说明和 SEO 需要的公开信息,通常应该随服务器渲染返回。这样可以少一次“下载 JS → 水合 → 发请求 → 再渲染”的链条,也不必在浏览器复制私有 API 凭据。
下面这些场景经常需要 SWR、TanStack Query 或项目已有的数据客户端:
一个常见组合是:Server Component 先给首屏数据,Client Component 用它作为初始值,后续再按用户事件增量读取。不要让服务端和客户端在首屏同时请求同一份数据,造成双请求和短暂不一致。
客户端需要访问受保护数据时,应该调用经过鉴权的 Route Handler 或 Server Action,而不是把数据库连接串、内部 token 或私有环境变量改名成 NEXT_PUBLIC_*。
NEXT_PUBLIC_ 的含义就是允许进入客户端构建结果。它不是“在浏览器中仍然保密”的环境变量前缀。
use() 可以流式解析服务器 PromiseReact 允许服务器把 Promise 交给 Client Component,再由客户端组件用 use() 在 Suspense 下解析。这适合确实需要在客户端组件中消费流式数据的结构,但会增加边界复杂度。
对普通商品列表,不要为了展示新 API 而采用它。服务器能直接渲染成列表时,直接渲染更容易维护;需要客户端 Context 多处共享同一个 Promise 时,再评估这条路径。
缓存策略如果没有失效路径,只完成了一半。运营人员修改价格后,我们需要决定:其他访客能否短暂看到旧值,修改者本人是否必须立刻看到新值。
revalidateTag 适合允许短暂旧值的内容CMS webhook 或 Route Handler 可以在验证来源后调用:
// app/api/revalidate-products/route.ts
import { revalidateTag } from 'next/cache'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return NextResponse.json({ ok: false }, { status:
第二个参数 'max' 表示 stale-while-revalidate:带该标签的结果被标记为陈旧;下次有人访问相关资源时,可以先拿旧值,同时在后台取得新值。它不会在 webhook 调用瞬间把所有相关页面主动抓取一遍。
单参数 revalidateTag(tag) 的立即过期旧签名已经弃用。新代码应明确选择 profile,或者在满足条件时使用 updateTag。
updateTag 适合“我刚改完就要看见”updateTag 只能在 Server Action 中调用。它立即让标签过期,下一次读取会等待新数据,而不是先返回旧值。
// app/admin/products/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { z } from 'zod'
import { requireEditor } from '@/app/data/auth'
import { db } from '@/lib/db'
const PriceInput = z.object({
productId: z.string().min(1),
priceInCents: z.coerce.number().int().nonnegative(),
})
这里在 Action 内再次认证授权,并把租户范围放进更新条件。Server Action 是可通过网络调用的服务端入口,不能因为按钮只在管理员页面出现就省略权限检查。
revalidatePath 按页面范围失效标签跟着数据走,一份商品数据被首页、搜索页和详情页共用时很方便。revalidatePath('/products') 则按路径失效,适合“我明确知道哪条页面输出受影响”的情况。复杂更新有时需要标签与路径一起使用,但不要无差别失效整个站点。
refresh 只刷新客户端路由Next.js 16 的 refresh() 只能在 Server Action 中调用。它要求客户端刷新当前路由并合并新的 RSC Payload,但不会自动让服务器持久缓存失效。
如果页面读取的是已缓存商品,单独 refresh() 可能再次拿到缓存旧值。先按业务调用 updateTag 或 revalidateTag,再决定是否需要刷新当前路由。
Client Component 中的 router.refresh() 也只重新请求并合并当前路由,不等于服务器缓存清除按钮。

数据获取代码在演示里只有几行,生产故障往往出在没写出来的部分:上游超时、权限错位、缓存键污染和开发环境行为误判。
页面隐藏某个按钮只是界面逻辑。真正读取订单或修改价格的 DAL、Server Action 和 Route Handler 都要验证身份与对象级权限。
一个可靠的数据函数通常按这个顺序工作:
先解析并校验来自 URL、表单或请求体的输入,不把动态路由参数当成可信字符串直接拼进查询。
再确认当前会话,并检查用户是否有权访问这个具体对象或租户,而不只检查“已经登录”。
使用参数化查询或 ORM 条件读取最少字段,并把结果转换为明确 DTO。
最后才把 DTO 交给 React 渲染;传给 Client Component 的一切都按浏览器可见数据处理。
地区、语言或货币会改变公开价格时,它们必须成为缓存键的一部分;否则北京用户可能命中上海地区结果。反过来,不应把随机对象或每次新建的复杂参数传给 memoized 函数,那会降低命中率并让行为难以推理。
普通 use cache 会根据函数标识、可序列化参数和闭包捕获值生成键。React cache 则按参数身份比较;对象参数每次重建可能视为不同输入。优先传稳定的字符串和数字,例如 slug、locale。
Node.js 的 fetch 支持 AbortSignal.timeout()。具体超时值应来自服务等级,而不是照抄一个数字。
export async function getInventory(productId: string) {
const response = await fetch(
`https://inventory.internal/products/${encodeURIComponent(productId)}`,
{
cache: 'no-store',
signal: AbortSignal.timeout(2_000),
},
)
if (!response.ok) {
throw new Error
超时之后显示“库存暂不可用”还是让整页失败,取决于购买流程是否允许在未知库存下继续。错误边界应该匹配这个业务范围。
Next.js 开发模式会为 Server Component 的 fetch 提供 HMR 缓存,以减少热更新时的重复 API 成本。即使写了 no-store,只改代码触发 HMR 时也可能暂时看到旧响应;导航或完整刷新会清理相应状态。
反过来,浏览器硬刷新经常携带 cache-control: no-cache,开发环境可能绕过 fetch 的缓存选项直接读取源站。判断生产缓存前,不要只凭一次 DevTools 操作下结论。
建议至少观察:数据源名称、缓存命中或未命中、请求耗时、状态码、重试次数、路由和不含隐私的关联 ID。不要把访问令牌、完整 Cookie、用户私密字段或原始数据库行写进日志。
日志可以回答“慢在哪里”,指标可以回答“多久发生一次”。把上游耗时、缓存命中率和流式首块时间分开,才知道该优化查询、缓存还是 Suspense 边界。
现在把前面的判断组合起来。目标是:商品主体可缓存,库存每次请求读取,评价允许晚到,重复商品查询在单次渲染内去重。
在当前项目未开启 Cache Components 的前提下,可以这样组织:
// app/data/products.ts
import 'server-only'
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
const getCachedPublicProduct = unstable_cache(
async (slug: string) =>
db.product.findFirst({
where: { slug, published: true },
select: {
id: true,
这里的 unstable_cache 跨请求复用公开商品,外层 React cache 让同一次渲染中的 metadata 和页面共享结果。库存没有公共持久缓存。
动态标签如果需要精确到商品,可以把函数按项目所用旧模型重新设计,使 product-${id} 在数据读取时建立;不要只调用一个从未关联到任何缓存项的标签,然后期待它自动找到数据。
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getProduct } from '@/app/data/products'
import { InventoryPanel } from './inventory-panel'
import { Reviews } from './reviews'
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params
// app/products/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const product = await getProduct(slug)
if (!product) return { title: '商品不存在' }
generateMetadata 和页面 import 同一个 getProduct,本次渲染可以共享查询。缓存解决的是重复读取,页面仍要对不存在的商品分别给出合理 metadata 与 notFound()。
迁移时,公开商品函数可以改成:
import { cacheLife, cacheTag } from 'next/cache'
export async function getProduct(slug: string) {
'use cache'
cacheLife('minutes')
cacheTag('products', `product-${slug}`)
return db.product.findFirst({
where: { slug, published: true },
select: {
id: true,
slug: true
实时库存继续留在缓存范围外,并放在 Suspense 中。迁移的重点不是逐字替换 API,而是保留原来的业务语义:哪些结果公共可共享、允许旧多久、由哪个事件失效。
写完页面后,可以按下面顺序检查。它比记住一堆 API 名字更可靠。
fetch 只是本次渲染去重,还是明确进入了持久缓存?cache 函数?await 串行阻塞?updateTag?refresh 是否被误当成服务器缓存失效?response.ok,关键请求是否有超时?最后留一个实践题:把你项目里最慢的一张页面画成“数据源—读取函数—组件—缓存—失效事件”的链条。逐项标出用户相关性、允许陈旧时间和等待关系。只要这张图说得清,代码里的 fetch、cache、Suspense 和失效 API 就不会再像一组互相冲突的魔法开关。