Vary: RSC, Accept
最近踩了一个挺有意思(却很折腾)的坑:
原来,一切的罪魁祸首就是:RSC payload 的缓存处理没配好。
在 Next.js 的 App Router + RSC 架构下,客户端导航不是直接拿 HTML,而是带个 RSC: 1
的请求头向服务端索取结构化 payload(也就是 index.txt
)。普通加载(例如刷新页面)才拿回完整的 index.html
。
也就是说,同一个 URL 可能返回两种内容:结构 payload 或者完整 HTML。它俩必须分开缓存,否则就麻烦了。
Vary
是什么鬼?
Vary
是个声明-response-header,告诉缓存系统(浏览器/CDN):“要复用这条缓存,除了 URL,还得看下面这些请求头的值。”
源地址:MDN - Vary headeroaicite:2
举个经典例子:服务端有 📜 GZIP 压缩版也有普通版,客户端支持 gzip 就返回压缩内容,不支持就普通内容。如果你不加,缓存就可能把压缩内容发给不支持的客户端,页面就乱套了。
Smashing Magazine 用得很直接:
“
Vary: Accept
就是告诉缓存——别只是看 URL,还要看 Accept。”
另外 Fastly 和 Akamai 的文章里也反复提到:Vary
是缓存分片的根基——没有它,同一个 URL 的不同内容会被混用,尤其 CDN 缓存
由于 RSC 请求会带 RSC: 1
,返回的是 payload,而普通访问则拿回 HTML,缓存系统得分开这俩版本。
最关键的是在响应里写上:
RSC: 1
+ Accept: text/plain
→ .txt
Accept: text/html
→ .html
维护者 Tim Neutkens 在社区里也说了:
“App Router 的目的就是告诉浏览器/CDN缓存系统:这 URL 会根据请求头变内容,所以要设
Vary
,让缓存别乱偷。”
出的问题 | 原因 | 理想方案 |
---|---|---|
.txt 被设为长缓存(如 1 小时) | 新 HTML 发版后 .txt 仍是旧结构 | 将 .txt 和 HTML 用同样策略(no-cache)或设置短 TTL(如 5 分钟) |
忘加 Vary | CDN 直接混用 .txt 和 .html ,页面结构崩 | 一定要在响应里加 Vary: RSC, Accept |
JS/CSS 乱拉版本 | 静态资源中途变版本,老页面引用失效 | 用带 hash 的版本目录,静态资源单纯长缓存 |
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') } } }