成为全栈·Next.js 网站前台篇·数据获取与缓存:60 秒再验证背后到底发生了什么

成为全栈·Next.js 网站前台篇·数据获取与缓存:60 秒再验证背后到底发生了什么

revalidate: 60 不是每 60 秒跑一次定时任务,也不保证第 61 秒的用户立即看到新内容。只有理解过期、旧值和再生成的时序,才能知道自己向用户承诺了什么。

前言

这个网站在本地联调时,最容易让人误判的现象是:管理后台已经改了站点名称,API 直接请求也返回新值,前台却还显示旧名称。下意识的判断是"接口没调通"或"React 没更新",实际上两者都没有出错,前台只是命中了一份尚未再验证的公开缓存。

更麻烦的是,当第 61 秒的访客仍看到旧值时,开发者很容易认为 ISR 失效了。但时间再验证采用的是 stale-while-revalidate 思路:先把已有内容快速返回,再在后台生成新版本。

这篇不只讲 API 怎么写,而是把用户真正看到的时间线拆开。

当前项目有三种不同的"缓存"

这些概念很容被都叫成 cache,实际职责完全不同:

层次 项目中的例子 解决什么 生命周期
React cache() categories()、settings()、详情页 load() 同一次服务端渲染里的重复计算 渲染请求范围
Next.js Data Cache fetch(..., { next: { revalidate: 60 } }) 跨请求复用公开 API 结果 由缓存与再验证规则决定
浏览器客户端状态 React Query 中的收藏、评论、会员数据 交互后的局部复用与失效 页面会话和 QueryClient 规则

React cache() 是请求记忆化工具,并不是一个"缓存 60 秒"的持久存储。当详情页的 generateMetadata 和页面正文都调用 load(slug) 时,它能避免同一次渲染里重复取文章。而跨用户请求的复用,是 Next.js fetch 缓存在工作。

如果把两者混为一谈,就会出现两种相反误判:以为 cache() 会让数据永久不更新,或者以为它能替代跨请求的 Data Cache。

公开请求层把策略写明白

项目的 serverFetch 专门服务公开内容:

ts 复制代码
export const serverFetch = async <T>(path: string, options = {}): Promise<T> => {
  const response = await fetch(url, {
    cache: options.cache || 'force-cache',
    ...(options.cache === 'no-store'
      ? {}
      : { next: { revalidate: 60, ...options.next } }),
    signal: AbortSignal.timeout(12000),
  })

  // 解析统一信封,非 0 业务码同样抛错
  return envelope.data
}

这里有三个明确决定:

  1. 公开内容默认 force-cache,不依赖 Next.js 的隐式猜测。
  2. 缓存内容默认 60 秒再验证,个别请求可以覆盖。
  3. no-store 请求不再附加再验证参数,避免同一请求表达两种相反意图。

我喜欢这种显式写法,因为 Next.js 的默认缓存语义曾随版本变化。当前项目固定使用 Next.js 16.3.5,且未开启 cacheComponents,代码应当说清自己选的模型,不让读者靠某一个版本的默认值猜。

第 61 秒发生的不是定时刷新

假设第一个请求在 10:00:00 生成了首页缓存,内容在 10:00:30 被后台修改。一条简化时间线是:

text 复制代码
10:00:00  首次请求,生成版本 A
10:00:30  后台发布版本 B,缓存中仍是 A
10:00:50  未过期,返回 A
10:01:01  已过期,可先返回 A,同时触发后台再生成
10:01:03  版本 B 生成并写入缓存
10:01:04  后续请求返回 B

这条时间线解释了为什么"等 60 秒然后刷新一次"不一定立即看到新值。对可以短暂陈旧的内容站,这种行为优先保证响应速度和可用性。对价格、权限或会员私有数据,它就可能完全不合适。

缓存键不只有路径

serverFetch 会把 query 参数拼进 URL。文章第 1 页与第 2 页、不同分类和排序会形成不同请求。因此说"文章列表缓存 60 秒"仍然不够准确,实际上是每组 URL 与请求选项对应的内容各自管理新鲜度。

这对分页尤其重要。当新文章插入第 1 页时,旧第 1 页的最后一篇可能被挤到第 2 页。若两页在不同时刻再生成,客户端持续加载就可能遇到重复或短暂缺口。所以后面的文章流必须按 article.id 去重,而不能把分页视为永远不变的快照。

私有请求不只是 TTL 设为 0

会员数据统一走客户端请求和 /api/v1/[...path] 同源代理。代理访问上游时使用 cache: 'no-store',返回浏览器时设置 Cache-Control: private, no-store。

两层都有意义:前者告诉 Next.js 不要复用上游结果,后者告诉浏览器与中间代理不要储存这份私有响应。只写 revalidate: 0 而忽略响应缓存头,不能完整表达私有数据的边界。

这里也有一条底线:缓存策略不是鉴权。 即便响应从不缓存,后端仍必须校验 access token 和数据归属。

请求失败时,别把缓存当成全能降级

当前 serverFetch 对上游响应做了严格解析:JSON 无法识别、HTTP 非成功、业务码非 0 或缺少 data 都会抛出 ApiError。核心文章请求的错误会进入路由错误边界,而页头站点信息这类可选模块才会使用默认值。

这种分界很重要。若所有请求失败都吞掉并返回空数组,一次 502 会被页面误写成"还没有文章"。缓存可以在再验证时优先提供旧内容,但业务代码仍要区分真正的空内容和上游故障。

验证缓存要改数据,不能只刷新页面

serverFetch 还需要正确处理 URL、超时与业务错误

文章里前面的代码省略了请求构造,完整职责至少应包含下面几步:

ts 复制代码
type ServerFetchOptions = RequestInit & {
  query?: Record<string, string | number | undefined>
  next?: NextFetchRequestConfig
}

export async function serverFetch<T>(path: string, options: ServerFetchOptions = {}) {
  const url = new URL(path, API_ORIGIN)
  Object.entries(options.query || {}).forEach(([key, value]) => {
    if (value !== undefined) url.searchParams.set(key, String(value))
  })

  const response = await fetch(url, {
    ...options,
    cache: options.cache || 'force-cache',
    next: options.cache === 'no-store'
      ? undefined
      : { revalidate: 60, ...options.next },
    signal: AbortSignal.timeout(12_000),
  })

  const envelope = await response.json()
  if (!response.ok || envelope.code !== 0) throw new ApiError(envelope.message, response.status)
  return envelope.data as T
}

缓存只能复用成功获得的数据,不能代替超时和信封校验。否则一个 HTML 错误页或业务失败响应也可能沿着"成功路径"进入组件。

React cache() 解决的是同一次渲染的重复调用

ts 复制代码
import { cache } from 'react'

export const getCategoryTree = cache(async () => {
  return serverFetch<CategoryNode[]>('/categories/tree', {
    next: { revalidate: 60, tags: ['categories'] },
  })
})
tsx 复制代码
// layout、导航或页面在同一次服务端渲染中都可以调用
const categories = await getCategoryTree()

外层 cache() 负责请求内去重,内层 fetch 负责跨请求缓存。把层次写出来,才能解释为什么它们可以同时存在。

标签为以后按需失效留下接口

ts 复制代码
return serverFetch<ArticlePage>('/articles', {
  query: params,
  next: { revalidate: 60, tags: ['articles'] },
})

未来后台发布成功后,可以由受保护的内部通道调用 revalidateTag('articles')。不过标签只是失效索引,不是权限机制;再验证入口必须鉴权,也要限制允许失效的标签集合。

私有代理的响应头必须由服务端写出

ts 复制代码
const outgoing = new Headers(upstream.headers)
outgoing.set('cache-control', 'private, no-store')
outgoing.delete('set-cookie') // 普通转发不应意外复制上游会话

return new Response(upstream.body, {
  status: upstream.status,
  headers: outgoing,
})
现象 更可能检查哪一层
同一页面一次渲染里请求两遍 React cache() 或调用结构
不同访客 60 秒内看到同一公开结果 Next.js Data Cache,通常是预期行为
后台已改但过期首访仍是旧值 stale-while-revalidate 时序
A 用户看到 B 用户资料 私有响应缓存或鉴权,必须立即处理
新文章导致分页重复 分页快照漂移,客户端按 ID 去重

一个更可靠的测试脚本思路

bash 复制代码
curl -s http://localhost:3000/ -o /tmp/home-before.html
# 在管理后台将唯一测试标题 A 修改为 B
curl -s http://localhost:3000/ -o /tmp/home-within-ttl.html
# TTL 过期后请求一次触发再验证,再请求一次保存结果
curl -s http://localhost:3000/ -o /tmp/home-after.html

不要只比较 HTTP 状态码,而要比较那个唯一测试标题,同时观察后端访问日志。若要验证故障行为,就在再验证阶段让测试上游返回一次 502,再恢复服务;记录旧页面是否仍可读以及后续是否恢复为 B。

缓存验收需要一个可识别的上游版本。可以先记录页面中的站点描述或文章标题 A,再通过后台将它改为 B。接下来分别在 TTL 内、TTL 过期后的第一次请求、再生成完成后访问,才能看到 A 如何过渡到 B。

如果只在浏览器里连续按刷新,很容易忽略客户端路由缓存、预取和浏览器本身行为。更稳妥的方式是同时观察上游请求计数、页面内容和必要的响应头,并使用一个新会话复查。

还要故意测失败路径。在再生成时暂时让上游返回 502,观察系统是继续服务已有内容,还是将错误扩散给全部访客。恢复上游后,再确认后续请求能更新。只验证理想状态下的 TTL,无法证明缓存真正提升了可用性。

我们实际验证了什么

本项目已经完成了 Next.js 标准生产构建、OpenNext Cloudflare 构建,以及本地 Worker 环境下的运行验证。本地 Worker 使用模拟 R2 缓存来检查增量缓存链路。

这些证据能支持以下结论:

  • 当前代码能在 Next.js Node 产物和 OpenNext Worker 产物中完成构建。
  • 公开内容与私有代理使用了不同缓存语义。
  • 本地可观察再验证前后的内容变化。

它们不能证明远端 Cloudflare 部署已经完成,也不能证明真实 R2、多地节点和 CDN 的失效传播时间。"Worker 构建通过"和"生产缓存正确"之间,还有一整条基础设施验收链。

适用边界

60 秒是当前内容站对新鲜度的取舍。如果发布操作要求立即对外可见,应在后台发布成功后调用一条受保护的再验证通道,对相关 tag 或 path 主动失效。

如果站点部署到多个实例,还要保证缓存条目和失效事件在实例间共享。否则 A 实例已经更新,B 实例仍可能服务旧值。这时问题已经从一行 revalidate 扩展到分布式缓存协调。

小结

revalidate: 60 真正表达的是:这份公开内容可以在一段时间内复用,过期后的访问允许先获得旧值,系统再生成新版本。它是新鲜度、响应速度与上游压力之间的契约。

各位看官,下次看到一个 TTL,不要只问数字够不够小。先画出过期前、触发再验证和新值写入后的三段时间线,你才会知道第 61 秒的用户会看到什么。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
liangshanbo12156 天前
React 性能瓶颈定位面试题——原理深挖版
react·performance·profiler
liangshanbo12156 天前
React 性能优化实战
性能优化·react
FungLeo6 天前
成为全栈·React 管理后台篇·按钮级权限:能力映射、菜单过滤与自锁保护
react·rbac·路由守卫·权限控制·前端权限·成为全栈
FungLeo6 天前
成为全栈·React 管理后台篇·评论审核工作流:把状态下拉改成可理解的动作
react·内容安全·状态机·交互设计·成为全栈·评论审核
liangshanbo12157 天前
React 状态持久化面试题——原理深挖版
react·状态持久化
liangshanbo12157 天前
Redux 的中间件(Middleware)的工作机制
中间件·react·redux
FungLeo7 天前
成为全栈·React 管理后台篇·Markdown 编辑器:预览、暗色主题与连续图片粘贴
图片上传·react·响应式设计·markdown编辑器·异步编程·成为全栈
FungLeo8 天前
成为全栈·React 管理后台篇·表单页范式:校验、数据回填与未保存保护
react·表单设计·zod·react hook form·成为全栈·数据回填
名字还没想好☜8 天前
React 文件上传实战:预览 URL 回收、多文件逐个进度与拖拽放置区踩坑
前端·react·next.js