site logo

Marico's space

Next.js App Router 缓存:哪些会被缓存(哪些不会)

前端技术 2026-07-06 11:29:07 9

最近折腾 Next.js App Router 的缓存机制,踩了几个坑才搞清楚这玩意儿到底是怎么工作的。部署上去之后调了 revalidatePath(),页面还是显示旧数据;或者关了某个 fetch 的缓存,结果页面居然还被缓存了;再或者从 Next.js 14 升级到 15 之后,原本好好的缓存策略全崩了。

这些问题的根源都一样:App Router 有 四层独立的缓存,每层都有自己的失效机制。大部分人踩坑是因为脑子里默认"缓存只有一个",实际上完全不是这么回事。

这篇文章把四层缓存说清楚。

Next.js App Router 的四层缓存

Next.js App Router 没有单一的缓存,而是四层完全不同的缓存结构,每层运行在不同上下文里,有各自的生命周期和失效方式。

缓存层 运行位置 生命周期 失效方式
请求记忆化(Request Memoization) 服务端,单次请求内 请求周期 自动失效
数据缓存(Data Cache) 服务端,持久化 直到重新验证 revalidateTagrevalidatePath
完整路由缓存(Full Route Cache) 服务端,持久化 直到重新验证 revalidatePath、重新部署
路由器缓存(Router Cache) 浏览器,会话级别 短 TTL 或导航时 router.refresh()、页面刷新

四层都得搞清楚。只了解其中一层,就是"我明明调了 revalidatePath 为什么没反应"这类 bug 的直接成因。

请求记忆化 vs 数据缓存:为什么不一样

这两层最容易搞混。

请求记忆化是自动的。如果在单次服务端渲染过程中对同一个 fetch() URL 调用了两次,Next.js 会自动去重,第二次调用直接返回第一次的结果,不会真正发网络请求。它只在单次请求内有效,渲染结束就消失。你没法配置、没法关闭、也没法持久化,它就是默默工作。

// 同一渲染过程中这两次调用只会真正发一次网络请求
const user = await fetch('/api/user/123')
const userAgain = await fetch('/api/user/123') // 自动去重

数据缓存完全不同,它是跨请求持久化的。当你在 fetch 时启用了缓存:

const data = await fetch('https://api.example.com/posts', { next: { revalidate: 60 }
})

这个结果会跨越多个用户、多次请求一直存在,直到 60 秒 TTL(缓存有效期)过期,或者你手动调用 revalidateTag()。做内部数据缓存时,你需要关注的是这层。

核心区别:请求记忆化解决的是"同一次渲染里同一个 fetch 被调用两次"的问题。数据缓存解决的是"这条数据不应该每次请求都去查数据库"的问题。解决的问题完全不同。

完整路由缓存:什么时候页面不再静态

完整路由缓存存储的是页面的渲染 HTML 和 React 服务端组件(RSC)Payload。Next.js 在构建时为静态路由生成这层缓存,之后每个访客都直接拿到这份预渲染结果,不再执行服务端组件逻辑。

页面是静态的(进入完整路由缓存)需要满足:

  • 没有使用动态函数(cookies()headers()searchParams
  • 所有 fetch 都用了 cache: 'force-cache' 或设了 revalidate

页面退出完整路由缓存(变成动态的)当:

  • 在服务端组件里用了 cookies()headers()
  • 设置了 export const dynamic = 'force-dynamic'
  • 内部某个 fetch 用了 cache: 'no-store'

这里有个坑:只要布局组件里有一个动态 fetch,整个路由都会静默变成动态的。我见过实际案例,一个完全静态的营销页面因为某个深层嵌套的组件调用了 cookies() 做分析统计,结果每次请求都去查数据库。

revalidatePath() 和重新部署都会使完整路由缓存失效。revalidateTag() 只有在路由的 fetch 调用恰好用了相同 tag 时才会让它失效。

路由器缓存:让所有人意外的客户端缓存

路由器缓存是调用 revalidatePath() 后页面没更新的最常见原因。

它存在浏览器里,不在服务端。Next.js 在内存里缓存了预取和访问过的路由分段,有效期是用户会话期间。这意味着即使你在服务端让数据缓存和完整路由缓存都失效了,用户可能还是看到旧内容——因为他们的浏览器在用自己的缓存。

清理它只有两种方式:

  1. 在客户端组件里调用 router.refresh()
  2. 手动刷新浏览器页面
'use client' import { useRouter } from 'next/navigation' function SubmitButton() { const router = useRouter() async function handleSubmit() { await updateData() router.refresh() // 清除当前路由的路由器缓存,触发重新验证 } return <button onClick={handleSubmit}>提交</button>
}

重要的事:服务端 Action 里调用 revalidatePath() 不会在同一次请求里清除路由器缓存。如果你想让页面立即更新,客户端还是得调 router.refresh()

按需重新验证:revalidatePath 和 revalidateTag

基于时间的重新验证(revalidate: 60 这种)适合允许一分钟延迟的数据。如果数据变了必须立刻更新,就得用按需重新验证。

revalidatePath(path) 让指定路径关联的所有完整路由缓存和数据缓存失效。适合影响整页的变更。

revalidateTag(tag) 更精准。先给 fetch 打标签,然后只让打了这个标签的数据失效。

// fetch 时打标签
const posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }
}) // 变更后只让带 posts 标签的 fetch 失效
import { revalidateTag } from 'next/cache' async function createPost(data: FormData) { 'use server' await savePost(data) revalidateTag('posts') // 只有带 posts 标签的 fetch 会被失效
}

对于 unstable_cache(服务端等效的 SWR,适用不走 fetch 的数据库查询或其他数据),失效方式一样,用 revalidateTag

import { unstable_cache } from 'next/cache' const getProducts = unstable_cache( async () => db.query.products.findMany(), ['products'], { tags: ['products'] }
)

Next.js 15 缓存变更:这个破坏性变更你得知道

如果你从 Next.js 14 升级到 15 后数据不再被缓存了,原因就在这。

在 Next.js 14 里,fetch() 默认是缓存的。默认值就是 cache: 'force-cache',想关闭缓存得显式写 cache: 'no-store'

在 Next.js 15 里,fetch() 默认不再缓存了。默认值变成了 cache: 'no-store',想启用缓存得显式声明。

这是个破坏性变更,升级时不会有任何弃用警告。

// Next.js 14 默认——会被缓存
const data = await fetch('https://api.example.com/data') // Next.js 15 默认——不会被缓存(同样的代码,不同的行为)
const data = await fetch('https://api.example.com/data') // Next.js 15 想启用缓存得这样写
const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 }
})
// 或者
const data = await fetch('https://api.example.com/data', { cache: 'force-cache'
})

迁移方案:排查项目里每个 fetch 调用,在你确实需要缓存的地方补上显式配置。只依赖默认行为的数据,应该是你本来就希望每次请求都获取最新值的。

FAQ

Next.js App Router 缓存是怎么工作的?

Next.js App Router 有四层缓存:请求记忆化(在单次服务端渲染内对 fetch 调用去重)、数据缓存(fetch 结果跨请求持久化直到重新验证)、完整路由缓存(静态路由的预渲染 HTML)、路由器缓存(浏览器内存里缓存预取的路由分段)。每层有各自的失效机制。

Next.js 14 里怎么关闭缓存?

想让单个 fetch 不缓存,加 { cache: 'no-store' } 作为第二个参数:fetch(url, { cache: 'no-store' })。想让整个路由始终动态,在路由文件顶部加 export const dynamic = 'force-dynamic'。注意 Next.js 15 里 fetch 默认不缓存了,如果已经升级到 15,可能不需要额外处理。

fetch 缓存和路由段配置的有什么区别?

fetch 缓存(fetch() 上的 cache 选项或 next.revalidate)控制的是那个特定数据请求的数据缓存。路由段配置(export const dynamicexport const revalidate)设置的是整个路由的默认行为。路由段配置比较粗暴,单独的 fetch 选项更精准也更灵活。

router.refresh() 是干什么的?

它触发一次到当前路由的软导航,清除当前路由的路由器缓存并重新获取服务端组件数据。它不会做完整的浏览器刷新。在客户端组件里做服务端变更后,如果需要 UI 立即反映最新数据,就用它。

为什么 unstable_cache 还带着 unstable_ 前缀?

这个 API 在团队迭代接口时被标记为不稳定。功能本身已经是生产可用的,用得很广泛。这个前缀表示 API 形状可能在未来的大版本里有变化。用是可以用的,但如果 API 变了就得准备迁移。