Next.js App Router 数据获取:fetch 缓存、revalidate 与 dynamic 一次讲清
从 Pages Router 迁到 App Router 的人,几乎都被同一件事绊倒过:「我明明改了数据库,页面刷新了半天还是旧数据」,或者反过来「本地跑得好好的,一部署到线上数据就不更新了」。根源都在同一处 ------ App Router 把 fetch 悄悄改造了,默认帮你做缓存,而你没意识到。
这篇把 App Router 里数据到底什么时候缓存、什么时候刷新、怎么手动控制讲透,给出能直接照抄的配置。
第一个反直觉:fetch 默认被缓存了
在 App Router 的服务端组件里,Next.js 用自己的实现包了原生 fetch。看这段代码:
tsx
// app/products/page.tsx ------ 默认就是服务端组件
export default async function ProductsPage() {
// 注意:这个请求结果默认会被永久缓存
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<ul>
{products.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
在生产构建下,这个 fetch 的结果默认等价于 cache: 'force-cache' ------ 结果会被缓存,后续请求直接命中缓存,哪怕后端数据已经变了页面也不更新。这就是「改了数据库页面还是旧的」的元凶。
提示:Next.js 14 里未配置的
fetch默认强缓存;从 Next.js 15 起默认改成了不缓存(no-store)。但依赖「默认值」是最坑的写法 ------ 版本一升行为就反转。所以别赌默认值,永远显式声明缓存策略。
三种缓存策略,对号入座
fetch 的第二个参数直接决定缓存行为,一共就三种,记住场景就不会错:
tsx
// 1) 永久缓存:适合几乎不变的数据,比如文档、配置、静态内容
await fetch(url, { cache: "force-cache" });
// 2) 完全不缓存:每次请求都打后端,适合购物车、实时价格、用户私有数据
await fetch(url, { cache: "no-store" });
// 3) 定时重新验证:缓存 60 秒,过期后台自动刷新,适合博客/商品列表这种"可以旧一会儿"的
await fetch(url, { next: { revalidate: 60 } });
选择标准很简单:
- 数据几乎不变 →
force-cache; - 数据必须实时、每次都要最新 →
no-store; - 数据允许有几十秒延迟,但要兼顾性能 →
next: { revalidate: N }。
第三种是最常用的,它用的是 stale-while-revalidate 策略:60 秒内直接给缓存(快),过期后第一个访问的人拿到的还是旧数据,但后台会悄悄拉新数据换掉缓存,下一个人就看到新的了。既快又不会太旧。
revalidate 也可以写在页面级别
如果一个页面里有多个 fetch,或者数据来自数据库(不走 fetch),用页面级的 export const revalidate 更省事。它作用于整个路由段:
tsx
// app/blog/page.tsx
// 整个页面每 300 秒重新生成一次
export const revalidate = 300;
export default async function BlogPage() {
const posts = await db.post.findMany(); // 直接查库,不是 fetch
return <PostList posts={posts} />;
}
revalidate = 300 表示这个页面走 ISR(增量静态再生):构建时生成一次 HTML,之后每 300 秒最多重新生成一次。适合内容更新不频繁的博客、落地页。
dynamic:强制整页动态渲染
有时你想让整个页面每次请求都实时渲染(比如后台管理页、依赖登录态的页面),不管里面 fetch 怎么配。用 export const dynamic:
tsx
// app/dashboard/page.tsx
// force-dynamic:每次请求都在服务端实时渲染,禁用一切缓存
export const dynamic = "force-dynamic";
export default async function Dashboard() {
const stats = await fetch("https://api.example.com/stats", {
cache: "no-store",
}).then((r) => r.json());
return <StatsPanel data={stats} />;
}
dynamic 有几个常用值:
'auto'(默认):Next.js 自己判断能不能静态化;'force-dynamic':强制每次请求动态渲染,等于给整页所有 fetch 兜底加no-store;'force-static':强制静态,即使用了no-store也会被当成缓存。
另外一个隐藏规则要记住:只要你在组件里用了 cookies()、headers()、searchParams 这些运行时才知道的东西,整个路由会自动变成动态渲染 ,不需要手动写 force-dynamic。这也是很多人「以为是静态页,结果每次都在服务端跑」的原因。
按需刷新:数据变了立刻更新,不用等 revalidate
定时刷新有个缺点:用户在后台改了商品,前台要等 60 秒才生效。更好的做法是 ------ 改完数据后主动通知 Next.js「这个缓存失效了」。用 revalidatePath 或 revalidateTag:
tsx
// app/actions.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: FormData) {
await db.product.update({ where: { id }, data: { /* ... */ } });
// 方式一:让某个路径的缓存立即失效,下次访问重新生成
revalidatePath("/products");
// 方式二:让打了特定 tag 的所有 fetch 失效(更精准)
revalidateTag("products");
}
配合 revalidateTag 使用时,在 fetch 上打标签:
tsx
// 给这个请求打上 'products' 标签
await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});
这样你可以设一个很长的 revalidate(甚至永久缓存),平时享受满速缓存,只在真正改数据时用 revalidateTag('products') 精准击穿相关缓存。这是生产环境最推荐的组合:默认缓存 + 写操作后按需失效。
小结
- App Router 里的
fetch默认带缓存(且默认值随版本变),别赌默认值,永远显式写缓存策略。 - 三选一:不变用
force-cache,实时用no-store,能容忍延迟用next: { revalidate: N }(stale-while-revalidate,又快又够新)。 - 页面级用
export const revalidate控制 ISR;用cookies()/headers()会自动转动态渲染;export const dynamic = 'force-dynamic'强制整页实时。 - 最佳实践:长缓存 +
revalidateTag/revalidatePath按需失效,改完数据立刻生效,平时全走缓存。
一句话记忆:缓存策略要么写在 fetch 上,要么写在页面 export 上,改完数据用 revalidate 系列主动通知失效。