Next.js 中的缓存机制

Next.js 通过缓存渲染工作和数据请求来提升应用性能并降低成本。本文将深入探讨 Next.js 的缓存机制、可配置的 API 以及它们之间的交互方式。

须知:本文帮助您理解 Next.js 的底层工作原理,但并非高效使用 Next.js 的必备知识。Next.js 的大部分缓存启发式规则由您的 API 使用情况决定,并提供了最佳性能的默认配置(零配置或最小配置)。如果您想直接查看示例,请从这里开始

概览

以下是不同缓存机制及其用途的高级概览:

机制缓存内容位置用途持续时间
请求记忆化函数返回值服务端在 React 组件树中复用数据单次请求生命周期
数据缓存数据服务端跨用户请求和部署存储数据持久性(可重新验证)
全路由缓存HTML 和 RSC 负载服务端降低渲染成本并提升性能持久性(可重新验证)
路由缓存RSC 负载客户端减少导航时的服务端请求用户会话或基于时间

默认情况下,Next.js 会尽可能缓存以提升性能并降低成本。这意味着路由会静态渲染且数据请求会被缓存,除非您选择退出。下图展示了默认缓存行为:当路由在构建时静态渲染以及首次访问静态路由时的情况。

展示 Next.js 中四种机制默认缓存行为的示意图,包含构建时和首次访问路由时的 HIT、MISS 和 SET 状态

缓存行为会根据路由是静态渲染还是动态渲染、数据是否被缓存以及请求是首次访问还是后续导航而变化。根据您的使用场景,您可以配置单个路由和数据请求的缓存行为。

请求记忆化

Next.js 扩展了 fetch API,自动对具有相同 URL 和选项的请求进行记忆化。这意味着您可以在 React 组件树的多个位置调用相同的 fetch 函数,而实际上只会执行一次。

去重化的 Fetch 请求

例如,如果您需要在路由中多处使用相同数据(如在布局、页面和多个组件中),您不必在树顶部获取数据并通过组件传递 props。相反,您可以在需要数据的组件中直接获取数据,而无需担心因网络请求重复数据而影响性能。

async function getItem() {
  // `fetch` 函数会自动记忆化,结果会被缓存
  const res = await fetch('https://.../item/1')
  return res.json()
}

// 此函数被调用两次,但仅第一次会执行
const item = await getItem() // 缓存 MISS

// 第二次调用可以发生在路由的任何位置
const item = await getItem() // 缓存 HIT

请求记忆化工作原理

展示 React 渲染期间 fetch 记忆化工作原理的示意图
  • 在渲染路由时,首次调用特定请求时,其结果不在内存中,因此是缓存 MISS
  • 因此,函数会被执行,数据会从外部源获取,结果会被存储在内存中。
  • 同一渲染过程中对同一请求的后续函数调用会是缓存 HIT,数据会直接从内存返回而无需执行函数。
  • 路由渲染完成且渲染过程结束后,内存会被"重置",所有请求记忆化条目会被清除。

须知

  • 请求记忆化是 React 的特性,而非 Next.js 的特性。此处提及是为了展示它与其他缓存机制的交互。
  • 记忆化仅适用于 fetch 请求中的 GET 方法。
  • 记忆化仅适用于 React 组件树,这意味着:
    • 它适用于 generateMetadatagenerateStaticParams、布局、页面和其他服务端组件中的 fetch 请求。
    • 它不适用于路由处理器中的 fetch 请求,因为它们不属于 React 组件树。
  • 对于不适合使用 fetch 的情况(如某些数据库客户端、CMS 客户端或 GraphQL 客户端),您可以使用 React cache 函数 来记忆化函数。

持续时间

缓存持续到服务器请求的生命周期结束,即 React 组件树完成渲染时。

重新验证

由于记忆化不在服务器请求间共享且仅在渲染期间有效,因此无需重新验证。

选择退出

记忆化仅适用于 fetch 请求中的 GET 方法,其他方法如 POSTDELETE 不会被记忆化。这是 React 的默认优化行为,我们不建议选择退出。

要管理单个请求,您可以使用 AbortControllersignal 属性。但这不会让请求退出记忆化,而是中止进行中的请求。

app/example.js
const { signal } = new AbortController()
fetch(url, { signal })

数据缓存

Next.js 内置了数据缓存,可持久化数据获取结果,跨服务器请求部署。这是通过扩展原生 fetch API 实现的,允许每个服务器请求设置自己的持久缓存语义。

须知:在浏览器中,fetchcache 选项表示请求如何与浏览器的 HTTP 缓存交互;在 Next.js 中,cache 选项表示服务器端请求如何与服务器的数据缓存交互。

您可以使用 fetchcachenext.revalidate 选项来配置缓存行为。

数据缓存工作原理

展示缓存和非缓存 fetch 请求如何与数据缓存交互的示意图。缓存请求存储在数据缓存中并被记忆化,非缓存请求从数据源获取,不存储在数据缓存中,但被记忆化。
  • 在渲染过程中首次调用带有 'force-cache' 选项的 fetch 请求时,Next.js 会检查数据缓存中是否有缓存的响应。
  • 如果找到缓存响应,则立即返回并记忆化
  • 如果未找到缓存响应,则向数据源发起请求,结果存储在数据缓存中并被记忆化。
  • 对于非缓存数据(如未定义 cache 选项或使用 { cache: 'no-store' }),结果总是从数据源获取并被记忆化。
  • 无论数据是否被缓存,请求都会被记忆化以避免在 React 渲染过程中重复请求相同数据。

数据缓存与请求记忆化的区别

虽然两种缓存机制都通过复用缓存数据提升性能,但数据缓存在请求间和部署间持久化,而记忆化仅持续单次请求的生命周期。

持续时间

除非重新验证或选择退出,否则数据缓存在请求间和部署间持久化。

重新验证

缓存数据可通过两种方式重新验证:

  • 基于时间的重新验证:在指定时间间隔后重新验证数据。适用于变化不频繁且实时性要求不高的数据。
  • 按需重新验证:基于事件(如表单提交)重新验证数据。按需重新验证可使用标签或路径方式一次性重新验证一组数据。适用于需要尽快显示最新数据的场景(如无头 CMS 内容更新时)。

基于时间的重新验证

要按时间间隔重新验证数据,可使用 fetchnext.revalidate 选项设置资源的缓存生命周期(秒)。

// 最多每小时重新验证一次
fetch('https://...', { next: { revalidate: 3600 } })

或者,您可以使用路由段配置选项来配置段中的所有 fetch 请求,或用于无法使用 fetch 的情况。

基于时间的重新验证工作原理

展示基于时间重新验证工作原理的示意图,重新验证期过后,首次请求返回陈旧数据,随后数据被重新验证。
  • 首次调用带有 revalidate 的 fetch 请求时,数据会从外部数据源获取并存储在数据缓存中。
  • 在指定时间范围内(如 60 秒)的任何请求都会返回缓存数据。
  • 时间范围过后,下一次请求仍会返回缓存(此时已陈旧)数据。
    • Next.js 会在后台触发数据重新验证。
    • 数据成功获取后,Next.js 会用新数据更新数据缓存。
    • 如果后台重新验证失败,则保留原数据不变。

这与 stale-while-revalidate 行为类似。

按需重新验证

可通过路径 (revalidatePath) 或缓存标签 (revalidateTag) 按需重新验证数据。

按需重新验证工作原理

展示按需重新验证工作原理的示意图,重新验证请求后数据缓存更新为新数据。
  • 首次调用 fetch 请求时,数据会从外部数据源获取并存储在数据缓存中。
  • 触发按需重新验证时,相应的缓存条目会从缓存中清除。
    • 这与基于时间的重新验证不同,后者会在获取新数据前保留陈旧数据。
  • 下次请求时,会再次出现缓存 MISS,数据会从外部数据源获取并存储在数据缓存中。

选择退出

如果不想缓存 fetch 的响应,可以这样做:

let data = await fetch('https://api.vercel.app/blog', { cache: 'no-store' })

全路由缓存

相关术语

您可能会看到自动静态优化静态站点生成静态渲染这些术语交替使用,指在构建时渲染和缓存应用路由的过程。

Next.js 会在构建时自动渲染和缓存路由。这是一项优化,允许您直接提供缓存的路由,而无需为每个请求在服务器上渲染,从而实现更快的页面加载。

要理解全路由缓存的工作原理,了解 React 如何处理渲染以及 Next.js 如何缓存结果很有帮助:

1. 服务器上的 React 渲染

在服务器上,Next.js 使用 React 的 API 来协调渲染。渲染工作被拆分为块:按单个路由段和 Suspense 边界划分。

每个块的渲染分为两步:

  1. React 将服务端组件渲染为一种特殊的数据格式,称为 React 服务端组件负载,针对流式传输进行了优化。
  2. Next.js 使用 React 服务端组件负载和客户端组件 JavaScript 指令在服务器上渲染 HTML

这意味着我们不必等待所有内容渲染完成后再缓存工作或发送响应。相反,我们可以在工作完成时流式传输响应。

什么是 React 服务端组件负载?

React 服务端组件负载是渲染后的 React 服务端组件树的紧凑二进制表示。React 在客户端使用它来更新浏览器的 DOM。React 服务端组件负载包含:

  • 服务端组件的渲染结果
  • 客户端组件应渲染位置的占位符及其 JavaScript 文件的引用
  • 从服务端组件传递到客户端组件的任何 props

要了解更多,请参阅服务端组件文档。

2. Next.js 服务器缓存(全路由缓存)

全路由缓存的默认行为,展示静态渲染路由的 React 服务端组件负载和 HTML 如何在服务器上缓存。

Next.js 的默认行为是在服务器上缓存路由的渲染结果(React 服务端组件负载和 HTML)。这适用于构建时静态渲染的路由或重新验证期间的路由。

3. 客户端的 React 水合和协调

在请求时,客户端会:

  1. 使用 HTML 快速显示客户端和服务端组件的非交互式初始预览。
  2. 使用 React 服务端组件负载协调客户端和渲染的服务端组件树,并更新 DOM。
  3. 使用 JavaScript 指令水合客户端组件,使应用具有交互性。

4. Next.js 客户端缓存(路由缓存)

React 服务端组件负载存储在客户端的路由缓存中——这是一个按单个路由段分割的独立内存缓存。路由缓存通过存储先前访问的路由和预取未来路由来提升导航体验。

5. 后续导航

在后续导航或预取时,Next.js 会检查 React 服务端组件负载是否存储在路由缓存中。如果是,则跳过向服务器发送新请求。

如果路由段不在缓存中,Next.js 会从服务器获取 React 服务端组件负载,并在客户端填充路由缓存。

静态与动态渲染 (Static and Dynamic Rendering)

路由是否在构建时被缓存取决于它是静态渲染还是动态渲染。静态路由默认会被缓存,而动态路由则在请求时渲染且不会被缓存。

下图展示了静态与动态渲染路由的区别,以及缓存与非缓存数据的差异:

静态与动态渲染对完整路由缓存的影响。静态路由在构建时或数据重新验证后被缓存,而动态路由从不被缓存

了解更多关于静态与动态渲染的内容。

持续时间 (Duration)

默认情况下,完整路由缓存 (Full Route Cache) 是持久化的。这意味着渲染输出会在用户请求之间被缓存。

失效机制 (Invalidation)

有两种方式可以使完整路由缓存失效:

退出机制 (Opting out)

您可以通过以下方式退出完整路由缓存(即对每个传入请求动态渲染组件):

  • 使用动态 API (Dynamic API):这将使路由退出完整路由缓存,并在请求时动态渲染。数据缓存仍可使用。
  • 使用路由段配置选项 dynamic = 'force-dynamic'revalidate = 0:这将跳过完整路由缓存和数据缓存。意味着组件将在每次传入请求时重新渲染,数据也会重新获取。路由缓存 (Router Cache) 仍会生效,因为它是客户端缓存。
  • 退出数据缓存 (Data Cache):如果路由有一个未被缓存的 fetch 请求,这将使路由退出完整路由缓存。该 fetch 请求的数据将在每次传入请求时重新获取。其他未退出缓存的 fetch 请求仍会被缓存在数据缓存中。这允许混合使用缓存和非缓存数据。

客户端路由缓存 (Client-side Router Cache)

Next.js 有一个内存中的客户端路由缓存,用于存储按布局、加载状态和页面分割的路由段的 RSC 负载。

当用户在路由间导航时,Next.js 会缓存访问过的路由段,并预取 (prefetch) 用户可能导航到的路由。这实现了即时前进/后退导航、导航间无整页重载,并保留了 React 状态和浏览器状态。

通过路由缓存:

须知:此缓存专门适用于 Next.js 和服务端组件 (Server Components),与浏览器的 bfcache 不同,尽管效果相似。

持续时间 (Duration)

缓存存储在浏览器的临时内存中。两个因素决定了路由缓存的持续时间:

  • 会话 (Session):缓存在导航间持续存在,但在页面刷新时会被清除。
  • 自动失效周期 (Automatic Invalidation Period):布局和加载状态的缓存会在特定时间后自动失效。持续时间取决于资源是如何预取 (prefetched) 的,以及资源是否是静态生成 (statically generated)
    • 默认预取 (Default Prefetching) (prefetch={null} 或未指定):动态页面不缓存,静态页面缓存 5 分钟。
    • 完整预取 (Full Prefetching) (prefetch={true}router.prefetch):静态和动态页面均缓存 5 分钟。

页面刷新会清除所有缓存段,而自动失效周期仅影响从预取时间开始的单个段。

须知:实验性的 staleTimes 配置选项可用于调整上述自动失效时间。

失效机制 (Invalidation)

有两种方式可以使路由缓存失效:

  • 服务端操作 (Server Action) 中:
  • 调用 router.refresh 会使路由缓存失效,并为当前路由向服务器发起新请求。

退出机制 (Opting out)

从 Next.js 15 开始,页面段默认退出缓存。

须知:您还可以通过将 <Link> 组件的 prefetch 属性设为 false 来退出预取 (prefetching)

缓存交互 (Cache Interactions)

在配置不同的缓存机制时,了解它们如何相互影响非常重要:

数据缓存与完整路由缓存 (Data Cache and Full Route Cache)

  • 重新验证或退出数据缓存使完整路由缓存失效,因为渲染输出依赖于数据。
  • 使完整路由缓存失效或退出不会影响数据缓存。您可以动态渲染一个同时包含缓存和非缓存数据的路由。这在页面大部分使用缓存数据,但少数组件依赖需要实时获取的数据时非常有用。您可以动态渲染,而无需担心重新获取所有数据对性能的影响。

数据缓存与客户端路由缓存 (Data Cache and Client-side Router cache)

API 参考 (APIs)

下表概述了不同 Next.js API 对缓存的影响:

API路由缓存 (Router Cache)完整路由缓存 (Full Route Cache)数据缓存 (Data Cache)React 缓存 (React Cache)
<Link prefetch>缓存
router.prefetch缓存
router.refresh重新验证
fetch缓存缓存
fetch options.cache缓存或退出
fetch options.next.revalidate重新验证重新验证
fetch options.next.tags缓存缓存
revalidateTag重新验证(服务端操作)重新验证重新验证
revalidatePath重新验证(服务端操作)重新验证重新验证
const revalidate重新验证或退出重新验证或退出
const dynamic缓存或退出缓存或退出
cookies重新验证(服务端操作)退出
headers, searchParams退出
generateStaticParams缓存
React.cache缓存
unstable_cache缓存

默认情况下,<Link> 组件会自动从完整路由缓存预取路由,并将 React 服务端组件负载添加到路由缓存中。

要禁用预取,可以将 prefetch 属性设为 false。但这不会永久跳过缓存,用户访问路由时仍会在客户端缓存该路由段。

了解更多关于 <Link> 组件的内容。

router.prefetch

useRouter 钩子的 prefetch 选项可用于手动预取路由。这会将 React 服务端组件负载添加到路由缓存中。

参见 useRouter 钩子 API 参考。

router.refresh

useRouter 钩子的 refresh 选项可用于手动刷新路由。这会完全清除路由缓存,并为当前路由向服务器发起新请求。refresh 不会影响数据缓存或完整路由缓存。

渲染结果将在客户端进行协调,同时保留 React 状态和浏览器状态。

参见 useRouter 钩子 API 参考。

fetch

fetch 返回的数据不会自动缓存在数据缓存中。

fetch 的默认缓存行为(例如未指定 cache 选项时)等同于将 cache 选项设为 no-store

let data = await fetch('https://api.vercel.app/blog', { cache: 'no-store' })

参见 fetch API 参考 获取更多选项。

fetch options.cache

您可以通过将 cache 选项设为 force-cache 来为单个 fetch 启用缓存:

// 启用缓存
fetch(`https://...`, { cache: 'force-cache' })

参见 fetch API 参考 获取更多选项。

fetch options.next.revalidate

您可以使用 fetchnext.revalidate 选项设置单个 fetch 请求的重新验证周期(以秒为单位)。这将重新验证数据缓存,进而重新验证完整路由缓存。将获取新数据,并在服务端重新渲染组件。

// 最多 1 小时后重新验证
fetch(`https://...`, { next: { revalidate: 3600 } })

参见 fetch API 参考 获取更多选项。

fetch options.next.tagsrevalidateTag

Next.js 有一个缓存标签系统,用于细粒度的数据缓存和重新验证。

  1. 使用 fetchunstable_cache 时,可以选择用一个或多个标签标记缓存条目。
  2. 然后,可以调用 revalidateTag 清除与该标签关联的缓存条目。

例如,可以在获取数据时设置标签:

// 使用标签缓存数据
fetch(`https://...`, { next: { tags: ['a', 'b', 'c'] } })

然后,调用 revalidateTag 并传入标签以清除缓存条目:

// 重新验证具有特定标签的条目
revalidateTag('a')

有两种地方可以使用 revalidateTag,具体取决于您的目标:

  1. 路由处理器 (Route Handlers) - 在第三方事件(例如 webhook)响应中重新验证数据。这不会立即使路由缓存失效,因为路由处理器不与特定路由绑定。
  2. 服务端操作 (Server Actions) - 在用户操作(例如表单提交)后重新验证数据。这将使关联路由的路由缓存失效。

revalidatePath

revalidatePath 允许您手动重新验证数据在单个操作中重新渲染特定路径下的路由段。调用 revalidatePath 方法会重新验证数据缓存,进而使完整路由缓存失效。

revalidatePath('/')

有两种地方可以使用 revalidatePath,具体取决于您的目标:

  1. 路由处理器 (Route Handlers) - 在第三方事件(例如 webhook)响应中重新验证数据。
  2. 服务端操作 (Server Actions) - 在用户交互(例如表单提交、点击按钮)后重新验证数据。

参见 revalidatePath API 参考 获取更多信息。

revalidatePathrouter.refresh 的区别:

调用 router.refresh 会清除路由缓存,并在服务端重新渲染路由段,而不使数据缓存或完整路由缓存失效。

区别在于 revalidatePath 会清除数据缓存和完整路由缓存,而 router.refresh() 不会改变数据缓存和完整路由缓存,因为它是客户端 API。

动态 API (Dynamic APIs)

动态 API 如 cookiesheaders,以及页面中的 searchParams 属性依赖于运行时传入的请求信息。使用它们会使路由退出完整路由缓存,换句话说,路由将被动态渲染。

cookies

在服务端操作中使用 cookies.setcookies.delete 会使路由缓存失效,以防止使用 cookie 的路由变得过时(例如反映认证变更)。

参见 cookies API 参考。

路由段配置选项

路由段配置 (Route Segment Config) 选项可用于覆盖路由段默认值,或在无法使用 fetch API 时(例如使用数据库客户端或第三方库)进行配置。

以下路由段配置选项将退出全路由缓存 (Full Route Cache):

  • const dynamic = 'force-dynamic'

此配置选项将使所有数据获取操作退出数据缓存 (Data Cache)(即等同于 no-store):

  • const fetchCache = 'default-no-store'

更多高级选项请参阅 fetchCache 文档。

更多配置选项请查看路由段配置文档。

generateStaticParams

对于动态路由段(例如 app/blog/[slug]/page.js),通过 generateStaticParams 提供的路径会在构建时被缓存到全路由缓存中。在请求时,Next.js 也会在首次访问时缓存那些构建时未知的路径。

要在构建时静态生成所有路径,需向 generateStaticParams 提供完整路径列表:

app/blog/[slug]/page.js
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())

  return posts.map((post) => ({
    slug: post.slug,
  }))
}

要在构建时静态生成部分路径,并在运行时首次访问时生成其余路径,可返回部分路径列表:

app/blog/[slug]/page.js
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())

  // 在构建时渲染前 10 篇文章
  return posts.slice(0, 10).map((post) => ({
    slug: post.slug,
  }))
}

要在首次访问时静态生成所有路径,可返回空数组(构建时不渲染任何路径)或使用 export const dynamic = 'force-static'

app/blog/[slug]/page.js
export async function generateStaticParams() {
  return []
}

须知: 即使返回空数组,也必须从 generateStaticParams 返回一个数组。否则该路由将动态渲染。

app/changelog/[slug]/page.js
export const dynamic = 'force-static'

要禁用请求时的缓存,可在路由段中添加 export const dynamicParams = false 选项。使用此配置后,仅会提供由 generateStaticParams 生成的路径,其他路由将返回 404 或匹配(对于全捕获路由)。

React cache 函数

React cache 函数允许对函数返回值进行记忆化 (memoize),使得多次调用同一函数时只会执行一次。

由于 fetch 请求会自动记忆化,因此无需用 React cache 进行包装。但对于不适合使用 fetch API 的场景(如某些数据库客户端、CMS 客户端或 GraphQL 客户端),可以使用 cache 手动记忆化数据请求。

import { cache } from 'react'
import db from '@/lib/db'

export const getItem = cache(async (id: string) => {
  const item = await db.item.findUnique({ id })
  return item
})