静态站上线不是「把文件夹传上去」:缓存、压缩、以及诚实的 404

纯静态托管的坑几乎全在响应头上。哪些文件能永久缓存、哪些一秒都不能缓存、以及为什么静态站绝对不能做 SPA 式回退。

静态站没有后端,所以最容易产生一种错觉:构建完把 dist/ 传上去就完事了。实际上真正会出事的地方全在响应头上——缓存策略、压缩、状态码。这些东西本地 npm run dev 永远测不出来,只有上了 Nginx 才暴露。

产物里其实是三类东西

先把 dist/ 分清楚,因为它们的缓存策略必须不同:

类别 例子 文件名会变吗
内容寻址资源 _astro/index.B3eZ-5oA.css ,名字里有内容哈希
HTML zh/blog/design-tokens/index.html 不会,地址和文件名都是固定的
固定名静态资源 favicon.icoog-image.pnglogo-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 天然冲突。

两条出路:

  1. 对那段脚本算哈希放进 script-src。问题是脚本一改哈希就变,构建和 Nginx 配置就得同步更新。
  2. 接受 script-src 'unsafe-inline',把 CSP 的重心放在 frame-ancestorsobject-srcbase-uri 这些内联影响不到的地方。

我选了第二条,理由是这个站没有用户数据也没有会话——CSP 在这里防的是第三方注入,而不是本站的脚本。

顺手要改的两处域名

纯静态站没有运行时的配置中心,所以域名是编译进产物的。这个站有两处:

  • astro.config.mjssite(sitemap、canonical、RSS 的绝对地址)
  • src/lib/site.ts 里的兜底域名

换域名时两处都要改,而且必须重新构建——只改 Nginx 的 server_name 是不够的,产物里的 canonical 还是旧域名。

静态站把“配置”从运行时挪到了构建时。代价就是:改配置等于重新发布。

上线前值得过一遍的清单

  • _astro/immutable,HTML 是 no-cache
  • 文本开了 gzip_static,图片没有
  • 不存在的地址返回 404,不是回退到首页
  • /404.html 真的存在,而且带站内导航
  • robots.txtsitemap.xml 里的域名是新域名
  • 换过域名后重新构建过一次

← 返回文章列表

评论