Next.js (App Router) 中的数据获取和缓存
1. 概述
Next.js App Router 会自动缓存 fetch 的结果到服务器上,以减少后端负载并加快页面速度。但这种缓存机制与你熟悉的常规 HTTP 缓存完全不同,如果理解不透彻,你很容易遇到“旧数据拒不更新”或反过来“每个页面都重新渲染,慢得离谱”的情况。
本文介绍了 Next.js 的各个缓存层:fetch 缓存、按时间重新验证、按标签重新验证和动态渲染。读完后,你将知道如何为每种类型的页面(静态、ISR、动态)选择正确的 fetch 策略,以及在缓存“执拗不听”时如何调试。
注意: 本文适用于 App Router(Next.js 13+,推荐使用 14/15)。Pages Router 使用不同的机制(
getStaticProps、getServerSideProps)不在本文范围内。
2. Next.js 的四层缓存
Next.js 有 4 种不同的缓存机制,很容易混淆:
| 机制 | 位置 | 目的 |
|---|---|---|
| Request Memoization | 服务器,单次 render 内 | 在同一请求中合并相同的 fetch 调用 |
| Data Cache | 服务器,跨多个请求 | 长期缓存 fetch 结果,可重新验证 |
| Full Route Cache | 服务器,构建时 | 缓存静态路由的 HTML + RSC payload |
| Router Cache | 客户端,会话期间 | 缓存导航结果以加快返回/前进 |
本文重点介绍 Data Cache,因为这是你通过 fetch 选项直接控制的部分。
3. 默认 fetch:无限期缓存
在 Server Component 中,Next.js 会 patch fetch 使其默认自动缓存:
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.shop.example.com/products");
if (!res.ok) throw new Error("无法获取产品 API");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: string; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
fetch不传递cache→ Next.js 默认使用force-cache。- 结果被保存到 Data Cache,供所有后续请求共享,不会自动过期,除非你手动重新验证。
- 此页面被纳入 Full Route Cache:在构建时生成静态 HTML,作为静态页面提供。
这就是为什么很多人感到惊讶:修改了后端数据但页面没有变化——因为 Next.js 将其视为静态数据。
4. 使用 cache 选项控制缓存
要始终获取新数据,使用 no-store:
async function getOrderStatus(orderId: string) {
const res = await fetch(
`https://api.shop.example.com/orders/${orderId}`,
{ cache: "no-store" }
);
return res.json();
}
cache: "no-store"完全关闭此请求的 Data Cache。- 包含此 fetch 的路由将被标记为动态,每个请求都重新 render。
- 适合变化频繁的数据:正在处理的订单、实时价格、仪表板。
注意: 只要在一个页面中有一个
fetch使用no-store,整个页面就会转为动态渲染——即使同页面中的其他fetch仍使用force-cache。
5. 按时间重新验证(ISR)
要缓存但在 N 秒后自动刷新(增量静态再生),使用 next.revalidate:
async function getCategories() {
const res = await fetch(
"https://api.shop.example.com/categories",
{ next: { revalidate: 300 } } // 5 分钟
);
return res.json();
}
revalidate: 300表示:数据在 300 秒后被认为“过时”。- 300 秒后的第一个请求仍返回旧数据(stale-while-revalidate),但 Next.js 在后台触发新 fetch。
- 下一个请求才会收到新数据。
可以通过在文件顶部导出变量将其应用于整个路由:
// app/blog/[slug]/page.tsx
export const revalidate = 300;
export default async function BlogPost({ params }: { params: { slug: string } }) {
const res = await fetch(`https://api.blog.example.com/posts/${params.slug}`);
return <article>{/* ... */}</article>;
}
export const revalidate应用于路由中的所有 fetch,如果该 fetch 没有声明自己的revalidate。- 如果某个特定的 fetch 声明了不同的
next.revalidate,该值优先用于该 fetch。
6. 按标签重新验证:revalidateTag
基于时间的重新验证适合定期变化的内容,但当你想在数据立即变化时刷新(例如管理员刚更新产品价格)时就不适用了。此时使用缓存标签。
// app/products/[id]/page.tsx
async function getProduct(id: string) {
const res = await fetch(
`https://api.shop.example.com/products/${id}`,
{ next: { tags: [`product-${id}`] } }
);
return res.json();
}
当管理员更新产品时,在 Route Handler 或 Server Action 中调用 revalidateTag:
// app/actions/update-product.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string, formData: FormData) {
await fetch(`https://api.shop.example.com/products/${id}`, {
method: "PATCH",
body: formData,
});
revalidateTag(`product-${id}`); // 删除带此标签的缓存
}
tags为 fetch 的缓存条目附加一个“标签”。revalidateTag(tag)删除所有带该标签的 Data Cache 条目,无论哪个路由在使用。- 下次访问时将重新 fetch 数据并按新标签缓存。
这是最强大的机制,因为它将“何时失效”的逻辑与“哪个页面显示数据”分离——类似于 Spring Cache 或 Redis 中的缓存标签。
7. 按路径重新验证:revalidatePath
如果不使用标签,你可以直接按路由路径重新验证:
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost(slug: string) {
// ... 调用 API 发布 ...
revalidatePath(`/blog/${slug}`); // 仅此页面
revalidatePath("/blog", "page"); // 或整个列表
}
revalidatePath("/blog/[slug]")删除该特定路由的 Full Route Cache + Data Cache。- 第二个参数(
"page"或"layout")决定是否同时重新验证使用该布局的子路由。 - 与
revalidateTag不同:revalidatePath绑定到特定路由,而标签可用于多个不同的路由。
注意:
revalidateTag/revalidatePath只能在 Server Action 或 Route Handler 中运行,不能从 Client Component 直接调用。
8. 动态渲染:当缓存不再有意义时
某些 API 会导致 Next.js 自动将路由转换为动态,绕过 Full Route Cache:
import { cookies, headers } from "next/headers";
export default async function AccountPage() {
const sessionToken = (await cookies()).get("session")?.value;
const userAgent = (await headers()).get("user-agent");
const res = await fetch("https://api.shop.example.com/me", {
headers: { Authorization: `Bearer ${sessionToken}` },
cache: "no-store",
});
return <div>你好,{(await res.json()).name}</div>;
}
- 调用
cookies()或headers()自动将路由标记为动态——这是合理的,因为数据取决于特定用户。 - 结合
cache: "no-store"确保数据始终根据会话保持最新。
显式强制动态(例如当你确定无法缓存时):
export const dynamic = "force-dynamic";
9. Route Handlers 也有自己的缓存
Route Handler(app/api/.../route.ts)中的 GET 方法也遵循与 Server Component 相同的缓存规则:
// app/api/stock/route.ts
export const revalidate = 60;
export async function GET() {
const res = await fetch("https://api.shop.example.com/stock");
const data = await res.json();
return Response.json(data);
}
- 如果
GET不使用Request对象(不读取request.url以获取查询),则可能被静态缓存。 - 如果 handler 使用
request.headers、request.cookies或非GET的方法(POST、PUT等)→ 始终动态,不缓存。
10. Client Component:缓存属于其他库
Next.js 的 fetch 缓存仅适用于服务器。在 Client Component 中,你需要使用 useEffect 自己 fetch 或使用专有缓存库如 SWR:
npm install swr
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function CartBadge({ userId }: { userId: string }) {
const { data, isLoading } = useSWR(`/api/cart/${userId}`, fetcher, {
refreshInterval: 10_000, // 每 10 秒轮询一次
});
if (isLoading) return <span>...</span>;
return <span>{data.itemCount} 件商品</span>;
}
- SWR 有自己的缓存、请求去重、focus 时重新验证——完全独立于服务器的 Data Cache。
- 当你需要 client 上频繁更新的数据而不想重新加载整个页面时使用。
11. 为缓存相关逻辑编写测试
Next.js 的缓存机制本身很难进行单元测试,但生成标签/路径的逻辑通常值得单独测试——以避免标签拼写错误导致 revalidateTag 不匹配的问题。
// lib/cache-keys.ts
export function productTag(id: string): string {
return `product-${id}`;
}
export function categoryTag(id: string): string {
return `category-${id}`;
}
// lib/cache-keys.test.ts
import { describe, it, expect } from "vitest";
import { productTag, categoryTag } from "./cache-keys";
describe("cache-keys", () => {
it("生成正确格式的产品标签", () => {
expect(productTag("sku-123")).toBe("product-sku-123");
});
it("产品和分类标签不会相互冲突", () => {
const id = "42";
expect(productTag(id)).not.toBe(categoryTag(id));
});
});
- 将标签生成函数提取到独立模块有助于确保调用
fetch(附加标签)和调用revalidateTag(删除标签)的地方始终使用相同的格式。 - 这是一个真实且常见的错误:一方写
product-${id},另一方写products-${id}→ revalidate 永远不会命中。
✓ cache-keys > 生成正确格式的产品标签
✓ cache-keys > 产品和分类标签不会相互冲突
12. 常见错误
- 忘记 fetch 默认无限期缓存:部署后数据不变因为仍在使用来自前次构建的旧缓存(静态导出)或首次 fetch(服务器部署)。修复方法:使用
revalidate或no-store。 - 不必要地在同一组件中混合
no-store和缓存:只需 1 个no-storefetch 就能让整个页面变为动态,失去其他 fetch 的缓存好处。 - 在 Server Action/Route Handler 外调用
revalidateTag/revalidatePath:无效,Next.js 会报错或根据版本默认忽略。 - 在第三方库中使用普通
fetch(例如 SDK 使用纯axios或undici):不受 Data Cache 管理,你必须用其他方式重新验证(例如在不同层缓存或编写包装器调用标准fetch)。 - 不按具体实体标记:使用过于通用的标签(
"products"表示所有产品)导致一个小更新使整个缓存失效,失去按项缓存的好处。
何时不应缓存: 按会话个性化的数据(购物车、余额、通知)、实时数据(股票价格、支付状态)或任何依赖
cookies()/headers()的响应。这些情况应从一开始就使用no-store或force-dynamic,不要试图强制缓存然后不断重新验证——既复杂又不符合数据的本质。
13. 总结
Next.js App Router 中的缓存包含多个层次,但你主动控制最多的是通过 fetch 选项的 Data Cache:cache、next.revalidate、next.tags。快速选择规则:数据很少变化 → 按时间 revalidate;数据因特定事件变化(管理员更新)→ 附加 tags 并使用 revalidateTag;用户相关或实时数据 → no-store/force-dynamic。掌握这三个要点,你将很少再陷入“为什么数据拒不更新”的困境。