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-store fetch 就能让整个页面变为动态,失去其他 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。掌握这三个要点,你将很少再陷入“为什么数据拒不更新”的困境。

nextjsreactcachingapp-routerserver-components