第 13 章

Next.js 四层缓存体系深度解析

第13章:Next.js 四层缓存体系深度解析

请求记忆化、数据缓存、全路由缓存、客户端路由缓存——四层缓存各自解决不同层次的问题,理解每一层的作用域、生命周期和失效机制是性能优化的核心。

本章核心问题:四层缓存分别存什么、存多久、如何失效?一个完整请求如何穿越这四层?

读完本章你将理解


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 开始。

适用范围

记忆化只对以下情况生效:

这一层缓存的存在意义在于:允许开发者在组件树的任意位置声明数据依赖,而不必担心"提升数据获取"来避免重复请求。组件的数据获取逻辑可以与组件定义在一起,这是 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 服务端组件的序列化输出,包含:

客户端收到 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 的过期策略:

这意味着用户在浏览静态内容页面时,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 环境变量——排查“数据没更新”或“每次都重新请求”类问题的利器。

本章评分
4.7  / 5  (22 评分)

💬 留言讨论