一次从页面显示「产品即将上线,敬请期待」查到 Nginx 缓存头的真实排障。涉及
__data.json、浏览器启发式缓存、no-cache的真正含义,以及一份不误伤静态资源的 Nginx 配置。
技术栈:SvelteKit 2 + Svelte 5 + @sveltejs/adapter-static,build:ssg 生成纯静态站,Nginx 直供 build-static/,后台点「一键发布」重新构建并覆盖产物。文中域名与路径已做脱敏。
1. 现象
发布完成后访问 https://example.com/rd-center#products,产品区显示空态:
text
产品即将上线,敬请期待
按一下 F5,6 个产品全出来了。开无痕窗口,一切正常。
第一反应通常是「构建没把数据打进去」。但这次不是。
2. 先确认静态产物里到底有没有数据
SvelteKit 预渲染会同时产出两样东西:
| 文件 | 内容 |
|---|---|
rd-center.html |
完整 HTML,服务端 load 的数据以 devalue 编码内联在页面里 |
rd-center/__data.json |
同一份 load 数据,供客户端导航时取用 |
__data.json 不是普通 JSON,而是 devalue 的扁平数组:
json
{"type":"data","nodes":[{"type":"data","data":[...],"uses":{...}}]}
用项目里已有的 devalue 解出来即可:
js
import { unflatten } from 'devalue';
import fs from 'fs';
const d = JSON.parse(fs.readFileSync('rd-center/__data.json', 'utf8'));
const page = unflatten(d.nodes[1].data);
console.log(page.templateData.products.length); // 6
再看响应头:
bash
curl -I https://example.com/rd-center
# HTTP/1.1 200 OK
# Last-Modified: Tue, 10 Nov 2026 13:46:25 GMT
# ETag: "6af32031-ce67"
# (没有 Cache-Control)
结论:产物是新的、数据是全的、服务器返回 200。无痕窗口正常也印证了这点。问题出在浏览器手里那份旧副本。
3. 一个页面,两份数据
理解这个现象,关键是 SvelteKit 有两种拿到数据的方式:
| 进入方式 | 数据从哪来 |
|---|---|
| 直接输网址 / F5 / 无痕窗口 | rd-center.html,数据内联在 HTML 里 |
| 站内点链接(客户端导航) | 另发一个请求取 /rd-center/__data.json?x-sveltekit-invalidated=01 |
而 Nginx 对静态文件默认只发 Last-Modified + ETag,不发 Cache-Control 。没有明确过期时间时,RFC 7234 允许浏览器自己估算一个「新鲜期」(常见做法是按 Date - Last-Modified 取一个比例)。这个估算结果可能是几小时甚至更久。
于是发布之后:
- 浏览器认为旧 HTML 还新鲜 → 连条件请求都不发 → 一直渲染旧页面里内联的空产品数组
- F5 通常会强制重新验证 → 拿到 304 或新 HTML → 产品出现
- 站内导航同理,用的是可能被缓存的旧
__data.json
「刷新一次就好」不是数据本来就缺,而是刷新恰好触发了重新验证。 只要没人给这些响应定规矩,下一次发布还会重演。
4. 第一层:管住数据小文件
先给 __data.json 定规矩:
nginx
location ~ __data\.json$ {
add_header Cache-Control "no-cache" always;
}
这里踩过一个坑,值得单独说:我最初顺手加了 try_files $uri =404;,但这台服务器的 404 兜底是 error_page 404 /index.html;(SPA 式兜底)。万一文件真缺了,会先 404、再被兜底成首页 HTML------客户端盼着 JSON,等来一个网页,报错信息完全跑偏,比不加还难查。
结论:这个 location 里不要写 try_files,让 Nginx 用默认的静态文件处理即可,它本来就会正确返回文件或 404。
5. 第二层:管住网页 HTML(难点)
想让发布后连强刷都省掉,得给 HTML 也加同样规矩。但有两个障碍:
- 页面 URL 不带
.html后缀 :/rd-center、/about、/rd-center/product/1。而location ~ \.html$匹配的是原始请求 URI ,永远匹配不到/rd-center。 - 不能误伤静态资源 :如果直接在
location /里写死no-cache,走这个 location 的 JS、CSS、图片也一起变成每次回源,带 hash 的 immutable 资源就白缓存了。
解法是按网址形态分流:带静态资源后缀的、位于 /_app/ 和 /media/ 的,返回空串;其余(页面路径 + __data.json)返回 no-cache。
完整配置
第一段放在 server { ... } 外面 ,conf.d/*.conf 里直接写在 server { 之前即可:
nginx
# 按网址形态决定缓存规矩:页面每次确认,静态文件照旧缓存
map $uri $cache_rule {
default "no-cache";
~*\.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|map)$ "";
~*^/_app/ "";
~*^/media/ "";
}
server {
# ... ssl / server_name 等原有配置 ...
root /srv/your-site/build-static;
index index.html;
# 新增这一行,由各 location 继承
add_header Cache-Control $cache_rule always;
location /media/ { alias /srv/your-site/media/; }
# 与上面的 map 效果重叠,可留可删
location ~ __data\.json$ {
add_header Cache-Control "no-cache" always;
}
# 以下保持你原有的写法,不要照抄
location / { try_files $uri $uri.html $uri/index.html /index.html; }
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
map 分流的结果:
| 请求 | $cache_rule |
效果 |
|---|---|---|
/rd-center、/about、/rd-center/product/1 |
no-cache |
每次用前确认 |
/rd-center/__data.json |
no-cache |
每次用前确认 |
/_app/immutable/chunks/xxx.js、/logo.png |
"" |
不加这个头,沿用原缓存 |
/media/xxx.jpg |
"" |
不加这个头 |
Nginx 的 add_header 在求值为空字符串时不添加该头 ,所以返回 "" 等于「不插手」,不是加了个空头。
6. 四个容易翻车的细节
no-cache 不是「不许缓存」。 它是「可以存,但每次用之前必须回源确认」。没变回 304(几乎不传内容),变了才回 200 + 新内容。要完全不存得用 no-store,对这个场景没必要。
map 只能写在 http 上下文。 塞进 server {} 会报 map directive is not allowed here。因为 conf.d/*.conf 是被 nginx.conf 的 http {} include 进来的,写在 server 块前面就合法。
add_header 的继承规则。 某一层只要自己写了 add_header,就不再继承上一层。如果 location / { ... } 里本来就有别的 add_header(比如安全头),server 级那行对它无效 ------ 把 add_header Cache-Control $cache_rule always; 也照抄一份进那个 location。
/media/ 别顺手设长缓存。 后台上传图片可能是同名覆盖,一旦给了 immutable 或很长的 max-age,管理员换了图,访客几周都看不到新的。让它保持原样最安全。
另外,正则 location 的优先级高于普通前缀 location,但低于精确匹配 location = /50x.html。新增的 __data.json 正则块不会抢走 /50x.html;如果配置里还有其他正则 location,注意它们的先后顺序(正则按出现顺序匹配,先写先命中)。
7. 验证
bash
nginx -t && systemctl reload nginx
三条请求各看各的:
bash
curl -I https://example.com/rd-center
# Cache-Control: no-cache ← 页面,对了
curl -I https://example.com/logo.png
# Expires: Thu, 10 Dec 2026 14:23:05 GMT
# Cache-Control: max-age=2592000
# Cache-Control: public, no-transform
# (没有 no-cache) ← 图片没被误伤,对了
curl -I https://example.com/rd-center/__data.json
# Cache-Control: no-cache ← 数据文件,对了
图片那两行 Cache-Control 是服务器原有配置(expires 30d 生成 max-age,另一处 add_header 加了 public, no-transform),浏览器会合并处理,不是这次引入的。判断有没有误伤只看一点:里面有没有 no-cache。
8. 效果,以及最后一次强刷
配置生效后:
- 老访客打开页面 → 带
If-None-Match问一句,没变回 304,变了拿新页面 - 老访客站内点链接 → 数据小文件同样走确认
- 图片、CSS、JS → 照旧长期缓存,不多花流量
但要注意:no-cache 只对之后收到的响应 生效。浏览器里已经存着的那份旧 HTML(当初没有任何 Cache-Control)可能仍被判定新鲜,连问都不问。所以配置上线后,自己和相关同事再强刷最后一次就彻底干净了,从那以后不用再刷。
小结
- 「刷新就好」不等于数据缺失,先解码
__data.json、看响应头,别急着怀疑构建。 - Nginx 不给
Cache-Control,浏览器就会用启发式缓存自己估新鲜期,发布后的旧页面能赖很久。 no-cache= 每次问一句,靠 304 兜住流量,是这类场景的正解。- SvelteKit 静态站的页面 URL 没有
.html后缀,用map $uri按网址形态分流,才能既管住页面又不误伤静态资源。 - 配置只管将来,已经缓存的旧页面仍需强刷一次收尾。