Next.js 四层缓存体系深度解析
第13章:Next.js 四层缓存体系深度解析
请求记忆化、数据缓存、全路由缓存、客户端路由缓存——四层缓存各自解决不同层次的问题,理解每一层的作用域、生命周期和失效机制是性能优化的核心。
本章核心问题:四层缓存分别存什么、存多久、如何失效?一个完整请求如何穿越这四层?
读完本章你将理解:
- 四层缓存的作用域与生命周期对比表
- 从用户请求到数据返回的完整六步数据流
- Next.js 15 中 fetch 默认行为从 force-cache 改为 no-store 的影响
Level 1 · 你需要知道的(1-3年经验)
为什么缓存如此复杂
Next.js 文档曾经因为缓存章节的复杂性而备受开发者诟病——一张包含四层缓存、双向箭头和颜色编码的图表让许多人望而却步。但这种复杂性并非过度设计,而是现实需求的体现:一个现代 Web 应用需要同时满足极致的性能(预生成静态内容)、良好的实时性(按需更新数据)和合理的开销(不重复做相同的计算)。
四层缓存各自解决了不同层次的问题,理解每一层的作用域、生命周期和失效机制是掌握 Next.js 性能优化的核心。
第一层:请求记忆化(Request Memoization)
作用域:单次渲染树,内存中
生命周期:渲染开始到渲染结束
自动性:对 RSC 中的 fetch 请求自动生效
工作原理
Next.js 在每次渲染开始时创建一个 Map<string, Promise<unknown>>。当 React 开始渲染 Server Component 树时,每个 fetch 调用都会先检查这个 Map:
请求 URL + 选项的哈希值
↓
Map 中存在?
/ \
Yes No
| |
返回 发出真实请求
已有 Promise 将 Promise 存入 Map
返回 Promise
渲染完成后,Map 被清空。下一次请求(不同用户、不同页面刷新)从空 Map 开始。
适用范围
记忆化只对以下情况生效:
fetch请求(不包括 Prisma、axios 等)- GET 方法(POST 不被记忆化)
- 相同的 URL 和请求选项
这一层缓存的存在意义在于:允许开发者在组件树的任意位置声明数据依赖,而不必担心"提升数据获取"来避免重复请求。组件的数据获取逻辑可以与组件定义在一起,这是 Server Components 的核心设计理念之一。
// 以下两个组件都调用了 getUser('123')
// 但在同一次渲染中,只会发出一个真实的网络请求
// Header.tsx
async function Header() {
const user = await getUser('123') // 第一次调用,发出请求
return <nav>{user.name}</nav>
}
// Sidebar.tsx
async function Sidebar() {
const user = await getUser('123') // 命中记忆化,无额外请求
return <aside>{user.avatar}</aside>
}
第二层:数据缓存(Data Cache)
作用域:跨请求、跨部署实例(持久化)
生命周期:直到显式失效(revalidateTag/revalidatePath)或 TTL 过期
失效机制:按标签(revalidateTag)、按路径(revalidatePath)、按时间(revalidate)
Data Cache 是 Next.js 最重要也最容易产生混淆的缓存层。它是一个持久化的键值存储,在 Vercel 部署时由边缘基础设施维护,在本地开发时存储在文件系统(.next/cache)。
缓存键的构成
Data Cache 的缓存键由 fetch 请求的 URL、HTTP 方法和请求头的特定部分组成。两个不同的组件调用相同 URL 的 fetch,会命中同一个 Data Cache 条目。
// 这两个调用共享同一个 Data Cache 条目
// (如果有适当的 revalidate 设置)
// 在 ProductsPage 中
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] }
})
// 在 FeaturedProducts 小组件中(不同组件,不同渲染)
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] }
})
// 第二个调用命中 Data Cache,不发出真实请求
Next.js 15 的重大变更
Next.js 15 将 fetch 的默认缓存行为从 force-cache 改为 no-store。这是一个设计哲学的转变:Next.js 团队认为,选择缓存应该是显式的,而非默认隐式的。
// Next.js 14(默认 force-cache)
const data = await fetch('/api/data') // 被缓存,可能返回旧数据
// Next.js 15(默认 no-store)
const data = await fetch('/api/data') // 每次都是新鲜数据,性能较差
// Next.js 15 中如需缓存,必须显式声明
const data = await fetch('/api/data', {
next: { revalidate: 3600 } // 或 cache: 'force-cache'
})
Level 2 · 它是怎么运行的(3-5年经验)
第三层:全路由缓存(Full Route Cache)
作用域:服务端,按路由存储
生命周期:直到 revalidatePath、重新部署、或 Data Cache 失效导致级联失效
适用对象:仅限静态路由(不含动态函数)
Full Route Cache 存储的是完整的渲染结果:HTML 字符串和 RSC Payload(一种 React 服务端组件的二进制序列化格式)。当一个路由是静态的,Next.js 在构建时(或首次访问时)渲染一次,将结果存入 Full Route Cache,后续请求直接返回缓存的 HTML,无需再次运行 React 渲染。
什么使路由变为动态
以下任一情况会使路由失去静态性,无法进入 Full Route Cache:
// 1. 使用了 cookies() 或 headers()
import { cookies } from 'next/headers'
const session = cookies().get('session')
// 2. 使用了 searchParams(在 Page 组件中)
export default function Page({ searchParams }: { searchParams: { q: string } }) {
// searchParams 存在即为动态
}
// 3. fetch 使用了 cache: 'no-store'
const data = await fetch('/api', { cache: 'no-store' })
// 4. 调用了 noStore()
import { unstable_noStore as noStore } from 'next/cache'
noStore()
RSC Payload
RSC Payload 是 React 服务端组件的序列化输出,包含:
- 组件树的结构描述(不是 HTML,是 React 的虚拟 DOM 格式)
- 服务端组件的渲染结果
- Client Components 的占位符和 props
客户端收到 RSC Payload 后,React 可以进行局部水合(hydration),无需重新获取数据。这也是客户端路由切换时的数据格式——浏览器不需要 HTML,只需要 RSC Payload 来更新组件树。
第四层:客户端路由缓存(Client-side Router Cache)
作用域:浏览器内存,当前 Tab
生命周期:自动过期(动态路由 30 秒,静态路由 5 分钟),Tab 关闭后清空
存储内容:访问过的路由的 RSC Payload
当用户在应用内导航时(点击 <Link> 组件),Next.js 不发起完整的页面请求,而是从 Client-side Router Cache 查找目标路由的 RSC Payload:
用户点击 /products 链接
↓
Router Cache 中有 /products 的 RSC Payload?
/ \
Yes No
| |
直接渲染, 向服务端请求 /products 的 RSC Payload
无网络请求 存入 Router Cache,然后渲染
Next.js 15 中的缓存时间
Next.js 15 调整了 Router Cache 的过期策略:
- 动态路由(使用了
cookies()、headers()等):30 秒 - 静态路由(Full Route Cache 中的路由):5 分钟
这意味着用户在浏览静态内容页面时,5 分钟内的导航都不会产生网络请求,速度极快。但如果你刚更新了内容,用户可能需要等待最多 5 分钟才能看到最新版本。
手动使 Router Cache 失效
Server Action 执行后,revalidatePath/revalidateTag 不仅使 Data Cache 失效,也会通知客户端 Router Cache 对应路由的条目已过期:
'use server'
export async function updatePost(id: string, data: FormData) {
await prisma.post.update({ where: { id }, data: /* ... */ })
revalidatePath(`/posts/${id}`) // 同时使 Data Cache 和 Router Cache 失效
}
用户下次导航到该路由时,会重新获取最新的 RSC Payload,而不是使用旧的缓存。
Level 3 · 规范怎么定义的(资深)
四层缓存的完整数据流
理解四层缓存如何协作,最好通过一个完整请求的追踪来说明:
用户访问 /products
第1步:客户端路由缓存检查
├── 有缓存(未过期)→ 直接渲染,结束
└── 无缓存或已过期 → 继续
第2步:服务端收到请求,检查全路由缓存
├── 静态路由且有 Full Route Cache → 返回缓存的 HTML + RSC Payload,结束
└── 动态路由或无 Full Route Cache → 开始渲染
第3步:React 渲染 Server Components
└── 遇到 fetch() 调用
第4步:请求记忆化检查(同一渲染树内)
├── 本渲染中已调用过相同 URL → 返回记忆化 Promise,跳过第5步
└── 第一次调用 → 继续
第5步:数据缓存检查
├── Data Cache 有有效条目 → 返回缓存数据,更新记忆化 Map
└── 无缓存或已过期 → 发出真实网络请求,存入 Data Cache,更新记忆化 Map
第6步:React 渲染完成
└── 静态路由:将结果存入 Full Route Cache
动态路由:直接返回,不存 Full Route Cache
调试缓存状态:NEXT_PRIVATE_DEBUG_CACHE
Next.js 提供了一个调试环境变量,可以输出缓存命中/未命中的详细日志:
# 在 .env.local 中设置
NEXT_PRIVATE_DEBUG_CACHE=1
# 或在启动命令中设置
NEXT_PRIVATE_DEBUG_CACHE=1 next dev
启用后,终端会输出类似以下的日志:
[CACHE] GET https://api.example.com/products - HIT (Data Cache)
[CACHE] GET https://api.example.com/users/123 - MISS (fetching...)
[CACHE] /products - HIT (Full Route Cache)
这对于排查"为什么我的数据没有更新"或"为什么每次都在重新请求"类型的问题极为有用。
实战:选择正确的缓存策略
不同类型的页面应选用不同的缓存组合:
完全静态的营销页(About、Landing Page):
// 无需任何配置,Next.js 默认生成静态页面
// 如有数据获取,显式使用 force-cache
const data = await fetch('/api/static-content', { cache: 'force-cache' })
带有定期更新的博客文章:
// 使用时间重验 + 标签,支持按需失效
const post = await fetch(`/api/posts/${slug}`, {
next: { revalidate: 86400, tags: [`post-${slug}`] }
})
// 文章更新时:revalidateTag(`post-${slug}`)
个性化 Dashboard(必须是动态的):
import { cookies } from 'next/headers'
// 使用 cookies() 自动将页面变为动态
const session = cookies().get('session-token')
// 数据获取使用 no-store 确保实时性
const data = await fetch('/api/user/dashboard', { cache: 'no-store' })
大型电商商品列表(高频访问 + 偶尔更新):
const getCachedProducts = unstable_cache(
() => prisma.product.findMany({ where: { published: true } }),
['product-list'],
{ revalidate: 300, tags: ['products'] } // 5分钟 + 按需失效
)
缓存层级的心智模型
最后,一个有助于记忆四层缓存的框架:
| 层级 | 在哪里 | 存什么 | 存多久 |
|---|---|---|---|
| 请求记忆化 | 服务端内存(单次渲染) | fetch 的 Promise | 一次渲染 |
| 数据缓存 | 服务端持久存储 | fetch/unstable_cache 的响应数据 | 直到失效 |
| 全路由缓存 | 服务端持久存储 | HTML + RSC Payload | 直到失效 |
| 路由缓存 | 浏览器内存 | RSC Payload | 30秒-5分钟 |
理解缓存的本质:每一层都是"用存储换计算"的权衡。缓存越靠近用户(路由缓存)速度越快,但失效越难控制;缓存越靠近数据源(数据缓存),粒度越细,失效越精确。Next.js 四层缓存的设计哲学是:让最常见的情况自动高效,让不常见的情况可以手动控制。
Level 4 · 边界与陷阱(所有人)
陷阱1:Router Cache 的过期时间——动态路由 30 秒、静态路由 5 分钟——意味着用户可能短时间内看不到最新内容。
陷阱2:cookies() 和 headers() 的使用会让路由失去静态性,无法进入 Full Route Cache——这是常见的意外动态渲染原因。
陷阱3:调试缓存状态可使用 NEXT_PRIVATE_DEBUG_CACHE=1 环境变量——排查“数据没更新”或“每次都重新请求”类问题的利器。