终于掌握 Next.js 最复杂的功能 - 缓存
2024 年 1 月 8 日
Next.js
介绍
Next.js 是一个令人惊叹的框架,它使编写复杂的服务器渲染 React 应用程序变得更加容易,但有一个巨大的问题。 Next.js 的缓存机制极其复杂,很容易导致代码中出现难以调试和修复的错误。
如果您不了解 Next.js 的缓存机制是如何工作的,那么感觉就像您不断地与 Next.js 作斗争,而不是获得 Next.js 强大缓存的惊人好处。这就是为什么在这篇文章中,我将详细分析 Next.js 缓存的每个部分是如何工作的,这样您就可以停止与它对抗,并最终利用其令人难以置信的性能提升。
在我们开始之前,先看一张图片,展示了 Next.js 中的所有缓存如何相互交互。我知道这令人难以承受,但在本文结束时,您将准确理解此过程中每个步骤的作用以及它们如何相互作用。

在上图中,您可能注意到术语"构建时间"和"请求时间"。为了确保这不会在整篇文章中造成任何混乱,让我在继续之前解释一下。
构建时间是指构建和部署应用程序的时间。在此过程中缓存的任何内容(主要是静态内容)都将成为构建时缓存的一部分。构建时缓存仅在应用程序重新构建和重新部署时更新。
请求时间是指用户请求页面的时间。通常,请求时缓存的数据是动态的,因为我们希望在用户发出请求时直接从数据源获取数据。
Next.js 缓存机制
乍一看,理解 Next.js 的缓存似乎令人畏惧。这是因为它由四种不同的缓存机制组成,每种缓存机制在应用程序的不同阶段运行,并以最初看起来很复杂的方式进行交互。
以下是 Next.js 中的四种缓存机制:
- 请求记忆
- 数据缓存
- 全路由缓存
- 路由器缓存
对于上述每个内容,我将深入研究它们的具体角色、存储位置、持续时间以及如何有效管理它们,包括使缓存失效和选择退出的方法。在本次探索结束时,您将牢牢掌握这些机制如何协同工作以优化 Next.js 的性能。
请求记忆
React 中的一个常见问题是当您需要在同一页面上的多个位置显示相同的信息时。最简单的选择是只在需要的两个位置获取数据,但这并不理想,因为您现在向服务器发出两个请求以获取相同的数据。这就是请求记忆的用武之地。
请求记忆是一项 React 功能,它实际上fetch
缓存渲染周期期间在服务器组件中发出的每个请求(基本上只是指渲染页面上所有组件的过程)。这意味着,如果您fetch
在一个组件中发出请求,然后fetch
在另一个组件中发出相同的请求,则第二个fetch
请求实际上不会向服务器发出请求。相反,它将使用第一个fetch
请求中的缓存值。
javascript
export default async function fetchUserData(userId) {
// The `fetch` function is automatically cached by Next.js
const res = await fetch(`https://api.example.com/users/${userId}`)
return res.json();
}
export default async function Page({ params }) {
const user = await fetchUserData(params.id)
return <>
<h1>{user.name}</h1>
<UserDetails id={params.id} />
</>
}
async function UserDetails({ id }) {
const user = await fetchUserData(id)
return <p>{user.name}</p>
}
在上面的代码中,我们有两个组件:Page
和UserDetails
。对fetchUserData()
函数的第一次调用就像平常一样Page
发出fetch
请求,但该请求的返回值fetch
存储在请求记忆缓存中。第二次fetchUserData
由组件调用UserDetails
,实际上并没有发出新的fetch
请求。相反,它使用第一次fetch
发出此请求时的记忆值。这种小的优化通过减少向服务器发出的请求数量,极大地提高了应用程序的性能,并且还使您的组件更易于编写,因为您无需担心优化请求fetch
。
重要的是要知道此缓存完全存储在服务器上,这意味着它只会缓存fetch
从服务器组件发出的请求。此外,该缓存在每个请求开始时都会被完全清除,这意味着它仅在单个渲染周期的持续时间内有效。不过,这不是问题,因为此缓存的全部目的是减少fetch
单个渲染周期内的重复请求。
最后,需要注意的是,该缓存只会缓存fetch
使用该方法发出的请求GET
。请求fetch
还必须传递完全相同的参数(URL 和选项)才能被记住。
缓存非fetch
请求
默认情况下,React 仅缓存fetch
请求,但有时您可能希望缓存其他类型的请求,例如数据库请求。为此,我们可以使用 React 的cache
函数。您需要做的就是传递要缓存的函数cache
,它将返回该函数的记忆版本。
javascript
import { cache } from "react"
import { queryDatabase } from "./databaseClient"
export const fetchUserData = cache(userId => {
// Direct database query
return queryDatabase("SELECT * FROM users WHERE id = ?", [userId])
})
在上面的代码中,第一次fetchUserData()
调用时,它直接查询数据库,因为还没有缓存结果。但是下次使用相同的函数调用该函数时userId
,将从缓存中检索数据。就像 一样fetch
,此记忆仅在单个渲染通道的持续时间内有效,并且与fetch
记忆相同。
重新验证
重新验证是清除缓存并使用新数据更新缓存的过程。这样做很重要,因为如果您从不更新缓存,它最终会变得陈旧和过时。幸运的是,我们不必担心请求记忆化的问题,因为该缓存仅在单个请求的持续时间内有效,而我们无需重新验证。
选择退出
要选择退出此缓存,我们可以将 anAbortController
signal
作为参数传递给fetch
请求。
javascript
async function fetchUserData(userId) {
const { signal } = new AbortController()
const res = await fetch(`https://api.example.com/users/${userId}`, {
signal,
})
return res.json()
}
fetch
这样做将告诉 React 不要在请求记忆缓存中缓存此请求,但我不建议这样做,除非您有充分的理由,因为此缓存非常有用并且可以极大地提高应用程序的性能。
下图直观地概括了请求记忆的工作原理。

从技术上讲,请求记忆化是一项 React 功能,并非 Next.js 独有。不过,我将其作为 Next.js 缓存机制的一部分包含在内,因为有必要了解它才能理解完整的 Next.js 缓存过程。
数据缓存
请求记忆化非常适合通过防止重复fetch
请求来提高应用程序的性能,但是当涉及到跨请求/用户缓存数据时,它是无用的。这就是数据缓存的用武之地。它是 Next.js 在实际从 API 或数据库获取数据之前命中的最后一个缓存,并且在多个请求/用户之间持久存在。
想象一下,我们有一个简单的页面,可以查询 API 以获取特定城市的指南数据。
javascript
export default async function Page({ params }) {
const city = params.city
const res = await fetch(`https://api.globetrotter.com/guides/${city}`)
const guideData = await res.json()
return (
<div>
<h1>{guideData.title}</h1>
<p>{guideData.content}</p>
{/* Render the guide data */}
</div>
)
}
该指南数据实际上根本不会经常更改,因此每次有人需要时都重新获取这些数据实际上没有意义。相反,我们应该在所有请求中缓存该数据,以便将来的用户立即加载。通常,这实现起来会很痛苦,但幸运的是 Next.js 通过数据缓存自动为我们完成了这一点。
默认情况下,fetch
服务器组件中的每个请求都将缓存在数据缓存(存储在服务器上)中,并将用于将来的所有请求。这意味着,如果您有 100 个用户都请求相同的数据,Next.js 将仅fetch
向您的 API 发出一个请求,然后为所有 100 个用户使用该缓存数据。这是一个巨大的性能提升。
期间
数据缓存与请求记忆缓存不同,除非您明确告诉 Next.js 这样做,否则该缓存中的数据永远不会被清除。这些数据甚至可以跨部署保留,这意味着如果您部署应用程序的新版本,数据缓存将不会被清除。
重新验证
由于 Next.js 永远不会清除数据缓存,我们需要一种方法来选择重新验证,这只是从缓存中删除数据的过程。在 Next.js 中,有两种不同的方法可以实现此目的:基于时间的重新验证和按需重新验证。
基于时间的重新验证
重新验证数据缓存的最简单方法是在一段时间后自动清除缓存。这可以通过两种方式完成。
php
const res = fetch(`https://api.globetrotter.com/guides/${city}`, {
next: { revalidate: 3600 },
})
第一种方法是将next.revalidate
选项传递给您的fetch
请求。这将告诉 Next.js 在数据被视为过时之前将数据保留在缓存中多少秒。在上面的示例中,我们告诉 Next.js 每小时重新验证缓存。
设置重新验证时间的另一种方法是使用revalidate
段配置选项。
javascript
export const revalidate = 3600
export default async function Page({ params }) {
const city = params.city
const res = await fetch(`https://api.globetrotter.com/guides/${city}`)
const guideData = await res.json()
return (
<div>
<h1>{guideData.title}</h1>
<p>{guideData.content}</p>
{/* Render the guide data */}
</div>
)
}
这样做将使fetch
该页面的所有请求每小时重新验证一次,除非它们有自己更具体的重新验证时间设置。
对于基于时间的重新验证,需要了解的一件重要的事情是它如何处理过时的数据。
第一次fetch
发出请求时,它将获取数据,然后将其存储在缓存中。在我们设置的 1 小时重新验证时间内发生的每个新fetch
请求都将使用缓存的数据,并且不再发出fetch
请求。然后1小时后,fetch
发出的第一个请求仍然会返回缓存的数据,但它也会执行fetch
请求以获取新更新的数据并将其存储在缓存中。这意味着fetch
此后的每个新请求都将使用新缓存的数据。这种模式称为 stale-while-revalidate,是 Next.js 使用的行为。
按需重新验证
如果您的数据没有定期更新,您可以使用按需重新验证,仅在新数据可用时重新验证缓存。当您想要使缓存无效并仅在发布新文章或发生特定事件时获取新数据时,这非常有用。
这可以通过两种方式之一完成。
javascript
import { revalidatePath } from "next/cache"
export async function publishArticle({ city }) {
createArticle(city)
revalidatePath(`/guides/${city}`)
}
该函数采用字符串路径,并将清除该路由上revalidatePath
所有请求的缓存。fetch
如果您想更具体地fetch
重新验证请求,可以使用revalidateTag
函数。
php
const res = fetch(`https://api.globetrotter.com/guides/${city}`, {
next: { tags: ["city-guides"] },
})
在这里,我们将city-guides
标签添加到我们的fetch
请求中,以便我们可以使用 来定位它revalidateTag
。
javascript
import { revalidateTag } from "next/cache"
export async function publishArticle({ city }) {
createArticle(city)
revalidateTag("city-guides")
}
通过revalidateTag
使用字符串调用,它将清除fetch
带有该标记的所有请求的缓存。
选择退出
选择退出数据缓存可以通过多种方式完成。
no-store
javascript
const res = fetch(`https://api.globetrotter.com/guides/${city}`, {
cache: "no-store",
})
通过传递cache: "no-store"
到您的fetch
请求,您就告诉 Next.js 不要在数据缓存中缓存此请求。当您的数据不断变化并且您希望每次都获取最新数据时,这非常有用。
您还可以调用该noStore
函数来选择退出该函数范围内的所有内容的数据缓存。
javascript
import { unstable_noStore as noStore } from "next/cache"
function getGuide() {
noStore()
const res = fetch(`https://api.globetrotter.com/guides/${city}`)
}
目前,这是一项实验性功能,因此它带有前缀 unstable_
,但它是 Next.js 中选择退出数据缓存的首选方法。
这是在每个组件或每个功能的基础上选择退出缓存的真正好方法,因为所有其他选择退出方法都将选择退出整个页面的数据缓存。
export const dynamic = 'force-dynamic'
如果我们想要更改整个页面的缓存行为而不仅仅是特定fetch
请求,我们可以将此段配置选项添加到文件的顶层。这将强制页面变为动态并完全退出数据缓存。
dart
export const dynamic = "force-dynamic"
export const revalidate = 0
选择将整个页面排除在数据缓存之外的另一种方法是使用revalidate
值为 0 的段配置选项
arduino
export const revalidate = 0
该行几乎相当于页面级的cache: "no-store"
.它适用于页面上的所有请求,确保没有任何内容被缓存。
缓存非fetch
请求
到目前为止,我们只了解了如何使用fetch
数据缓存来缓存请求,但我们可以做的远不止这些。
如果我们回到之前的城市指南示例,我们可能想直接从数据库中提取数据。为此,我们可以使用cache
Next.js 提供的功能。这与 React 功能类似cache
,只不过它适用于数据缓存而不是请求记忆。
javascript
import { getGuides } from "./data"
import { cache as unstable_cache } from "next/cache"
const getCachedGuides = cache(city => getGuides(city), ["guides-cache-key"])
export default async function Page({ params }) {
const guides = await getCachedGuides(params.city)
// ...
}
目前,这是一个实验性功能,因此它带有前缀 unstable_
,但它是在数据缓存中缓存非获取请求的唯一方法。
上面的代码很短,但如果这是您第一次看到该cache
函数,可能会感到困惑。
缓存函数需要三个参数(但只需要两个)。第一个参数是要缓存的函数。在我们的例子中,它是getGuides
函数。第二个参数是缓存的键。为了让 Next.js 知道哪个缓存是哪个,它需要一个密钥来识别它们。该键是一个字符串数组,对于您拥有的每个唯一缓存来说,该数组必须是唯一的。如果两个cache
函数传递了相同的键数组,它们将被视为相同的请求并存储在相同的缓存中(类似于具有相同 URL 和参数的获取请求)。
第三个参数是可选选项参数,您可以在其中定义重新验证时间和标签等内容。
在我们的特定代码中,我们缓存函数的结果getGuides
并使用 key 将它们存储在缓存中["guides-cache-key"]
。这意味着,如果我们getCachedGuides
对同一城市调用两次,第二次它将使用缓存的数据,而不是getGuides
再次调用。
下面的图表将逐步引导您了解数据缓存的运行方式。

全路由缓存
第三种类型的缓存是完整路由缓存,这种类型更容易理解,因为它的可配置性比数据缓存要少得多。此缓存有用的主要原因是它允许 Next.js 在构建时缓存静态页面,而不必为每个请求构建这些静态页面。
在 Next.js 中,我们呈现给客户端的页面由 HTML 和称为 React 服务器组件有效负载 (RSCP) 的内容组成。有效负载包含有关客户端组件如何与呈现的服务器组件一起工作以呈现页面的说明。完整路由缓存在构建时存储静态页面的 HTML 和 RSCP。
现在我们知道它存储了什么,让我们看一个例子。
javascript
import Link from "next/link"
async function getBlogList() {
const blogPosts = await fetch("https://api.example.com/posts")
return await blogPosts.json()
}
export default async function Page() {
const blogData = await getBlogList()
return (
<div>
<h1>Blog Posts</h1>
<ul>
{blogData.map(post => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<a>{post.title}</a>
</Link>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</div>
)
}
在我上面的代码中,Page
将在构建时缓存,因为它不包含任何动态数据。更具体地说,其 HTML 和 RSCP 将存储在完整路由器缓存中,以便在用户请求访问时更快地提供服务。更新此 HTML/RSCP 的唯一方法是重新部署应用程序或手动使该页面所依赖的数据缓存无效。
我知道您可能会认为,既然我们正在执行一个fetch
请求,我们就拥有动态数据,但该fetch
请求由 Next.js 缓存在数据缓存中,因此该页面实际上被认为是静态的。动态数据是在页面的每个请求中发生变化的数据,例如动态 URL 参数、cookie、标头、搜索参数等。
与数据缓存类似,完整路由缓存存储在服务器上,并在不同的请求和用户之间持续存在,但与数据缓存不同的是,每次重新部署应用程序时都会清除该缓存。
选择退出
选择退出完整路由缓存可以通过两种方式完成。
第一种方法是选择退出数据缓存。如果您为页面获取的数据未缓存在数据缓存中,则不会使用完整路由缓存。
第二种方法是在页面中使用动态数据。动态数据包括headers
、cookies
或searchParams
动态函数,以及动态 URL 参数(例如id
in )/blog/[id]
。
下图演示了完整路由缓存的工作原理的分步过程。

此缓存仅适用于您的生产版本,因为在开发过程中所有页面都是动态呈现的,因此它们永远不会存储在此缓存中。
路由器缓存
最后一个缓存有点独特,因为它是唯一存储在客户端而不是服务器上的缓存。如果没有正确理解,它也可能成为许多错误的根源。这是因为它会缓存用户访问的路由,因此当用户返回这些路由时,它会使用缓存的版本,而不会实际向服务器发出请求。虽然这种方法在页面加载速度方面是一个优势,但它也可以相当令人沮丧。下面我们就来看看为什么。
javascript
export default async function Page() {
const blogData = await getBlogList()
return (
<div>
<h1>Blog Posts</h1>
<ul>
{blogData.map(post => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<a>{post.title}</a>
</Link>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</div>
)
}
在上面的代码中,当用户导航到此页面时,其 HTML/RSCP 会存储在路由器缓存中。同样,当他们导航到任何路由时/blog/${post.slug}
,该 HTML/RSCP 也会被缓存。这意味着如果用户导航回他们已经访问过的页面,它将从路由器缓存中提取该 HTML/RSCP,而不是向服务器发出请求。
期间
路由器缓存有点独特,因为它存储的持续时间取决于路由的类型。对于静态路由,缓存会保存5分钟,而对于动态路由,缓存只会保存30秒。这意味着,如果用户导航到静态路由,然后在 5 分钟内返回,它将使用缓存的版本。但如果他们在 5 分钟后返回,它将向服务器发出请求以获取新的 HTML/RSCP。同样的情况也适用于动态路由,只不过缓存仅存储 30 秒而不是 5 分钟。
该缓存也仅为用户的当前会话存储。这意味着如果用户关闭选项卡或刷新页面,缓存将被清除。
revalidatePath
您还可以通过使用/从服务器操作中清除数据缓存来手动重新验证此缓存revalidateTag
。您还可以调用从客户端上的钩子router.refresh
获得的函数。useRouter
这将迫使客户端重新获取您当前所在的页面。
重新验证
我们已经在上一节中讨论了两种重新验证的方法,但还有很多其他方法可以实现。
我们可以根据需要重新验证路由器缓存,就像我们对数据缓存所做的那样。这意味着使用重新验证数据缓存revalidatePath
或revalidateTag
也重新验证路由器缓存。
选择退出
没有办法选择退出路由器缓存,但考虑到重新验证缓存的方法太多,这并不是什么大问题。
下面的图像直观地概括了路由器缓存的工作原理。

结论
拥有这样的多个缓存可能很难让您理解,但希望本文能够让您了解这些缓存的工作原理以及它们如何相互交互。虽然官方文档提到,使用 Next.js 并不需要了解缓存知识,但我认为了解其行为很有帮助,以便您可以配置最适合您的特定应用程序的设置。
下表总结了所有四种缓存机制及其详细信息。
缓存 | 描述 | 地点 | 重新验证标准 |
---|---|---|---|
数据缓存 | 跨用户请求和部署存储数据 | 服务器 | 基于时间或按需重新验证 |
请求记忆 | 在同一渲染通道中重复使用值以提高效率 | 服务器 | 不适用,仅持续服务器请求的生命周期 |
全路由缓存 | 在构建时缓存静态路由以提高性能 | 服务器 | 通过重新验证数据缓存或重新部署应用程序来重新验证 |
路由器缓存 | 存储导航路线以优化导航体验 | 客户 | 在特定时间后或数据缓存被清除时自动失效 |