2025-08-27
前端
00
请注意,本文编写于 40 天前,最后修改于 40 天前,其中某些信息可能已经过时。

目录

亲历踩坑:为什么使用 Next.js + RSC 一定要加 Vary: RSC, Accept
1. RSC 请求是怎么一回事?
2. HTTP 的 Vary 是什么鬼?
为什么这很重要?
3. 那我们 RSC 场景下到底怎么处理?
4. 曾踩过的问题 & 最佳做法一览表
5. 核心实战脚本(EdgeScript)
确保所有变体都携带 Vary 以避免缓存串扰
放行 /homepage-data/*

亲历踩坑:为什么使用 Next.js + RSC 一定要加 Vary: RSC, Accept

最近踩了一个挺有意思(却很折腾)的坑:

  • 页面发版后一切看起来都对,但浏览器竟然不断去拉“看不见的 index.txt”
  • 路由跳转异常,组件状态错位,甚至乱套...

原来,一切的罪魁祸首就是:RSC payload 的缓存处理没配好


1. RSC 请求是怎么一回事?

在 Next.js 的 App Router + RSC 架构下,客户端导航不是直接拿 HTML,而是带个 RSC: 1 的请求头向服务端索取结构化 payload(也就是 index.txt)。普通加载(例如刷新页面)才拿回完整的 index.html

也就是说,同一个 URL 可能返回两种内容:结构 payload 或者完整 HTML。它俩必须分开缓存,否则就麻烦了。


2. HTTP 的 Vary 是什么鬼?

Vary 是个声明-response-header,告诉缓存系统(浏览器/CDN):“要复用这条缓存,除了 URL,还得看下面这些请求头的值。”
源地址:MDN - Vary header

oaicite:2

为什么这很重要?

举个经典例子:服务端有 📜 GZIP 压缩版也有普通版,客户端支持 gzip 就返回压缩内容,不支持就普通内容。如果你不加,缓存就可能把压缩内容发给不支持的客户端,页面就乱套了。

Smashing Magazine 用得很直接:

Vary: Accept 就是告诉缓存——别只是看 URL,还要看 Accept。”

另外 Fastly 和 Akamai 的文章里也反复提到:Vary 是缓存分片的根基——没有它,同一个 URL 的不同内容会被混用,尤其 CDN 缓存


3. 那我们 RSC 场景下到底怎么处理?

由于 RSC 请求会带 RSC: 1,返回的是 payload,而普通访问则拿回 HTML,缓存系统得分开这俩版本。

最关键的是在响应里写上:

  • RSC: 1 + Accept: text/plain.txt
  • 普通访问 + Accept: text/html.html

维护者 Tim Neutkens 在社区里也说了:

“App Router 的目的就是告诉浏览器/CDN缓存系统:这 URL 会根据请求头变内容,所以要设 Vary,让缓存别乱偷。”


4. 曾踩过的问题 & 最佳做法一览表

出的问题原因理想方案
.txt 被设为长缓存(如 1 小时)新 HTML 发版后 .txt 仍是旧结构.txt 和 HTML 用同样策略(no-cache)或设置短 TTL(如 5 分钟)
忘加 VaryCDN 直接混用 .txt.html,页面结构崩一定要在响应里加 Vary: RSC, Accept
JS/CSS 乱拉版本静态资源中途变版本,老页面引用失效用带 hash 的版本目录,静态资源单纯长缓存

5. 核心实战脚本(EdgeScript)

plaintext
# 确保所有变体都携带 Vary 以避免缓存串扰 add_rsp_header('Vary', 'RSC, Accept', true) # 放行 /homepage-data/* if match_re($uri, '^/homepage-data/') { # pass } else { # ===== 1) 带版本前缀:/v-<hash>(/...) ===== if match_re($uri, '^/v-[A-Za-z0-9]{7}(/.*)?$') { # 恰好 /v-<hash> 或 /v-<hash>/ :补 index.{txt|html} if or(match_re($uri, '^/v-[A-Za-z0-9]{7}$'), match_re($uri, '^/v-[A-Za-z0-9]{7}/$')) { if eq($http_RSC, '1') { rewrite(concat(gsub_re($uri, '^/v-', '/homepage/'), 'index.txt'), 'break') } if not(eq($http_RSC, '1')) { rewrite(concat(gsub_re($uri, '^/v-', '/homepage/'), 'index.html'), 'break') } } else { # 目录无后缀:按是否以 / 结尾补 index.{txt|html} if and(match_re($uri, '^/v-[A-Za-z0-9]{7}/'), not(match_re($uri, '\.[A-Za-z0-9]+$'))) { if match_re($uri, '/$') { if eq($http_RSC, '1') { rewrite(concat(gsub_re($uri, '^/v-', '/homepage/'), 'index.txt'), 'break') } if not(eq($http_RSC, '1')) { rewrite(concat(gsub_re($uri, '^/v-', '/homepage/'), 'index.html'), 'break') } } else { if eq($http_RSC, '1') { rewrite(concat(gsub_re($uri, '^/v-', '/homepage/'), '/', 'index.txt'), 'break') } if not(eq($http_RSC, '1')) { rewrite(concat(gsub_re($uri, '^/v-', '/homepage/'), '/', 'index.html'), 'break') } } } else { # 有后缀(含 _next/static/** 等静态资源):仅做前缀替换 rewrite(gsub_re($uri, '^/v-', '/homepage/'), 'break') } } # ===== 2) 未带版本:走 latest ===== } else { # 根或 /index → 首页 index.{txt|html} if or(eq($uri, '/'), eq($uri, '/index'), eq($uri, '/index/')) { if eq($http_RSC, '1') { rewrite('/homepage/latest/index.txt', 'break') } if not(eq($http_RSC, '1')) { rewrite('/homepage/latest/index.html', 'break') } } # 目录(以 / 结尾且无后缀)→ 补 index.{txt|html} if and(match_re($uri, '/$'), not(match_re($uri, '\.[A-Za-z0-9]+$'))) { if eq($http_RSC, '1') { rewrite(concat('/homepage/latest', $uri, 'index.txt'), 'break') } if not(eq($http_RSC, '1')) { rewrite(concat('/homepage/latest', $uri, 'index.html'), 'break') } } # 非 / 结尾且无后缀 → 补 /index.{txt|html} if and(not(match_re($uri, '/$')), not(match_re($uri, '\.[A-Za-z0-9]+$'))) { if eq($http_RSC, '1') { rewrite(concat('/homepage/latest', $uri, '/index.txt'), 'break') } if not(eq($http_RSC, '1')) { rewrite(concat('/homepage/latest', $uri, '/index.html'), 'break') } } # 有后缀的静态资源 → 仅拼 latest 前缀 if match_re($uri, '\.[A-Za-z0-9]+$') { rewrite(concat('/homepage/latest', $uri), 'break') } } }