数据获取与缓存策略
面试题:Next.js 中 fetch 的缓存机制是什么?revalidatePath 和 revalidateTag 区别?React.cache 解决什么问题?
一、Next.js 15 的 fetch 扩展
Next.js 15 扩展了原生 fetch,增加了缓存控制能力。所有 Server Component 中的 fetch 行为都受 Next.js 缓存层管理。
1.1 四种缓存策略
// ① 默认:永久缓存(force-cache)
const res = await fetch("https://api.example.com/posts");
// 结果会缓存到 .next/cache,下次 build/ISR 复用
// 适合:构建期数据、永久不变的数据
// ② 不缓存(no-store)
const res = await fetch("https://api.example.com/me", {
cache: "no-store",
});
// 每次请求都重新拉
// 适合:用户个性化数据、敏感数据
// ③ 时间窗口(revalidate)
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
// 60 秒内复用缓存,过期后台重新拉
// 适合:ISR 场景
// ④ 标签失效(tag-based)
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
// 主动调用 revalidateTag("posts") 时才失效
// 适合:内容更新可控的场景
1.2 与原生 fetch 的区别
面试题:Next.js 的 fetch 和浏览器 fetch 有什么区别?
| 维度 | 浏览器 fetch | Next.js fetch |
|---|---|---|
| 运行环境 | 浏览器 | 服务端(Node / Edge) |
| 缓存位置 | 浏览器 HTTP 缓存 | 服务端持久缓存(.next/cache) |
| 跨请求复用 | HTTP 304 / max-age | 直接复用,不发请求 |
| 额外配置 | 无 | { next: { revalidate, tags } } |
关键:Next.js 看到 Server Component 里调 fetch 时,会拦截到自己的缓存层,不会真的发到网络(如果命中缓存)。
二、四种渲染模式与 fetch 的关系
// app/posts/page.tsx
// 情况 1:默认行为 = 静态生成(SSG)
const res = await fetch("/api/posts");
// → build 时拉一次,缓存到 .next/cache
// 情况 2:no-store = 动态渲染(SSR)
const res = await fetch("/api/posts", { cache: "no-store" });
// → 每次请求都拉
// 情况 3:revalidate = ISR
const res = await fetch("/api/posts", { next: { revalidate: 60 } });
// → build 时拉一次,60s 后过期重新拉
// 情况 4:tags = 按需失效
const res = await fetch("/api/posts", { next: { tags: ["posts"] } });
// → 缓存永久生效,revalidateTag("posts") 才失效
Next.js 的渲染模式 = fetch 缓存策略的封装。记住一个,能推四个。
三、动态 API 触发动态渲染
// app/page.tsx
import { cookies, headers } from "next/headers";
export default async function Page() {
const cookieStore = await cookies(); // 15+ 异步
const token = cookieStore.get("token");
// 只要用了 cookies() / headers() 这类动态 API
// Next.js 自动把整个页面切换到「动态渲染」
// 即使你 fetch 不加 cache: "no-store"
return <div>{token?.value}</div>;
}
面试题:什么是「动态 API」?
cookies()/headers()/searchParams/draftMode()。调用任何一个,Next.js 自动放弃缓存,整个路由变动态渲染。
四、revalidatePath vs revalidateTag
4.1 revalidatePath
按路径失效缓存:
// app/actions/post.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
await db.post.create({ data: { ... } });
revalidatePath("/posts"); // 失效 /posts 路由缓存
revalidatePath("/posts/[id]", "page"); // 失效动态路由
revalidatePath("/", "layout"); // 失效所有页面
}
场景:明确知道改了哪个路径。
4.2 revalidateTag
按标签失效缓存:
// app/api/posts/route.ts
export async function GET() {
const data = await fetch("https://cms.example.com/posts", {
next: { tags: ["posts"] }, // 打标签
});
return Response.json(await data.json());
}
// app/actions/post.ts
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
await db.post.create({ data: { ... } });
revalidateTag("posts"); // 失效所有带 posts 标签的 fetch
}
场景:一个数据被多个路由使用,希望一改全更新。
4.3 对比
| 维度 | revalidatePath | revalidateTag |
|---|---|---|
| 粒度 | 路径级 | 标签级(可跨路径) |
| 适用 | 单页面更新 | 共享数据多页面更新 |
| 配置 | 简单(直接传路径) | 需提前给 fetch 打标签 |
| 影响 | 单路由 | 所有打该标签的 fetch |
面试题:什么场景用 Tag? 多页面共享同一份数据(如「文章列表」数据被
/posts、/tags/[tag]、/authors/[id]都用到),改一篇文章要让所有相关页面都更新,用 tag 一行解决。
五、unstable_cache(数据级缓存)
如果数据不是通过 fetch 拿的(如数据库直查、Redis 调用),用 unstable_cache:
import { unstable_cache } from "next/cache";
const getCachedPosts = unstable_cache(
async () => {
return await db.post.findMany(); // 同步数据源
},
["posts-key"], // 缓存 key
{
revalidate: 60, // 60s 过期
tags: ["posts"], // 标签
},
);
// 用法
export default async function Page() {
const posts = await getCachedPosts();
return <PostList posts={posts} />;
}
注意:
unstable_前缀表示 API 还在变,但 15+ 已经是稳定形态,生产可用。
六、React.cache(请求级去重)
6.1 问题场景
// lib/user.ts
export async function getUser(id: string) {
return await db.user.findUnique({ where: { id } });
}
// app/dashboard/page.tsx
export default async function Page() {
const user = await getUser("123"); // 第 1 次 DB 查询
return <UserProfile user={user} />;
}
// app/dashboard/layout.tsx
export default async function Layout({ children }) {
const user = await getUser("123"); // 第 2 次 DB 查询(重复!)
return <Shell user={user}>{children}</Shell>;
}
// UserProfile 组件
async function UserProfile({ user }) {
const orders = await getOrders(user.id); // 第 3 次
return <div>...</div>;
}
问题:同一个 getUser("123") 在一次请求中被调了 N 次,每次都打 DB。
6.2 解决
// lib/user.ts
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } });
});
效果:
- 同一个请求内
getUser("123")只调一次 - 不同请求间不共享缓存(每个请求独立)
- 不需要传 key(自动用参数)
6.3 进阶:与 unstable_cache 配合
import { cache } from "react";
import { unstable_cache } from "next/cache";
export const getUser = cache(
unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
["user"],
{ revalidate: 3600, tags: ["user"] },
),
);
两层缓存:
| 层 | 粒度 | 失效 |
|---|---|---|
unstable_cache | 跨请求 | 时间 / 标签 |
cache | 单次请求 | 请求结束自动清理 |
性能:未命中第一层 → 第二层;都未命中 → 真打 DB。
七、客户端数据获取(TanStack Query)
虽然 Server Component 解决了很多问题,但客户端实时数据仍需要数据获取库:
"use client";
import { useQuery } from "@tanstack/react-query";
export function UserList() {
const { data, isLoading } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(r => r.json()),
staleTime: 30_000, // 30s 内不发请求
});
if (isLoading) return <Skeleton />;
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
适用场景:
- 实时刷新的数据(如聊天、股票)
- 客户端状态依赖服务端数据
- 频繁交互(搜索、分页、过滤)
八、面试高频追问
Q1:revalidate 和 ISR 什么关系?
A:ISR = revalidate 时间窗口缓存。配置 revalidate = 60 就等同于 60s 的 ISR。
Q2:什么时候用 unstable_cache,什么时候用 React.cache?
A:
unstable_cache:跨请求复用(持久化)React.cache:单次请求内去重(临时)- 两者可叠加(外层 unstable_cache 持久化,内层 cache 去重)
Q3:revalidateTag 失效的颗粒度是什么?
A:打到该 tag 的所有 fetch 缓存。粒度由打 tag 时决定,不区分页面、不区分路径。
Q4:Server Action 里能 revalidate 吗?
A:可以,且最常见的用法就是 Server Action 改完数据后调 revalidatePath / revalidateTag,让 Client 自动看到新数据。
Q5:缓存命中率怎么监控?
A:Next.js 16+ 提供 cacheLife 和 cacheTag 工具,配合 Vercel Observability / 自建 Prometheus。
九、决策流程图
需要数据?
├─ 是
│ ├─ 在 Server Component?
│ │ ├─ 是
│ │ │ ├─ 数据用 fetch 拿? → fetch + cache 配置
│ │ │ └─ 数据从 DB 拿? → unstable_cache + cache
│ │ └─ 否(Client Component)→ TanStack Query
│ └─ 用户实时交互(搜索/分页) → Client fetch + 缓存
└─ 否(纯静态)→ 直接写