Next.js 16 缓存策略详解:从旧模型到 Cache Components

在 Next.js 中,"缓存"从来不是一个附加能力,而是整个渲染模型的一部分。尤其到了 Next.js 16,随着 Cache Components 的引入,页面不再只是在"静态"与"动态"之间二选一,而是可以在同一个路由里同时包含静态外壳、未缓存的动态内容,以及带生命周期的缓存内容。官方文档对 Cache Components 的定义就是:在同一路由中混合静态、缓存和动态内容,并通过一个静态 HTML shell 先把页面展示出来,再逐步补充动态部分。

这篇文章想讲清两套思路。第一套是未启用 Cache Components 时的旧模型,也就是大家更熟悉的 revalidate、dynamic = 'force-dynamic'、fetch(..., { cache: 'no-store' }) 这些做法。第二套是 Next.js 16 开启 Cache Components 之后的新模型,也就是通过 Suspense 处理实时动态内容,通过 "use cache" 和 cacheLife 处理低频变化内容。

一、旧模型:未启用 Cache Components 时,Next.js 会尽量缓存

如果你没有启用 cacheComponents: true,或者显式写成 false,那么你仍然主要处在旧的 App Router 缓存模型里。Next.js 的 route segment config 文档说明,默认的 dynamic 是 'auto',框架会尽可能缓存,同时在检测到动态行为时再切换为动态渲染。也就是说,在没有特殊声明的情况下,Next.js 会尽量把路由静态化,以提升性能和降低重复计算成本。

例如下面这个页面:

复制代码
export default async function Home() {
  const randomImage = await fetch('https://www.loliapi.com/acg/pc?type=json')
  const data = await randomImage.json()

  return (
    <div>
      <h1>Home</h1>
      <img width={500} height={500} src={data.url} alt="random image" />
    </div>
  )
}

在开发模式下,你通常会发现每次刷新都重新获取图片;但构建到生产环境后,情况可能变化。因为在生产环境中,Next.js 如果判断这条路由可以预渲染,就可能把结果缓存下来,后续访问看到的就是同一份输出,而不是每次重新请求。这个行为和官方对默认缓存倾向的说明是一致的:在 dynamic = 'auto' 下,Next.js 会优先尝试缓存。

这也是很多人第一次接触 App Router 时最困惑的地方:同一段代码在开发环境里看起来"总是重新请求",但在生产构建后却"像被缓存住了"。本质原因并不是行为随机,而是 Next.js 在生产环境里会尽量静态化和缓存

二、旧模型下,如何让页面退出缓存?

在未启用 Cache Components 的旧模型中,常见的退出缓存方式主要有四类。

第一种是使用 revalidate。route segment config 文档说明,你可以在页面级别导出 revalidate 来控制重新验证时间,单位是秒。比如:

复制代码
export const revalidate = 5

export default async function Home() {
  const randomImage = await fetch('https://www.loliapi.com/acg/pc?type=json')
  const data = await randomImage.json()
  return (
    <div>
      <h1>Home</h1>
      <img width={500} height={500} src={data.url} alt="random image" />
    </div>
  )
}

这表示页面缓存会按一定间隔重新验证;如果写成 revalidate = 0,就意味着尽量不要静态缓存。需要注意的是,Next.js 官方也明确说明,在启用 Cache Components 后,像 revalidate 这样的页面级 route segment config 会被禁用,并且会逐步废弃。

第二种是使用 dynamic = 'force-dynamic'。官方文档说明,这个配置会强制整条路由在每次请求时动态渲染,其效果相当于把这一段中的所有 fetch() 视为 { cache: 'no-store', next: { revalidate: 0 } },也等价于强制不走 Data Cache。比如:

复制代码
export const dynamic = 'force-dynamic'

export default async function Home() {
  const randomImage = await fetch('https://www.loliapi.com/acg/pc?type=json')
  const data = await randomImage.json()
  return (
    <div>
      <h1>Home</h1>
      <img width={500} height={500} src={data.url} alt="random image" />
    </div>
  )
}

这种写法最直接,但粒度也最粗,因为它是整页级别的强制动态。

第三种是对单次请求使用 fetch(..., { cache: 'no-store' })。Next.js 的 fetch API 文档明确支持 cache: 'no-store',这表示这一次请求不参与 Data Cache。例如:

复制代码
export default async function Home() {
  const randomImage = await fetch(
    'https://www.loliapi.com/acg/pc?type=json',
    { cache: 'no-store' }
  )
  const data = await randomImage.json()

  return (
    <div>
      <h1>Home</h1>
      <img width={500} height={500} src={data.url} alt="random image" />
    </div>
  )
}

和 force-dynamic 相比,这种方式的优点是粒度更细,你只禁掉了某一个请求的缓存,而不是整条路由。

第四种是使用动态 API,例如 connection()。官方文档说明,connection() 的作用是让渲染等待真实请求到来之后再继续执行,它适合"这个页面没有直接用 cookies/headers 等动态 API,但我仍然希望它按运行时请求动态渲染"的情况。例如:

复制代码
import { connection } from 'next/server'

export default async function Home() {
  await connection()

  const randomImage = await fetch('https://www.loliapi.com/acg/pc?type=json')
  const data = await randomImage.json()

  return (
    <div>
      <h1>Home</h1>
      <img width={500} height={500} src={data.url} alt="random image" />
    </div>
  )
}

这种方式并不是"缓存配置",而是通过引入请求时机,让页面天然变成运行时动态渲染。

三、新模型:开启 Cache Components 后,思路彻底变了

到了 Next.js 16,如果你在配置中启用:

复制代码
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

那你就进入了新的缓存模型。官方文档说明,cacheComponents 是一个显式开启的配置项;开启之后,页面会被组织成一个静态 HTML shell,先尽快返回给浏览器,而依赖网络、请求上下文或系统 API 的部分则在后续阶段处理。也就是说,这时你不再用"整页静态 / 整页动态"来理解路由,而是用"静态外壳 + 局部动态块 + 可缓存块"来理解页面。

这里要特别纠正一个常见误解:启用 Cache Components 并不等于"所有组件默认都是动态内容"。更准确的说法是,Next.js 会尽量把不依赖外部请求和动态上下文的部分提前放进静态 shell,而那些真正的动态内容则必须被显式管理。官方文档明确写道,构建时会先预渲染组件树,把不依赖网络、系统 API 或请求上下文的内容自动加入静态外壳。

四、为什么开启后会出现 "Uncached data was accessed outside of

<Suspense>"?

这是新模型最重要的约束之一。

官方错误页面写得很清楚:当你开启 Cache Components 后,如果页面里有未缓存的数据访问,而这部分内容又没有被包在 <Suspense> 边界下面,Next.js 会报错,因为它不允许一块未缓存的动态数据阻塞整条路由。框架要求你明确声明:这块内容是动态的,它应该作为一个局部延迟块存在,而不是拖住整页。

所以在 Cache Components 模型里,真正实时、每次都要最新的数据,正确处理方式通常是:

复制代码
import { Suspense } from "react"

const DynamicImage = async () => {
  const randomImage = await fetch('https://www.loliapi.com/acg/pc?type=json')
  const data = await randomImage.json()

  return <img width={500} height={500} src={data.url} alt="random image" />
}

export default async function Home() {
  return (
    <div>
      <h1>Home</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <DynamicImage />
      </Suspense>
    </div>
  )
}

这样页面的静态部分会先展示,而 DynamicImage 这一块则在数据回来之前显示 fallback。这个模式和官方对 Cache Components 的描述是一致的:先把静态 shell 交给浏览器,再让动态内容随后更新 UI。

五、"use cache":处理低频变化数据的核心工具

如果说 Suspense 是给"必须实时"的内容准备的,那么 "use cache" 就是给"变化不频繁、可以接受一段时间复用结果"的内容准备的。

官方文档说明,"use cache" 是一个指令,可以用在文件、组件或函数层面。被标记后,Next.js 会缓存它的结果,以便后续复用。这个机制特别适合那些不依赖当前请求上下文、又不需要每次都重新抓取的数据,比如 GitHub stars、导航栏统计数、低频更新配置、某些内容目录等。

例如一个 GitHub stars 组件:

复制代码
import { cacheLife } from 'next/cache'

export default async function StarsCount() {
  'use cache'
  cacheLife('hours')

  const res = await fetch('https://api.github.com/repos/vercel/next.js')
  const data = await res.json()

  return <span>⭐ {data.stargazers_count}</span>
}

这里的意思是:这个组件的结果可以缓存,而且缓存生命周期按"小时"级别处理。这样,页面每次访问时不需要重新等待 stars 接口返回,而是可以优先命中缓存。官方文档说明,"use cache" 可以与 cacheLife 配合使用,用来声明内容多久保持新鲜、多久后需要重验证。

你也可以把 "use cache" 用在函数里:

复制代码
import { cacheLife } from 'next/cache'

export async function getStars() {
  'use cache'
  cacheLife('days')

  const res = await fetch('https://api.github.com/repos/vercel/next.js')
  return res.json()
}

或者放在文件顶部,让这个文件导出的内容都带缓存语义。官方文档明确支持这三种位置:文件、组件、函数。

六、cacheLife 到底是干什么的?

cacheLife 的作用,是给 "use cache" 标记的内容配置缓存生命周期。官方 API 文档说明,它必须和 "use cache" 一起使用,用来定义缓存多久保持有效,以及下一次什么时候需要重新验证。Next.js 还支持多种时间粒度,比如秒、分钟、小时、天等。

也就是说,在新模型里:

  • 实时动态数据,重点是不要阻塞整页,所以放到 Suspense

  • 低频变化数据,重点是减少重复请求,所以用 "use cache" + cacheLife

这个分工非常清楚。

七、旧模型和新模型到底差在哪?

旧模型更像是"整页思维"。你通常会问:

  • 这页是静态的吗?
  • 这页要不要强制动态?
  • 这页多久 revalidate 一次?

而新模型更像是"内容块思维"。你会开始问:

  • 这块内容是不是每次都要最新?
  • 这块内容能不能缓存?
  • 这块内容是不是应该先显示一个 fallback?
  • 这块内容是不是可以进静态 shell?

所以最本质的变化是:

旧模型是在路由层面决定缓存策略,新模型是在组件/内容块层面决定缓存策略。

这也是为什么官方文档会反复强调,Cache Components 不是单纯替代某一个配置项,而是引入了一种新的页面组织方式。

八、构建输出中的 ○、ƒ、◐ 应该怎么理解?

虽然这些符号不是这篇文章的重点,但它们确实能帮助你理解渲染模式。

一般可以这样理解:

○ 更接近静态预渲染。

ƒ 更接近运行时动态渲染。

◐ 则可以理解为部分预渲染,也就是页面有一个静态外壳先出来,随后再把动态内容流式补上。

这个 ◐ 的理解方式,其实和 Cache Components 的官方描述高度一致:静态 HTML shell 先返回给浏览器,动态部分随后补充。

九、实战里应该怎么选?

如果你还不确定什么时候该用什么,可以直接记下面这套规则。

如果一块数据每次请求都希望尽量最新,而且不能接受旧值,那它更适合放在 <Suspense> 下,作为未缓存动态内容处理。官方错误提示页也说明了,未缓存数据应该被显式地放进 Suspense 边界。

如果一块数据变化不频繁,比如数小时、数天更新一次就够了,那它更适合用 "use cache" 标记,再配合 cacheLife 指定缓存周期。

如果某些内容完全不依赖网络、请求上下文或系统 API,那么它们会自动进入静态 shell,成为页面初始预渲染的一部分。

十、总结

Next.js 16 之前,我们更多是在"整页静态 / 整页动态"的框架下理解缓存策略:通过 revalidate 控制重新验证,通过 dynamic = 'force-dynamic' 强制整页动态,通过 fetch(..., { cache: 'no-store' }) 精细禁用某次请求缓存,通过 connection() 等动态 API 把页面推入运行时渲染。

到了 Next.js 16,随着 Cache Components 的引入,思路明显变了。你不再主要考虑"这整页该不该缓存",而是开始考虑"这块内容该不该缓存、这块内容要不要实时、这块内容是不是应该放在 Suspense 下面"。官方文档给出的答案也很明确:把页面拆成静态 shell、动态内容和缓存内容,分别处理。

所以,这篇文章最值得记住的一句话是:

旧模型是整页决定缓存策略,新模型是每一块内容自己决定命运。

相关推荐
兜兜风1 小时前
从零部署 OpenClaw:打造你的第二大脑
前端·后端
凌览1 小时前
OpenClaw创始人炮轰腾讯"只抄不养",腾讯喊冤
前端·后端
不甜情歌1 小时前
🎭 玩转JavaScript字符串:从“反转乾坤”到“回文侦探”的趣味指南
javascript
jwn9991 小时前
【JavaEE】Spring Web MVC
前端·spring·java-ee
星辰_mya1 小时前
并发容器全家桶:选择正确的“交通工具”
java·开发语言·面试
下北沢美食家1 小时前
React面试题
前端·javascript·react.js
一方热衷.1 小时前
YOLO26-OBB ONNXruntime部署 python/C++
开发语言·c++·python
小曹要微笑1 小时前
C#的运算符重载
开发语言·c#·运算符重载·c#运算符重载
我是唐青枫1 小时前
C#.NET Channel 深入解析:高性能异步生产者消费者模型实战
开发语言·c#·.net