静态站上线不是「把文件夹传上去」:缓存、压缩、以及诚实的 404
纯静态托管的坑几乎全在响应头上。哪些文件能永久缓存、哪些一秒都不能缓存、以及为什么静态站绝对不能做 SPA 式回退。
静态站没有后端,所以最容易产生一种错觉:构建完把 dist/ 传上去就完事了。实际上真正会出事的地方全在响应头上——缓存策略、压缩、状态码。这些东西本地 npm run dev 永远测不出来,只有上了 Nginx 才暴露。
产物里其实是三类东西
先把 dist/ 分清楚,因为它们的缓存策略必须不同:
| 类别 | 例子 | 文件名会变吗 |
|---|---|---|
| 内容寻址资源 | _astro/index.B3eZ-5oA.css |
会,名字里有内容哈希 |
| HTML | zh/blog/design-tokens/index.html |
不会,地址和文件名都是固定的 |
| 固定名静态资源 | favicon.ico、og-image.png、logo-mark-64.png |
不会 |
这个区分决定了后面全部内容。
缓存:按「名字会不会变」分两档
只有文件名里带内容哈希的资源才配拿到
immutable。
_astro/ 下带哈希的东西可以永久缓存:
location /_astro/ {
# 文件名含内容哈希,内容变了名字就变,所以可以放心长缓存
add_header Cache-Control "public, max-age=31536000, immutable";
}
HTML 恰恰相反。/zh/blog/design-tokens/ 这个地址是永不改变的,如果给它上了长缓存,改完文章之后老访客会一直看到旧版本,而且你没有任何办法让他们刷新——地址没变、文件名没变、etag 也可能没变。
location / {
try_files $uri $uri/index.html $uri.html =404;
# HTML 每次都回源校验:它是唯一"名字不变但内容会变"的东西
add_header Cache-Control "no-cache";
}
no-cache 不是“不缓存”,是“每次用之前先问一下”。配合 etag,没变就是 304,几乎不花流量;变了立刻生效。这是静态站最容易配错、后果又最严重的一条。
固定名的图片/图标处在一个尴尬位置:它们没有哈希,所以给 immutable 就一定会出现“换了 logo 但用户看到旧的”。要么给一个较短的 TTL,要么在引用处加查询串。我给的是短 TTL —— 因为换 logo 的频率远低于文章。
压缩:文本压,图片绝不压
gzip on;
gzip_static on; # 优先用构建期压好的 .gz,省 CPU
gzip_types text/css application/javascript application/json
image/svg+xml application/xml;
# 图片、字体已经是压缩格式,再压一遍只浪费 CPU
gzip_vary on;
两个点值得强调:
- 不要压图片。 JPEG / PNG / WebP / AVIF / WOFF2 内部已经是压缩数据,gzip 之后体积几乎不变,只是白白吃 CPU。
gzip_static优先。 构建期把.gz预生成好,请求进来直接发文件,不用每次现压。这个站是纯静态的,没有理由让 CPU 在请求路径上做重复劳动。
Brotli 比 gzip 小 15%~20%,代价是要多一个模块。文本为主的站值得上;已经有 gzip_static 兜底的时候,它算优化不算必需。
404:静态站最不该学 SPK
很多“单页应用”的 Nginx 模板里有这么一行:
# SPA 回退:任何路径都发给 index.html
try_files $uri /index.html;
静态站照抄这一行是灾难。 它会让 /zh/blog/typo/ 这种不存在的地址返回 200 加上首页内容:
- 搜索引擎会把无限多个不存在的 URL 当成有效页面收录(软 404);
- 用户以为自己点对了,只是“页面有点怪”;
- 你的 404 页永远不会被看到。
静态站的真实路由在构建期就全部确定了,所以正确做法是老老实实返回 404:
error_page 404 /404.html;
location / {
try_files $uri $uri/index.html $uri.html =404;
}
一个站点敢返回 404,说明它知道自己有哪些页面。这是静态生成相对“什么都回退到首页”的实打实的优势。
CSP 与那个必须内联的脚本
静态站的安全性通常很省心:没有服务端、没有数据库、没有会话,所以几乎不需要 unsafe-inline。
但有一处例外:首屏防闪烁的脚本必须内联。它要做的事(在第一次绘制之前决定主题、决定语言入口页要不要显示)根本没法交给一个打包出来的 type="module"——module 默认 defer,等它执行时页面已经画出来了。而内联脚本和严格的 CSP 天然冲突。
两条出路:
- 对那段脚本算哈希放进
script-src。问题是脚本一改哈希就变,构建和 Nginx 配置就得同步更新。 - 接受
script-src 'unsafe-inline',把 CSP 的重心放在frame-ancestors、object-src、base-uri这些内联影响不到的地方。
我选了第二条,理由是这个站没有用户数据也没有会话——CSP 在这里防的是第三方注入,而不是本站的脚本。
顺手要改的两处域名
纯静态站没有运行时的配置中心,所以域名是编译进产物的。这个站有两处:
astro.config.mjs的site(sitemap、canonical、RSS 的绝对地址)src/lib/site.ts里的兜底域名
换域名时两处都要改,而且必须重新构建——只改 Nginx 的 server_name 是不够的,产物里的 canonical 还是旧域名。
静态站把“配置”从运行时挪到了构建时。代价就是:改配置等于重新发布。
上线前值得过一遍的清单
-
_astro/是immutable,HTML 是no-cache - 文本开了
gzip_static,图片没有 - 不存在的地址返回 404,不是回退到首页
-
/404.html真的存在,而且带站内导航 -
robots.txt与sitemap.xml里的域名是新域名 - 换过域名后重新构建过一次

评论
…