Next.js App Router 缓存理解:页面为什么没有按你想的更新

Next.js App Router 刚开始用时,最容易让人困惑的不是路由,而是缓存。

很多问题看起来像 bug:接口明明返回了新数据,页面却还是旧的;本地开发正常,部署后不更新;加了 revalidate 以后,有时更新有时不更新;后台刚发布文章,前台列表过一会儿才变化。

这些现象背后通常不是框架失控,而是缓存层次没有想清楚。App Router 把渲染、数据获取和缓存绑定得更紧,性能上有好处,但也要求开发者明确表达数据新鲜度。

我自己踩得最狠的一次,是把一个旧 Pages Router 项目迁过来。当时一个用 getServerSideProps 写的运营配置页,照搬成 Server Component 后直接拿 fetch 取数据,本地一切正常。结果上线两天,运营改了好几次文案前台都不动,被追着问到底有没有发版。最后才反应过来:Pages Router 时代 getServerSideProps 默认每次请求都跑,而 App Router 的 fetch 默认是会缓存的——在 Next.js 14 里没写缓存配置的 fetch,构建时就被静态化成一份了。心智模型不一样,旧习惯直接害了我。

把这件事理清楚之后会发现,App Router 里其实叠了好几层缓存:Request Memoization(同一次渲染里相同 fetch 去重)、Data Cache(跨请求的 fetch 结果缓存)、Full Route Cache(整个路由的渲染结果缓存)、还有客户端的 Router Cache。一个“页面没更新”的现象,可能命中的是这几层里的任意一层。不分清楚就乱加 no-store,往往是堵了一层、漏了另一层。

这四层其实有明确的生命周期和作用域,搞混它们是一切混乱的根源。我后来逼自己把它们记成一张表,排查时对着看:

1缓存层               作用域            存活时间               谁来失效
2Request Memoization  单次渲染(一次请求)  渲染结束即丢          自动,无需干预
3Data Cache           跨请求、跨用户、持久化  revalidate / 手动失效  revalidateTag/Path
4Full Route Cache     跨请求(构建产物或运行时) 部署或 revalidate     revalidateTag/Path
5Router Cache         单个浏览器会话内        30s(动态)/ 5min(静态),可调  router.refresh / 服务端 revalidate

最值得说的是 Request Memoization,它和 Data Cache 长得像但完全不是一回事。Request Memoization 是 React 层面的,作用域只在一次服务端渲染内:同一棵组件树里,不同组件各自 await fetch('/api/user') 拿同一份数据,底层只会真正发一次请求,其余的直接复用 promise。它依据的是 fetch 的 URL + options 做的 key,连 cache: 'no-store' 都照样去重——因为去重和缓存是两码事,前者解决“同一次渲染里别重复打接口”,后者解决“跨次渲染要不要复用结果”。这也是为什么 App Router 鼓励你“在需要数据的组件里就地 fetch”,而不是像 Pages 时代那样一定要把数据从顶层往下传 props——就地 fetch 反正会被去重,组件反而更内聚。

这里容易踩坑:Request Memoization 只对 fetch 自动生效。如果你用的是数据库 client 或者 ORM 直接查询,这层去重就没了,得自己用 React 的 cache() 包一层:

1import { cache } from 'react'
2import { db } from '@/lib/db'
3
4// 同一次渲染里多处调用 getUser(id),只会真正查一次库
5export const getUser = cache(async (id: string) => {
6  return db.user.findUnique({ where: { id } })
7})

我有个页面在 header、sidebar、主体三个地方都要当前用户信息,没包 cache() 之前一次渲染打了三次库,监控里这个查询的 QPS 莫名其妙是页面 PV 的三倍,查了半天才定位到是渲染内重复查询,包上 cache() 立刻降回来。

先不要急着写 no-store

遇到页面不更新,很多人的第一反应是:

1fetch(url, { cache: 'no-store' })

这确实能解决“旧数据”问题,但它也会放弃缓存收益。所有请求都实时获取,页面响应变慢,服务端压力变大,CDN 也帮不上太多忙。

更合理的做法是先判断数据类型:

  • 博客文章:分钟级或小时级更新可以接受
  • 商品库存:需要更短缓存,甚至实时
  • 用户订单:通常不能共享缓存
  • 后台列表:优先保证数据正确
  • 首页推荐:可以缓存,但要允许后台刷新

不同数据应该有不同策略。缓存不是开关,而是业务新鲜度的表达。

我现在的习惯是,在动手写 fetch 之前先问一句:这份数据如果晚 5 分钟更新,会有人投诉吗?会的话就往实时靠,不会的话就大胆缓存。库存这种听着像“必须实时”的数据,真做下来也未必。我们有个商品页一开始全上 no-store,QPS 一高数据库直接顶不住;后来改成 revalidate: 10,库存有十秒误差,业务方完全能接受,下单时再用动态接口做一次实时校验兜底,数据库压力立刻降下来。所谓“实时”,很多时候是被需求方的口头禅吓出来的,真按钱和体验去算账,缓存的容忍度比想象中大。

静态渲染和动态渲染

App Router 默认会尽量把页面静态化。只要页面没有使用动态函数,也没有显式要求实时数据,Next.js 就可能在构建时或缓存中复用结果。

一个博客列表可以这样写:

1export default async function BlogPage() {
2  const posts = await fetch('https://api.example.com/posts', {
3    next: { revalidate: 3600 },
4  }).then((res) => res.json())
5
6  return (
7    <main>
8      {posts.map((post) => (
9        <article key={post.id}>{post.title}</article>
10      ))}
11    </main>
12  )
13}

这表示数据最多缓存一小时。用户访问速度会比较快,内容也会周期性更新。

如果页面必须每次请求都读取最新数据,可以使用:

1export const dynamic = 'force-dynamic'
2
3export default async function OrdersPage() {
4  const orders = await getOrders()
5  return <OrderTable orders={orders} />
6}

或者针对请求使用:

1await fetch('https://api.example.com/orders', {
2  cache: 'no-store',
3})

两者传达的意图不完全一样。force-dynamic 更像是告诉页面“这个路由整体动态”,no-store 则是告诉某个请求“这份数据不要缓存”。

这个区别在混合页面里特别关键。比如一个页面顶部是缓存得住的导航和分类(一小时刷一次就行),中间嵌了一块跟登录用户相关的“我的推荐”。如果图省事直接给整个路由打 force-dynamic,那导航、分类也跟着每次重渲染,等于把能缓存的部分一起废了。更合适的做法是路由本身保持可静态,只让那块私有数据的 fetchno-store,或者干脆把它拆成一个客户端组件单独去拿。判断标准可以固定下来:能锁在单个 fetch 上的,就不要往整个路由上加。颗粒度越大,缓存收益丢得越多。

还有个容易忽略的点:一旦你在页面里用了 cookies()headers(),或者读了 searchParams,这个路由就被自动判定成动态渲染了,根本轮不到你去配 revalidate。我有次纳闷一个加了 revalidate: 3600 的页面怎么每次都重新渲染,查了半天发现是组件树深处某个地方读了一次 cookies() 做埋点。动态函数是会“传染”整条渲染链的,排查时得往深里翻。

这背后的机制其实挺优雅:Next.js 在构建时会真的去“试渲染”一遍每条路由,这个过程叫静态生成的探测。如果渲染过程中碰到了 cookies()headers()noStore() 或者带 no-storefetch,它就抛出一个内部的 bailout 信号,告诉构建器“这条路由没法静态化”,于是整条路由降级成动态。问题在于这个判定是“整条路由”粒度的,只要树里任意一个角落碰了动态函数,整页都会动态化——它不会聪明到只把那一小块动态化。

定位这种“被某个动态函数传染”的问题,与其肉眼翻代码,不如直接看 next build 的输出,被降级的路由会标成 ƒ (Dynamic),再配合一个临时手段:把可疑的子组件用 <Suspense> 包起来观察边界,或者干脆在本地用实验选项打印 bailout 原因。Next.js 后来也提供了更显式的 import { unstable_noStore as noStore } from 'next/cache',在某个组件里调一次 noStore() 就能精确表达“这块要动态”,比埋一个 cookies() 当副作用清楚得多:

1import { unstable_noStore as noStore } from 'next/cache'
2
3async function LivePrice() {
4  noStore() // 显式声明:这块每次都要最新,别静态化
5  const price = await getPrice()
6  return <span>{price}</span>
7}

fetch 缓存不是浏览器缓存

App Router 中的 fetch 缓存发生在 Next.js 的数据层,不要把它简单理解成浏览器 HTTP 缓存。

例如:

1const data = await fetch('https://api.example.com/posts', {
2  next: { revalidate: 60 },
3}).then((res) => res.json())

这里的含义是:Next.js 可以复用这次数据结果,60 秒后允许重新验证。它不是让用户浏览器缓存 60 秒,而是影响服务端渲染和数据获取行为。

这也是为什么很多“我接口已经变了,页面怎么没变”的问题,不能只看 Network 面板。你需要同时理解 Next.js 是否复用了上一次渲染结果。

这里有个反直觉的现象:打开浏览器 Network,能看到页面对接口发了请求、返回的也是新数据,但页面渲染出来还是旧的。原因在于 Network 面板里那个请求其实是客户端导航时拉的 RSC payload,而那份 payload 是服务端用 Data Cache 里的旧数据生成好、又被 Full Route Cache 存住的——接口看着调了,源头数据压根没重新取。换句话说,浏览器层面看到的“请求”和 Next.js 数据层是否真的去后端取数,是两回事,不能只看 Network 面板下结论。

要确认 Data Cache 命中情况,本地可以开 next dev 时配上日志:

1// next.config.js
2module.exports = {
3  logging: {
4    fetches: { fullUrl: true },
5  },
6}

开了之后,终端会标出每个 fetch(cache hit) 还是 (cache skip),比盯着 Network 猜要靠谱得多。生产构建时 next build 的输出也会用 (静态)、λ/ƒ(动态)标注每条路由的渲染模式,这一步我现在每次发版前都会扫一眼,能拦下不少“本来想静态结果变动态”或者反过来的意外。

revalidate 的适用场景

revalidate 适合“可以短时间接受旧数据”的页面。

1await fetch('https://api.example.com/articles', {
2  next: { revalidate: 300 },
3})

这表示 5 分钟内复用缓存,过期后允许重新生成。

它适合博客列表、文档目录、商品分类这类“内容数据”,不适合用户余额、支付状态、订单详情这类“账户数据”。我做项目时会把数据先分成这两类:内容数据尽量缓存,账户数据默认动态。这个分类比死记哪个接口该怎么配更有用,具体到每类页面该给多长的 revalidate,我放在后面“业务上怎么定策略”里详细列。

还有个细节是 revalidate 的“过期不等于阻塞”。它走的是 stale-while-revalidate 的路子:缓存过期后第一个进来的请求拿到的还是旧数据,同时后台悄悄触发一次重新生成,下一个人才能看到新的。所以你设 revalidate: 300,并不意味着第 301 秒访问的人会等着新数据出来——他大概率还是吃到旧的,只是顺手帮你“点了火”。这点在做演示时很坑:产品经理在我旁边改了后台,刷新前台没变,正要皱眉,我让他再刷一次就好了。理解这个机制,能省掉很多“到底是不是没生效”的误判。

另外别忘了,同一个 URL 配了不同 revalidate 的两处 fetch,Next.js 会取较小的那个值。我在两个组件里复用同一接口、却写了不同的过期时间,结果一直以为生效的是大的那个,查日志才发现被短的覆盖了。复用接口时,缓存配置最好抽到一个统一的封装里,别散落在各处。

手动刷新:revalidatePath 和 revalidateTag

如果后台发布文章后希望前台立刻更新,仅靠定时 revalidate 不够。这时需要手动刷新缓存。

可以给请求打 tag:

1await fetch('https://api.example.com/posts', {
2  next: {
3    tags: ['posts'],
4    revalidate: 3600,
5  },
6})

后台发布文章后调用:

1import { revalidateTag } from 'next/cache'
2
3export async function POST(request: Request) {
4  await publishPost(request)
5  revalidateTag('posts')
6
7  return Response.json({ ok: true })
8}

也可以按路径刷新:

1import { revalidatePath } from 'next/cache'
2
3revalidatePath('/blog')
4revalidatePath('/blog/my-new-post')

我的经验是:列表适合 tag,详情页适合 path。比如文章发布影响文章列表、标签页、归档页,这些可以用 posts tag 管起来;单篇文章更新,则可以刷新具体详情路径。

tag 的好处是“一处声明、一处失效,影响面自动收敛”。给文章数据打上分层 tag,发布时按需失效,能省得满项目找该刷哪几个路径:

1import { revalidateTag } from 'next/cache'
2
3export async function publishPost(post: Post) {
4  await savePost(post)
5  // 列表相关的全刷
6  revalidateTag('posts')
7  // 这篇本身单独一个 tag,详情页吃这个
8  revalidateTag(`post:${post.slug}`)
9}

对应的 fetch 这么打 tag:

1// 详情页
2await fetch(`https://api.example.com/posts/${slug}`, {
3  next: { tags: [`post:${slug}`], revalidate: 3600 },
4})

有个坑提醒一下:revalidatePath 默认刷的是页面级别。如果你要刷的是布局(layout)里取的数据,得显式传第二个参数 revalidatePath('/blog', 'layout'),否则布局里的缓存不会动。我就因为漏了这个参数,眼睁睁看着列表更新了、外层导航的红点数字却纹丝不动,排查了好一阵。

还要注意 revalidate* 只是把缓存标记为失效,并不会当场重建——真正重新生成发生在下一次有人访问那条路由的时候。所以后台点了“发布”,立刻去前台刷新看到的可能还是旧的,过一下才对。如果业务上要求点完即刻可见,那就别只靠失效,配合 router.refresh() 或者干脆让那块走动态。

一次 revalidateTag 在多实例下“偶尔不生效”的排查

revalidateTag 在多实例部署下的行为,是这套缓存机制里最容易被本地环境掩盖的一环。后台发布文章会调 revalidateTag('posts'),本地、预发都好好的,上了生产之后变成“有时刷得动、有时刷不动”。我盯着看板反复刷新才确认这不是错觉:同一篇文章连发两次,第一次前台不变,第二次就变了,规律诡异得像是随机的。

排查了大半天,根因是部署形态:我们前台跑在多个 serverless 实例上,而 Next.js 默认的 Data Cache 用的是各实例本地的文件系统缓存。运营点发布,请求被网关路由到了实例 A,revalidateTag 只清掉了实例 A 上的缓存标记;可前台用户的读请求被打到了实例 B、C,它们的缓存压根没收到失效通知,于是继续吐旧数据。第二次发布恰好又落到另一个实例,看起来“好像生效了”,其实只是赌中了路由。

1[运营] --发布--> 网关 --> 实例A  revalidateTag('posts'),A 本地缓存失效
2[用户] --访问--> 网关 --> 实例B  仍命中 B 本地旧缓存(没失效)

这类问题在单实例(比如自己一台机器跑 next start)上永远复现不出来,所以本地怎么测都是好的。真正的解法是把 Data Cache 换成共享存储。按 2024-07 这个时间点说,Next 14.1 已经把自定义 cacheHandler 标成稳定能力,可以把缓存落到 Redis 这种所有实例都能访问的地方;如果部署平台本身提供共享缓存,也可以优先用平台能力:

1// next.config.js(示意,具体实现按项目 Next 版本确认)
2module.exports = {
3  cacheHandler: require.resolve('./cache-handler.js'),
4  cacheMaxMemorySize: 0, // 关掉内存兜底,强制走共享层,避免又退化成单机
5}

cache-handler.js 里实现的就是一个最基本的 KV 接口:get/set 读写缓存内容,revalidateTag 负责按 tag 找到对应的 key 并删除——落地时额外维护一份 tag 到 key 的映射,这样 revalidateTag('posts') 才知道该清哪些具体缓存项。接口不复杂,各实例连的是同一个 Redis,失效自然就全局生效了。

如果暂时不想上自定义 handler,退而求其次的兜底是给关键内容页配一个不算长的 revalidate(比如 60 秒),让多实例的缓存最终都会自然过期对齐——失效不及时,但至少不会无限期错下去。这件事给我的教训是:缓存的正确性和你的部署拓扑强相关,单机心智模型搬到多实例上一定会出事,评审缓存策略时一定要问一句“我们生产是几个实例、缓存存在哪”。

客户端刷新不等于服务端缓存失效

App Router 里客户端可以调用 router.refresh()

1'use client'
2
3import { useRouter } from 'next/navigation'
4
5export default function RefreshButton() {
6  const router = useRouter()
7
8  return <button onClick={() => router.refresh()}>刷新</button>
9}

它会重新请求当前路由的服务端组件 payload。但如果服务端数据本身还命中缓存,用户看到的仍然可能是旧数据。

所以 router.refresh() 不是万能刷新按钮。它适合“重新获取当前路由结果”,但不等于“清空所有缓存”。如果数据需要失效,还是要配合 revalidatePathrevalidateTagno-store

这里还藏着客户端那层 Router Cache。App Router 在浏览器里会把访问过的路由片段缓存一段时间,你在站内来回点链接,回到刚看过的列表页很可能直接命中客户端缓存,连服务端都不碰。我做过一个“点进详情、改了状态、返回列表”的流程,返回后列表状态没变,第一反应又怪服务端缓存,结果根本没发请求——是 Router Cache 把旧的那份片段还给了用户。正确解法是在改完数据的 Server Action 里 revalidatePath,Next.js 会顺带让客户端那份失效;要是改动发生在纯客户端,就手动 router.refresh() 把当前路由的缓存冲掉。

把这几个动作摆在一起看会清楚很多:revalidateTag/revalidatePath 失效的是服务端的 Data Cache 和 Full Route Cache,router.refresh() 重新拉服务端结果并刷新客户端 Router Cache,no-store 则是从根上让某个 fetch 不进缓存。它们解决的是不同层的问题,遇事先想清楚“是哪一层把旧数据还给了我”,再选对应的工具,比一通乱试高效得多。

统一错误处理也要考虑缓存

缓存策略里还有一个容易忽略的点:错误响应要不要缓存?

比如文章详情页:

1export default async function PostPage({ params }) {
2  const post = await fetchPost(params.slug)
3
4  if (!post) {
5    notFound()
6  }
7
8  return <Article post={post} />
9}

如果接口短暂异常,你不能把异常结果当成正常空数据缓存起来。请求层应该区分“业务不存在”和“服务异常”:

1async function fetchJson(url: string) {
2  const response = await fetch(url, {
3    next: { revalidate: 300 },
4  })
5
6  const body = await response.json().catch(() => null)
7
8  if (response.status === 404) {
9    return null
10  }
11
12  if (!response.ok) {
13    throw {
14      status: response.status,
15      message: body?.error?.message || 'Request failed',
16    }
17  }
18
19  return body
20}

404 可以走 notFound(),500 应该抛给错误边界。不要把所有失败都当空数据处理,否则会把系统问题伪装成内容不存在。

这里还有一个更隐蔽的坑,和缓存的“原子性”有关:revalidate 缓存的是 fetch 的响应快照,包括状态码。如果你在某个 fetch 上配了 revalidate: 300,而那一刻后端正好抽风返回了 500,但你的代码没有在非 2xx 时抛错、而是把它 .json() 成了某个降级对象——那么这个“坏响应”会被原样缓存五分钟。后端早就恢复了,前台还在吐错误页,因为 Data Cache 里存的就是那份坏快照。

所以我现在的原则是:只有确定是稳定、可缓存的结果才让它进缓存,临时性错误必须在拿到响应的第一时间就抛出去,绝不让它有机会被缓存住。 对照两种写法,差别要命:

1// 危险写法:坏响应会被 revalidate 缓存住
2async function getConfig() {
3  const res = await fetch(url, { next: { revalidate: 300 } })
4  const body = await res.json().catch(() => ({})) // 500 也被吞成 {}
5  return body // {} 被缓存 5 分钟,后端恢复了前台还坏着
6}
7
8// 安全写法:非 2xx 直接抛,让这次 fetch 不产生可缓存的成功结果
9async function getConfig() {
10  const res = await fetch(url, { next: { revalidate: 300 } })
11  if (!res.ok) {
12    // 抛错会冒泡到 error.tsx,且这次 fetch 不会写入一份“成功”缓存
13    throw new Error(`config fetch failed: ${res.status}`)
14  }
15  return res.json()
16}

如果连“别缓存错误”都想兜得更稳,可以对易错接口在错误分支单独降级缓存窗口——比如平时 revalidate: 300,但探测到上游不稳定时临时切到更短或 no-store,用业务降级而不是把坏数据焊死在缓存里。

调试缓存的几个方法

排查缓存问题时,我一般按这个顺序:

第一,确认页面是否用了动态函数,例如 cookies、headers、searchParams。它们会影响渲染模式。

第二,检查每个 fetch 的缓存配置,是默认、no-store,还是 revalidate

第三,确认数据源是否本身还有缓存,比如接口网关、CDN、数据库查询缓存。

第四,确认是否触发了 revalidatePathrevalidateTag,以及 tag 名称是否一致。

第五,区分本地开发和生产部署。开发环境为了体验会有一些行为差异,不要只凭本地判断。

不过这里要泼一盆冷水:next devnext start 的缓存行为差得很远。开发模式为了热更新体验,很多缓存默认被弱化甚至关掉,next dev 下看到的“每次都重新取数”可能纯属开发态假象,到了生产就静态化了。本地怎么都复现不出线上的旧数据问题,多半是因为对照的姿势不对,得用生产构建跑本地:

1next build && next start

只有这样,Full Route Cache、Data Cache 的行为才和线上一致。配合前面的 logging.fetches,第一个实用手段是在页面里打一个构建时间戳常量,配合一句日志一起看:

1// 模块顶层求值,只在构建/重新生成时变化
2const RENDERED_AT = new Date().toISOString()
3
4export default async function Page() {
5  console.log('route regenerated at', RENDERED_AT)
6  // ...
7}

如果这个值在多次访问间纹丝不动,说明命中了 Full Route Cache,整页是缓存产物;如果每次都变,说明是动态渲染,日志本身就是最直接的证据。

第二个手段是直接看响应头。命中 Full Route Cache 的页面,响应里会带 x-nextjs-cache: HIT(或 STALE/MISS),用 curl -I 扫一眼比猜快得多,两个信号一对,到底卡在哪一层基本就锁定了:

1curl -sI https://example.com/blog | grep -i x-nextjs-cache
2# x-nextjs-cache: HIT   说明吃的是缓存产物
3# x-nextjs-cache: STALE 命中但已过期,本次会触发后台重新生成

业务上怎么定策略

我会让每个页面在评审时回答三个问题:

第一,用户能接受看到多久以前的数据?

第二,数据是否和当前登录用户相关?

第三,后台操作后是否需要立刻对前台可见?

答案决定策略:

1内容页:revalidate 3600 + 发布后 revalidatePath
2文章列表:revalidate 300 + revalidateTag('posts')
3用户订单:cache: 'no-store'
4后台管理:force-dynamic 或 no-store
5营销页配置:revalidate 600 + 后台手动刷新

这样写出来的缓存策略是可解释的,而不是遇到问题就到处加 no-store

Next.js App Router 的缓存不是一个单点功能,而是渲染模式、fetch 策略、路径刷新、tag 刷新和业务新鲜度共同作用的结果。

遇到页面不更新,不要第一时间关闭所有缓存。先判断数据属于内容、账户、交易还是后台管理,再选择 revalidateno-storeforce-dynamicrevalidatePathrevalidateTag

缓存写清楚了,Next.js 会带来很好的性能收益;缓存写含糊了,页面就会变成“有时更新,有时不更新”的黑盒。