: Next.js 面试 · 数据获取与缓存

第 4 章 · 数据获取与缓存:fetch 策略、四层缓存、revalidate、静态 vs 动态

本章主线一句话:Next 把"数据"和"页面"各包了一层缓存,再让它们联动失效——理解"哪一层缓存了什么、谁能让谁失效",就能解释一个页面到底是静态还是动态、数据多久刷新一次、改数据后为什么页面没变。


4.0 为什么需要这一章(动机)

第 1 章讲了"页面是静态还是动态",第 3 章讲了"服务端组件怎么直接 await 取数"。但有个问题一直没回答透:

服务端组件每次请求都 await fetch() 吗?那缓存还有什么意义?反过来,缓存了为什么数据又"不新鲜"?

答案藏在 Next 的四层缓存里。先记住一句话锚点:

Next 15 起 fetch 默认不缓存——这是理解本章一切的前提。14 及以前的"隐式缓存"被取消了,现在你必须显式告诉 Next 什么时候缓存、什么时候刷新。


4.1 先建立一个"纵向视图":一次请求穿过哪几层缓存

一次服务端渲染请求,数据会经过下面四层(面试直接背这张表):

缓存什么在哪生命周期作用
请求记忆化 Request Memoization同一个请求内相同 fetch 的返回值服务器单次请求避免一次渲染里重复发请求
数据缓存 Data Cachefetch / unstable_cache 的结果服务器跨请求、跨部署持久化少打后端/数据库
完整路由缓存 Full Route Cache静态页的 HTML + RSC Payload服务器跨请求,部署时清空少做整页渲染
路由器缓存 Router Cache各段的 RSC Payload客户端内存会话内导航时少请求服务器

记忆锚点:前两层是"数据"的缓存,后两层是"页面/路由"的缓存。数据缓存失效会"级联"让完整路由缓存失效——因为页面渲染结果依赖数据。


4.2 第一层:请求记忆化(Request Memoization)

是什么

React 的特性(不是 Next 独有):同一个渲染 pass 里,URL 和选项完全相同的 fetch 只真正执行一次,结果在组件树里共享。

// 同一个请求里,Page 和 UserCard 各自 fetch 同一个 URL
// → 网络层只发一次请求,两个组件拿到同一份结果
async function Page() {
  const user = await fetch("/api/user").then(r => r.json()); // 真正请求
  return <UserCard />; // UserCard 里也 fetch 同一个 URL,命中记忆化,不再请求
}

关键边界(面试常考)

  • 只对 GET 有效:POST / DELETE 不被记忆化。
  • 只在一个请求内生效:渲染完成后内存就重置,下一个请求重新来。它不跨请求共享(那是下一层数据缓存的事)。
  • 只在 React 组件树里生效generateMetadatagenerateStaticParams、layout、page、Server Component 里都算;但 Route Handler(route.ts)里的 fetch 不享受

一句话:请求记忆化解决"同一次渲染重复请求"的问题,不解决"跨请求少打后端"的问题。


4.3 第二层:数据缓存(Data Cache)—— 本章核心

是什么

Next 内置的持久化数据缓存,把 fetch 的结果在服务端存下来,跨请求、跨部署复用,直到被重新验证或主动退出。

Next 15/16 的关键变化:fetch 默认不缓存

这是最容易答错、也最该主动讲出来的点:

版本fetch 默认行为
Next 14 及以前默认 force-cache(隐式缓存)
Next 15 起默认不缓存,需显式声明

不加任何选项的 fetch,在 Next 15/16 里:

  • 动态渲染时:每次请求都重新请求数据(返回新数据)
  • 静态渲染时:数据进数据缓存、渲染结果进完整路由缓存
// 显式"加入缓存"(相当于老版本的默认行为)
fetch(url, { cache: "force-cache" })

// 显式"退出缓存"(每次请求都拿新数据)
fetch(url, { cache: "no-store" })

// 基于时间的重新验证:最多缓存 3600 秒,过期后下次请求后台刷新
fetch(url, { next: { revalidate: 3600 } })

// 打标签:配合 revalidateTag 按需失效
fetch(url, { next: { tags: ["posts"] } })

为什么这么改?(承接第 1 章 1.9 的伏笔)为了避免"服务端组件用了 fetch 却没拿到新数据"的隐式缓存陷阱,把"是否缓存"的决策显式化、交给开发者。

失效方式(面试重点)

  1. 基于时间的重新验证(Time-based)next: { revalidate: N },SWR 思路——过期后首次请求先返回旧值、后台刷新。
  2. 按需重新验证(On-demand)
    • revalidateTag("posts"):按标签清掉一批数据缓存
    • revalidatePath("/blog"):按路径清掉对应路由的数据缓存 + 完整路由缓存

注意:revalidateTag / revalidatePath 必须在 Server Action 或 Route Handler 里调用(服务端上下文),客户端不能直接调。

非 fetch 的数据怎么缓存:unstable_cache

不是所有数据都来自 fetch(比如直接查数据库、调 SDK)。这时用 unstable_cache 手动包一层:

import { unstable_cache } from "next/cache";

const getPosts = unstable_cache(
  async () => db.post.findMany(),
  ["posts"],        // 缓存 key
  { revalidate: 60, tags: ["posts"] }  // 可选:时间 + 标签
);

4.4 第三层:完整路由缓存(Full Route Cache)

是什么

Next 在构建时把静态路由渲染成 HTML + RSC Payload 存下来。请求来时直接返回缓存,不再跑一遍组件。

关键点

  • 只有静态渲染的路由会被缓存;动态渲染的路由每次请求才渲染,不缓存。
  • 部署时被清空(重新构建 = 重新生成)。
  • 退出方式(和"页面变动态"是同一件事,呼应第 1 章 1.6):
    • 用了动态 API:cookies()headers()searchParams
    • fetch(..., { cache: "no-store" })
    • 段配置 export const dynamic = "force-dynamic"export const revalidate = 0

与数据缓存的联动(本章最常考)

重新验证/退出 数据缓存  →  级联使 完整路由缓存 失效
退出 完整路由缓存        →  不影响 数据缓存(可以"动态渲染 + 用缓存数据")

记忆锚点:数据缓存失效会拖垮页面缓存;反过来页面不缓存,不代表数据不缓存


4.5 第四层:路由器缓存(Router Cache,客户端)

是什么

客户端内存里存各路由段的 RSC Payload,按 layout / loading / page 分块。导航时优先命中,减少服务器往返。

Next 15 的关键变化:页面段默认不缓存

  • layout、loading 段:导航时被缓存并复用。
  • page 段:Next 15 起默认退出缓存(浏览器后退/前进时仍复用)。
  • 自动失效:默认预取静态页 5 分钟、动态页不缓存;prefetch={true}router.prefetch 则静态动态都 5 分钟。
  • 页面刷新时清空。

谁能让它失效

  • Server Action 里 revalidatePath / revalidateTag / cookies.set:同时清数据缓存 + 路由器缓存。
  • 客户端 router.refresh()只清路由器缓存,不动数据缓存和完整路由缓存。

常见误区(面试加分点):router.refresh() 不等于 revalidatePath。前者只在客户端重新拉一次当前路由的 RSC,不改数据缓存;后者才是真正让服务端数据/页面缓存失效。


4.6 一张"谁失效谁"总表(背下来)

API数据缓存完整路由缓存路由器缓存
fetch(默认/GET)不缓存(15+)
fetch(force-cache)缓存
fetch(next.revalidate)时间刷新时间刷新
revalidateTag / revalidatePath清除清除清除(Server Action 内)
router.refresh()不动不动清除
cookies(动态 API)退出(变动态)
const dynamic = "force-dynamic"退出
const revalidate = N时间刷新时间刷新
generateStaticParams缓存(SSG)

4.7 静态 vs 动态:缓存视角重新理解

第 1 章从"渲染模型"讲了静态/动态,这里从"缓存"再收一次口:

  • 静态渲染:构建时跑一次 → 数据进数据缓存、页面进完整路由缓存 → 之后请求直接命中缓存。
  • 动态渲染:每次请求跑一次 → 不缓存(除非显式 force-cache)。

决定一个页面是静态还是动态的,本质是"它依赖的输入是否在构建时可确定"

  • 依赖 cookies / searchParams / request → 每个请求输入都不同 → 只能动态。
  • 依赖 fetch 且默认不缓存 → 数据可能变化 → 动态。
  • 什么都不依赖(或依赖 generateStaticParams 枚举好的值)→ 静态。

一句话收束:缓存是"静态 vs 动态"的底层机制,静态 vs 动态是缓存对外呈现的结果。


4.8 代码实验

位置:src/app/lab/data/,入口 /lab/data。数据源用共享的内联 async 函数 _data.tsgetRawData()(返回带时间戳 + 随机数,模拟一次 IO),配合 unstable_cache / revalidate / tags 观察缓存命中/失效。

实验目录验证目标
不缓存(默认)fetch-default/force-dynamic + 裸取数)pnpm build 看标记为 ƒ,刷新时间变
数据缓存fetch-cache/unstable_cache 无 revalidate)构建期 ,时间戳冻结
时间重新验证revalidate-time/unstable_cache + revalidate: 10过期后首次请求后台刷新(SWR)
标签失效revalidate-tag/unstable_cache + tags + Server Action 里 revalidateTag点按钮后数据立刻更新
非 fetch 缓存unstable-cache/(对比裸函数 vs unstable_cache跨请求复用、按标签失效
静态 vs 动态static-vs-dynamic/force-dynamic,同数据对比裸取数与缓存取数)页面 ƒ;刷新时裸取数变、缓存取数不变

说明:为何不用 fetch 指向 /api/... 演示缓存选项?因为服务端组件里的 fetch 会在构建期预渲染时执行,而 build 阶段 localhost 上还没有服务,会 ECONNREFUSED 导致构建失败。改用内联 getRawData() + unstable_cache 更稳定,且 unstable_cache 是 Next 15+ 官方推荐的"非 fetch 数据缓存"方式,语义与 fetchcache 选项等价。

观察方法(沿用前三章):

  • pnpm dev:所有页面每次请求都重新渲染,看不到缓存效果(属正常)。
  • pnpm build && pnpm start
    • build 日志看每个路由 (静态)/ ƒ(动态)标记。
    • .next/server/app/.../*.html 存在静态 HTML = 该页被静态化。
    • fetch-cache 页刷新,时间戳不变 → 命中完整路由缓存;改数据源后再刷新仍不变 → 数据缓存未失效。
    • revalidate-tag 页点按钮触发 revalidateTag,立刻看到新数据 → 数据缓存 + 路由器缓存被清。

注:revalidate-tag 里用到的 revalidateTagNext 16 起需要第二个参数 profile(缓存生命周期档位,如 "default"),调用形如 revalidateTag("tag", "default")。实验代码已按新签名实现。


4.9 面试自测题

<details> <summary>1. Next 15 起 fetch 的默认缓存行为是什么?为什么这么改?</summary>

默认不缓存。14 及以前 fetch 默认 force-cache,导致"服务端组件用了 fetch 却拿到旧数据"的隐式缓存陷阱;15 起取消隐式缓存,把是否缓存的决策显式化,让开发者用 cache 选项或 next.revalidate 自己声明。

</details> <details> <summary>2. Next 有哪四层缓存?分别缓存什么?</summary>

请求记忆化(单次请求内相同 fetch 的返回值,服务器内存)、数据缓存(fetch/unstable_cache 结果,跨请求持久化)、完整路由缓存(静态页的 HTML+RSC Payload,部署时清空)、路由器缓存(各段 RSC Payload,客户端内存)。前两层管"数据",后两层管"页面/路由"。

</details> <details> <summary>3. 请求记忆化和数据缓存有什么区别?</summary>

请求记忆化只在单次请求内生效,解决"同一次渲染重复发请求",渲染完就重置,不跨请求;数据缓存跨请求、跨部署持久化,解决"跨请求少打后端",需要显式缓存并配合重新验证。

</details> <details> <summary>4. 怎么让一个 fetch 结果缓存?怎么让它永远不缓存?</summary>

缓存:fetch(url, { cache: "force-cache" });不缓存:fetch(url, { cache: "no-store" })。也可以用 next: { revalidate: N } 做时间缓存、next: { tags: [...] } 打标签配合 revalidateTag 按需失效。

</details> <details> <summary>5. revalidateTag 和 revalidatePath 的区别?在哪里调用?</summary>

revalidateTag 按缓存标签清一批数据缓存;revalidatePath 按路径清对应路由的数据缓存+完整路由缓存。都必须在服务端上下文(Server Action 或 Route Handler)调用,客户端不能直接调。

</details> <details> <summary>6. 数据缓存失效会影响完整路由缓存吗?反过来呢?</summary>

会。数据缓存重新验证/退出会级联使完整路由缓存失效(页面渲染依赖数据)。反过来退出完整路由缓存不影响数据缓存——可以"动态渲染 + 用缓存数据"。

</details> <details> <summary>7. 完整路由缓存什么时候被清空?</summary>

重新部署(重新构建)时清空;或数据缓存被重新验证导致级联失效;或用动态 API、fetch no-store、force-dynamic、revalidate=0 退出缓存(此时路由变动态,不再进完整路由缓存)。

</details> <details> <summary>8. 路由器缓存和完整路由缓存有什么本质区别?</summary>

位置不同:路由器缓存在客户端内存,完整路由缓存在服务器。内容不同:路由器缓存各段 RSC Payload(按 layout/loading/page 分块),完整路由缓存整页 HTML+RSC。失效方式不同:路由器缓存页面刷新即清、可被 router.refresh 清,完整路由缓存部署才清。

</details> <details> <summary>9. router.refresh() 和 revalidatePath 有什么不同?</summary>

router.refresh 是客户端 API,只清路由器缓存、重新拉一次当前路由 RSC,不动数据缓存和完整路由缓存;revalidatePath 是服务端 API,真正让数据缓存+完整路由缓存失效。前者是"刷新视图",后者是"刷新数据与页面"。

</details> <details> <summary>10. 非 fetch 的数据(如直接查数据库)怎么缓存?</summary>

用 next/cache 的 unstable_cache 手动包一层,传入缓存 key,可选 revalidate(时间)和 tags(标签),之后可配合 revalidateTag 按需失效。

</details> <details> <summary>11. 从缓存视角,"静态 vs 动态"的本质是什么?</summary>

本质是页面依赖的输入在构建时能否确定。不依赖动态 API、且 fetch 可静态化的页面构建时跑一次进缓存(静态);依赖 cookies/searchParams/request 或默认不缓存 fetch 的页面每个请求输入不同,只能每次渲染(动态)。缓存是底层机制,静态/动态是它对外呈现的结果。

</details> <details> <summary>12. 一个页面用了 fetch 且没写任何缓存选项,它是静态还是动态?</summary>

动态(Next 15+)。因为 fetch 默认不缓存,数据每次请求都可能变,Next 无法在构建时确定输出,路由会被标记为 ƒ。要静态需显式 force-cache 或 next: { revalidate: N }(配合静态化条件)。

</details>

4.10 常见追问(加分项)

  • "为什么数据缓存失效会级联清完整路由缓存?":完整路由缓存存的是"用某份数据渲染出的 HTML",数据变了、渲染结果就必然过期,两者强耦合,所以数据缓存失效必须连带页面缓存失效。
  • "Request Memoization 和 React.cache 是一回事吗?":机制相近,都是"同一渲染内复用"。React.cache 是 React 的通用函数记忆化,能包任意函数(不止 fetch);请求记忆化是 React 对 fetch 的内建优化。两者都只在单次请求内生效。
  • "Route Handler 里 revalidateTag 能让客户端立刻看到新数据吗?":能清数据缓存,但不会立即使路由器缓存失效(Route Handler 不绑定特定路由),所以客户端要 router.refresh() 才能立刻看到;Server Action 里调用则同时清路由器缓存。
  • "Next 15 页面段默认退出路由器缓存,意味着什么?":意味着页面切换回来时会重新向服务器请求该页 RSC(layout/loading 仍被复用),减少"返回旧页面数据"的陈旧感,代价是导航可能多一点请求;需要缓存可用实验性 staleTimes 配置。
  • "ISR 和这一章的数据缓存是什么关系?":ISR(第 1 章)依赖两层缓存叠加——数据缓存(fetch 的 revalidate)+ 完整路由缓存(页面的 revalidate)。Next 15+ fetch 默认不缓存后,ISR 需要显式给 fetch 加 next.revalidate 或段配置 revalidate,否则页面会被拖成纯动态。

下一章:第 5 章 Server Actions —— 'use server' 原理、与 API Route 的区别、渐进增强。本章的 revalidateTag / revalidatePath 正是 Server Action 里最常见的落地场景,两章强衔接。