在 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、动态内容和缓存内容,分别处理。
所以,这篇文章最值得记住的一句话是:
旧模型是整页决定缓存策略,新模型是每一块内容自己决定命运。