4736 words
24 minutes
Next.js 15 动态 IO 缓存:终结过度缓存的利器
2025-08-16 13:29:10
2025-08-20 23:47:38

缓存是把双刃剑——一旦它开始与你作对,后果便不堪设想。长期以来,过度缓存一直困扰着使用 Next.js App Router 的开发者,将这个本应提升性能的工具,变成了一个隐形的“用户体验杀手”。自 v13 版本首次亮相以来,Next.js 激进的默认缓存策略已导致数据陈旧、与后端源不一致,以及各种令人沮丧的异常行为。

Fix-Overcaching-With-Dynamic-IO-LogRocket.png

Next.js 15 引入了动态 IO 缓存,这是一种更灵活、更精细的控制缓存内容和时间的方式。它在性能和新鲜度之间取得了平衡,而无需开发人员在完全缓存和无缓存之间做出选择。

在本指南中,我们将深入剖析早期版本 Next.js 中由过度缓存引发的种种问题,并展示动态 IO 如何让缓存控制变得更智能、更简单、更直观。

Next.js 缓存的问题#

与传统的缓存(更多是手动的,并且通常与服务器响应头或外部缓存如 Redis 或 Varnish 相关联)不同,Next.js 使用一种直接集成到框架架构中的缓存范例。这意味着缓存是自动处理的,并扩展到整个请求-响应生命周期。

Next.js 缓存 不依赖于单一机制。相反,它分层了多种策略,包括数据缓存、请求记忆化和路由器缓存。这种多层方法非常有效:它减少了冗余数据获取,加快了页面加载速度,并减轻了后端的负载。

然而,这种分层系统也带来了一些挑战。有趣的是,它实际上可能工作得太好了。在许多情况下,Next.js 最终会过度缓存应用程序,忽略动态更改并提供陈旧数据。而且由于程序的多层设计,通常很难确定缓存问题的确切来源。

假设我们有一个简单的应用程序,它向 API 发出异步请求并获取每次请求都会更改的数据,如下所示:

import Image from "next/image"; const apiKey = "..."; async function fetchImage() { const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}` ); const res = await req.json(); return { imageURL: res.urls.full, }; } export default async function Home() { const { imageURL } = await fetchImage(); return ( <div className="..."> <main className="..."> <div className="..."> <Image className="dark:invert" src={imageURL} alt="Next.js logo" width={300} height={200} priority /> </div> </main> </div> ); }

此代码在每次请求时从 Unsplash API 获取并显示一张随机图像。

logrocket_1.gif

默认情况下,Next.js 会缓存此页面,即使它正在获取动态内容。虽然如上例所示,图像在每次重新加载时可能会更改,但这并不一定意味着路由没有被缓存。事实上,我们可以通过检查 Next.js 开发覆盖层来确认路由正在被缓存。

logrocket_2.png

“static”标签表示页面正在被缓存并将被静态生成。 我们还可以通过构建应用程序来观察缓存行为。一旦构建,页面将被渲染为完全静态的资产,除非手动禁用或绕过缓存,否则每次请求都会提供相同的内容。

Image-Loads-Instantly.gif

图像现在可以立即加载,但这就是问题所在。应用程序没有重新验证,而是从缓存中提供陈旧的内容,包括最初获取的图像。本应是动态的内容实际上被冻结了。

幸运的是,Next.js 提供了退出此默认缓存行为的方法。一种选择是向 fetch 调用添加 cache: 'no-store',这可以确保数据始终是动态获取的。或者,您可以使用实验性的 noStore() 函数来选择退出每个组件或每个函数的数据缓存。

async function fetchImage() { const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}`, { cache: "no-store", } ); const res = await req.json(); return { imageURL: res.urls.full, }; } ...

理论上,这应该会使页面动态化并解决陈旧数据问题,如下所示:

Dynamic-Page-No-Static.gif

然而,情况并非总是如此。我们讨论过的任何其他缓存层可能仍处于活动状态,并阻止退出措施按预期工作。

进入动态 IO#

动态 IO 是 Next.js 15 中的一项实验性功能,它颠覆了默认的缓存模型。与以往自动缓存 App Router 中所有异步操作不同,动态 IO 默认选择退出缓存。现在,您可以主动选择要缓存的内容,而不是被动地排除不需要缓存的内容。

这一变化赋予了开发者对数据新鲜度的精细控制权,使他们无需再为绕开框架的默认设置而烦恼。目前,动态 IO 仅在 Next.js canary 版本中可用,因此您需要升级才能访问它。

npm install next@canary

完成后,通过将 dynamicIO 实验性标志添加到您的 next.config.ts 文件并将其设置为 true 来启用动态 IO:

import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { dynamicIO: true, }, } export default nextConfig

动态 IO 的工作原理#

启用动态 IO 后,它会自动检测异步操作,例如数据获取或任何 async/await 的使用,并将页面标记为动态。例如,在我们之前使用 fetchImage() 的代码中,动态 IO 将默认将该页面视为动态。

async function fetchImage() { const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}` ); const res = await req.json(); return { imageURL: res.urls.full, }; }

从本质上讲,在路由中使用动态数据会自动禁用静态渲染。请求记忆化等功能(通常在 App Router 中缓存 fetch() 调用)被绕过。因此,fetch() 的行为就像在完全动态的环境中一样,返回新数据而没有任何缓存层。

启用动态 IO 并重建项目后,应用程序会将页面视为动态。您将在运行时收到实时数据,而不是陈旧的、预渲染的内容。

Suspense 的必要性#

当动态 IO 处于活动状态时,任何未明确缓存的异步代码——尤其是访问 params 或动态路由段的代码——都必须用带有回退 UI 的 <Suspense> 边界包裹起来。这可以确保页面在渲染期间能够妥善处理异步行为,而不会导致 UI 破坏或延迟。

import Image from "next/image"; import { Suspense } from "react"; const apiKey = "..."; async function fetchImage() { const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}` ); const res = await req.json(); return { imageURL: res.urls.full, }; } async function SuspendedImageComponent() { const { imageURL } = await fetchImage(); return ( <> <Image className="dark:invert" src={imageURL} alt="Next.js logo" width={300} height={200} priority /> </> ); } export default async function Home() { return ( <div className="..."> <main className="..."> <div className="..."> <Suspense fallback={<p>Loading image...</p>}> <SuspendedImageComponent /> </Suspense> </div> </main> </div> ); }

为了正确处理这个问题,我们创建了一个名为 <SuspendedImageComponent /> 的独立组件来封装动态图像获取逻辑。这使我们能够将其包装在 Suspense 边界中,如上面的代码所示。

不这样做将导致运行时错误,如下所示:

Runtime-Error-Nextjs.png

此设置允许 Next.js 立即提供静态内容,并在动态内容解析时以流式传输方式提供,从而提高了加载性能和数据新鲜度。

增量缓存#

如前所述,缓存在性能和可伸缩性中起着至关重要的作用。动态 IO 不会移除缓存。相反,它引入了一种更深思熟虑的增量模型。您可能希望缓存昂贵的操作或避免冗余的网络请求,同时仍允许其他数据保持动态。为了支持这一点,Next.js 提供了让您在有意义的地方选择加入缓存的工具。

使用 ‘use cache’ 进行缓存#

'use cache' 指令的工作方式类似于 'use client''use server',但它不是确定运行时位置,而是控制缓存行为。将 'use cache' 放在文件顶部可以为整个模块启用缓存:

"use cache"; import Image from "next/image"; import { Suspense } from "react"; async function fetchImage() { ... } async function SuspendedImageComponent() { ... return ( <> ... </> ); } export default async function Home() { return ( <div > ... </div> ); }

use cache 指令的一个常见用例是提高执行繁重异步操作的组件的性能。当动态组件由于昂贵的逻辑而阻塞渲染时,缓存它可以确保该操作不会在每次重新渲染时重新运行。

您可以采用混合方法,而不是缓存整个应用程序,方法是将 use cache 应用于仅从中受益的组件或页面。这使您可以优化关键区域的速度,同时在需要时保留动态行为。

然而,并非每个异步操作都是阻塞的或每次请求都需要新数据。在这些情况下,缓存整个文件可能有些过头了。幸运的是,Next.js 还允许在单个异步函数中应用 use cache

在我们的阻塞组件示例中,假设繁重的异步操作被封装如下:

async function heavyOperation(): Promise<string> { // 模拟一个耗时的异步操作 return new Promise((resolve) => { setTimeout(() => { resolve('Heavy result calculated and cached'); }, 3000); }); }

您可以直接在函数中应用 use cache 指令来仅缓存该特定操作的结果,而不是缓存整个组件:

async function heavyOperation(): Promise<string> { 'use cache'; // 模拟一个耗时的异步操作 return new Promise((resolve) => { setTimeout(() => { resolve('Heavy result calculated and cached'); }, 3000); }); }

此设置允许 Next.js 缓存和重用 heavyOperation() 的结果,避免重复执行,直到数据被明确重新验证。

使用 cacheTag 重新验证 use cache#

cacheTag() 函数提供了一种标记缓存条目以进行有针对性的重新验证的方法。通过为函数或文件分配基于字符串的标签,您可以选择性地清除其缓存,而不会影响应用程序中的其他数据。

我们的随机图像示例是使用 cacheTag() 的一个很好的候选者。假设我们想按需使 fetchImage() 函数无效。我们首先导入 cacheTag() 并分配一个标签,如下所示:

async function fetchImage() { "use cache"; cacheTag("unsplash-image"); const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}` ); const res = await req.json(); return { imageURL: res.urls.full, }; }

现在,我们可以通过调用 revalidateTag() 函数并传入与我们要重新验证的函数关联的缓存标签字符串来触发 fetchImage() 函数的重新验证。

import { unstable_cacheTag as cacheTag, revalidateTag } from "next/cache"; async function revalidateData() { "use server"; revalidateTag("unsplash-image"); } ... export default async function Home() { return ( <div className="..."> <main className="..."> <div className="..."> <Suspense fallback={<p>Loading image...</p>}> <SuspendedImageComponent /> </Suspense> <form action={revalidateData}> <button className=...” type="submit">Revalidate</button> </form> </div> </main> </div> ); }

在此示例中,我们使用表单操作而不是带有 onClick 处理程序的按钮来触发 revalidateTag()。由于 revalidateTag() 是一个仅限服务器的函数,因此如果没有额外的设置,它不能直接从客户端事件处理程序中调用。使用表单提交可以使逻辑与服务器兼容,并且更易于推理。

重建项目后,返回浏览器。最初,将显示缓存的图像。单击 Revalidate 按钮并刷新页面后,应加载新图像,确认缓存已成功失效。

Invalidating-Cache.gif

cacheTag() 函数使您可以对应用程序中的缓存进行精细控制。但是,如果您不希望采用这种手动方法,并希望在自己设定的时间自动完成,该怎么办?

使用 cacheLife 进行基于时间的重新验证

cacheTag() 函数非常相似,cacheLife() 函数允许您控制缓存函数或文件的重新验证,但采用不同的方法。传统上,在使用 Next.js 中的缓存时,您唯一能控制的是服务器端重新验证周期,它决定了缓存在服务器上刷新的频率。

然而,使用新的 cacheLife() 函数,您还可以控制客户端缓存行为,特别是陈旧时间。这表示在自动丢弃之前,一段缓存数据被认为是新鲜的时间。

要使用 cacheLife(),只需导入它并在要缓存的函数或组件中调用它,如下所示:

import { unstable_cacheTag as cacheLife} from "next/cache"; async function fetchImage() { 'use cache'; cacheLife('hours'); const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}` ); const res = await req.json(); return { imageURL: res.urls.full, }; }

在上面的示例中,传递给 cacheLife() 函数的 “hours” 字符串参数是几个内置缓存配置文件之一,这些配置文件确定了缓存数据在过期前保持有效的时间。

以下是一些可用的缓存配置文件:

  • "seconds":非常适合需要近乎实时更新的快速变化的内容。

  • "minutes":最适合在一小时内频繁更新的内容。

  • "hours":适用于每天更新但可以稍微陈旧的内容。

如果未指定缓存配置文件,cacheLife() 将使用 Next.js 提供的默认配置文件。有关可用缓存配置文件及其行为的完整列表,请参阅官方 Next.js 文档

自定义缓存配置文件#

cacheLife() 函数之所以特别强大,是因为它支持自定义缓存配置文件,这使您可以根据内容的更新频率以及数据在刷新前应保持有效的时间来微调缓存行为。

缓存配置文件是包含三个关键属性的对象:

  • stale:客户端在不检查服务器的情况下应缓存值的时间。

  • revalidate:缓存应刷新的频率。

  • expire:值在切换到动态获取之前可以保持陈旧的最长持续时间。

要创建自定义缓存频率,您需要在 next.config.tscacheLife 选项中定义它,并为上述突出显示的属性提供一个可重用的名称:

const nextConfig: NextConfig = { experimental: { dynamicIO: true, }, cacheLife: { imageCache: { stale: 60 * 60 * 24 * 5, // (客户端) 提供 5 天的陈旧缓存 revalidate: 60 * 60 * 24, // (服务器) 每日刷新 expire: 60 * 60 * 24 * 5, // (服务器) 最长生命周期: 5 天 }, }, }; export default nextConfig;

上面示例中的自定义 cacheLife 配置文件将缓存 5 天,每天检查更新,并在 5 天后使缓存过期。

定义后,此自定义缓存配置文件可以在您的应用程序中的任何位置引用。因此,您可以简单地使用自定义的 “imageCache” 配置文件,而不是像我们在上一个示例中所做的那样使用内置的配置文件,如 “hours”

import { unstable_cacheTag as cacheLife} from "next/cache"; ... async function fetchImage() { "use cache"; cacheLife("imageCache"); const req = await fetch( `https://api.unsplash.com/photos/random?client_id=${apiKey}` ); const res = await req.json(); return { imageURL: res.urls.full, }; } ...

或者,如果您很难为缓存配置文件想出一个有意义的名称,您可以简单地用自己的自定义配置覆盖 Next.js 的内置配置文件之一。

例如,将我们的自定义配置文件命名为 imageCache 是可行的,但它不是特别直观。相比之下,像 “hours”“days” 这样的内置名称更具自解释性,并且一目了然。由于我们的自定义配置更适合多天缓存,因此覆盖 “days” 配置文件会更有意义。

为此,只需在 next.config.tscacheLife 配置中将自定义配置文件的可重用名称替换为 “days”。这将用您的自定义设置覆盖默认的 “days” 值。

const nextConfig: NextConfig = { experimental: { dynamicIO: true, }, cacheLife: { days: { stale: 60 * 60 * 24 * 5, // (客户端) 提供 5 天的陈旧缓存 revalidate: 60 * 60 * 24, // (服务器) 每日刷新 expire: 60 * 60 * 24 * 5, // (服务器) 最长生命周期: 5 天 }, }, }; export default nextConfig;

定义自定义 cacheLife 配置文件还有其他配置选项。有关高级用例和支持的参数的完整分解,请参阅官方文档

真实世界用例#

虽然我们到目前为止使用的示例有助于概念化动态 IO 的工作原理,但它们并不能完全说明它在真实世界场景中的表现。鉴于过度缓存问题在复杂应用程序中最为常见,因此了解这项新功能如何有效解决实际生产级环境中的此类挑战非常重要。

让我们以一个仪表板应用程序为例,该应用程序在不同部分显示各种内容类型。

Dashboard-Use-Case.gif

默认情况下,整个仪表板都会被缓存,即使它的许多组件都是动态的。虽然收入图表和用户指标等组件不需要实时更新并且可以被缓存,但其他组件(如分析卡和最近的活动)需要在每次请求时获取新数据以显示最新信息。

如您所知,启用动态 IO 将通过使整个页面动态化来解决过度缓存问题。然而,这引入了一个新的问题:性能。让每个组件在每次请求时都获取数据会对性能产生负面影响,并显着增加服务器成本。这就是选择性缓存变得至关重要的地方。

由于用户指标和收入图表不总是需要新数据,我们可以使用 cacheLife 函数来缓存它们的数据获取逻辑,以便它们仅在指定的时间间隔后重新获取数据。

async function getRevenueData() { 'use cache'; cacheLife('minutes'); // API call ... return [ ... ] }

用户个人资料和侧边栏组件可以永久缓存,因为它们的内容很少更改。相比之下,系统通知组件可以使用标签进行缓存,从而可以在更新发生时有选择地重新验证它。

async function getSystemNotifications() { 'use cache'; cacheTag('system-alerts'); // API call ... return [ { ... ] }

然而,分析卡和最近的活动组件应保持动态,因为它们需要在每次请求时获取新数据以显示实时活动源和实时指标。这种选择性方法使我们能够在数据新鲜度和性能之间取得平衡,即使在高度复杂的应用程序中也是如此。

结论#

动态 IO 标志着 Next.js 中缓存处理方式的重大演变。通过将控制权交还给开发人员,它解决了围绕过度缓存的长期痛点,同时实现了更有意图、以性能为导向的工作流程。

借助 use cachecacheTag()cacheLife() 等工具,Next.js 现在支持真正的增量缓存模型,该模型可从单个函数扩展到完整组件,而不会牺牲灵活性。虽然仍处于实验阶段,但动态 IO 显示出明显的潜力,可以成为使用 Next.js 构建的现代数据密集型应用程序中的默认缓存策略。

随着框架的不断发展,尽早掌握这些功能可以为团队带来性能优势,并为大规模动态内容提供更清晰、更可预测的方法。

banner

加入我们,共译前端好文

我们不靠算法推荐,而是靠前端开发者对技术趋势的敏锐、对知识分享的热爱,手动为你筛选每周全球社区中最新、最热、最值得关注的前端文章。@海外前端动态情报源清单(持续更新)

qrcode
Next.js 15 动态 IO 缓存:终结过度缓存的利器
https://0bipinnata0.my/posts/weekly-translate/dynamic-io-caching-next-js-15/
Author
0bipinnata0
Published at
2025-08-16 13:29:10