静态站点缓存怎么配:从 Hexo 全站协商到 Astro 的 hash 资源

有一次检查自己的 Hexo 博客,发现线上几乎所有 URL 都带着同一个缓存头:

Cache-Control: max-age=31536000, must-revalidate

这个配置看起来像是「一年内缓存,但过期后要重新验证」。问题在于,这个站点的 Hexo 产物没有文件 hash:/index.html/css/main.css/js/boot.js/local-search.xml、文章页和标签页都是稳定 URL。稳定 URL 会在内容更新后继续指向新内容,如果浏览器把旧内容当成一年内仍然 fresh,就可能一直不向服务器确认。

所以这次真正写错的不是 Hexo,而是缓存头。对这种没有版本化 URL 的静态站点,默认应该全站走协商缓存:

Cache-Control: no-cache

no-cache 这个名字很容易误导人。它不是「不要缓存」,而是「可以存副本,但每次复用前必须向服务器验证」。浏览器可以保存文件,下次访问时带上 ETagLast-Modified 做条件请求;内容没变就返回 304 Not Modified,内容变了就返回新文件。

先判断 URL 有没有版本化

静态站点缓存可以先问一个问题:内容变化时,URL 会不会变?

如果内容变化后 URL 也变化,例如:

/assets/app.8f3a1c2.js
/assets/main-B7PI925R.css
/_astro/index.Ds3k9x.css

这类资源可以长缓存。旧 URL 对应旧内容,新内容会生成新 URL,浏览器就算长期缓存旧文件,也不会影响新页面加载。

Cache-Control: public, max-age=31536000, immutable

如果内容变化后 URL 不变,例如:

/index.html
/about/
/css/main.css
/js/boot.js
/local-search.xml
/robots.txt
/sitemap.xml
/favicon.ico

这类资源应该协商缓存。浏览器可以存副本,但不能在不验证的情况下直接复用。

Cache-Control: no-cache

MDN 的 Cache-Control 文档也按这个思路解释:带版本或 hash 的静态资源可以配长 max-ageimmutable;HTML 本身应该使用 no-cache,这样用户能拿到新的 HTML,再由新的 HTML 引用新的静态资源。

must-revalidate 不能抵消一年缓存

这次误配最容易让人误会的地方是 must-revalidate

Cache-Control: max-age=31536000, must-revalidate

这句话的意思不是「每次都重新验证」。max-age=31536000 会先让响应在一年内保持 fresh;must-revalidate 只约束缓存过期后的行为,意思是资源变 stale 以后不能继续离线复用,必须重新验证。

如果想表达「每次都重新验证」,直接写:

Cache-Control: no-cache

max-age=0, must-revalidate 在现代 HTTP 场景里通常可以视作 no-cache 的旧式写法,但没有必要绕这一层。MDN 的 HTTP caching 文档也提到,现在更应该直接使用 no-cache

no-store 是另一件事。它表示不要存储响应,适合隐私、账号、一次性敏感内容,不适合作为普通静态站点的默认值。静态博客想要的通常不是禁止缓存,而是「缓存可以留着,但复用前要确认」。

Hexo 这种无 hash 站点怎么配

当前这个 Hexo 博客的产物基本都是稳定文件名。

public/index.html
public/page/2/index.html
public/css/main.css
public/js/boot.js
public/local-search.xml
public/2026/06/01/example-post/index.html

文章更新、主题 CSS 更新、搜索索引更新后,URL 不会变。那就不要按「构建产物都可以强缓存」去处理,直接全站协商缓存更稳:

Cache-Control: no-cache

这里包括 HTML、CSS、JS、图片、字体、下载脚本、local-search.xml404.html。因为它们都没有内容 hash,任何一个文件都可能在同一个 URL 下被覆盖。

404 也不要长缓存。比如某篇文章还没发布时,有人提前访问过 URL,浏览器拿到 404;如果这个 404 被一年缓存,等文章发布后,用户仍然可能继续看到旧的 404。静态站点里 404.html、缺失路径兜底页、robots.txtsitemap.xml 这类固定 URL 都应该按协商缓存或短缓存处理。

如果以后博客引入了带 hash 的资源,例如把主题 CSS/JS 改成:

/assets/main.8f3a1c2.css
/assets/boot.91bd0aa.js

再把这些 hash 资源单独切到长缓存。不要在所有文件都没有 hash 的时候提前套这个规则。

Astro 和 Vite 场景要分两类

Astro 和 Vite 这类现代构建工具通常会同时产生两类文件。

第一类是构建处理过的资源。Vite 静态资源文档写到,被源码 import 的图片、字体等资源会进入构建资源图,生产构建会生成带 hash 的文件名;Astro 的 build.assets 配置也说明,Astro 生成的 JS/CSS 这类资源默认放在 build.assets 指定的目录下,默认目录是 '_astro'

这类文件可以长缓存:

/_astro/client.Ds3k9x.js
/_astro/page.B7PI925R.css
/assets/logo-BuPIv-2h.svg
Cache-Control: public, max-age=31536000, immutable

第二类是 public/ 目录里的文件。Astro 的项目结构文档说明,public/ 里的文件不会经过构建处理,会原样复制到构建目录;Vite 的 public 目录文档也写得很清楚:public 目录适合 robots.txt、必须保留原始文件名的文件,最终会原样复制到 dist 根目录。

这类文件默认不要长缓存:

/robots.txt
/favicon.ico
/manifest.webmanifest
/og-image.png
/downloads/tool.sh
Cache-Control: no-cache

Astro 站点可以按这个规则切:

URL 类型 推荐缓存
HTML 页面、目录路由、404.html no-cache
sitemap*.xmlrobots.txtmanifest.webmanifest no-cache
public/ 原样复制出来的图片、脚本、字体 默认 no-cache,除非文件名自己带版本
/_astro/ 下确认带 hash 的 JS/CSS/图片/字体 public, max-age=31536000, immutable

这里有一个小边界:不要只按目录名盲配。最稳的做法是打开最终 dist/ 看一眼文件名,确认它们确实带 hash,并且内容变化时会生成新文件。缓存策略永远跟最终 URL 走,而不是跟「它来自哪个框架」走。

如果用了 Astro 的 assetsPrefix/_astro/ 上传到单独 CDN 域名,也一样按最终 URL 分类。HTML 仍然协商缓存,CDN 上的 hash 资源可以强缓存,public/ 里的固定文件继续保守处理。

其他构建场景也按同一套模型

不管是 Hexo、Astro、Vite、Webpack、Rspack、Next、Nuxt,判断模型都差不多。

产物形态 例子 推荐缓存
HTML / 文档路由 //about//posts/a//index.html no-cache
内容 hash 资源 /assets/app.8f3a1c2.js/_next/static/... public, max-age=31536000, immutable
固定文件名资源 /main.css/app.js/favicon.ico no-cache 或短缓存
搜索索引 / feed / sitemap /local-search.xml/feed.xml/sitemap.xml no-cache
用户下载文件 /files/tool.sh/downloads/report.pdf 看更新方式;无版本默认 no-cache
Service Worker /sw.js/service-worker.js 通常 no-cache,避免更新卡住

真正要避免的是把「静态文件」当成一个整体。静态文件里有的 URL 是内容寻址,有的 URL 是固定入口,它们不应该用同一条缓存规则。

上传到 OSS 时怎么写

OSS 的 Cache-Control 属于 object 元数据。上传 object 时写进去,下载时 OSS 会把它作为响应头返回。阿里云的 PutObject 文档列出了 Cache-Control 这个标准 HTTP 请求头,也说明 no-cache 会要求缓存复用前向源站重新验证。

如果只是用 ossutil 上传单个对象,可以这样写:

ossutil api put-object \
  --bucket <BUCKET_NAME> \
  --key index.html \
  --body file://public/index.html \
  --cache-control no-cache

博客这种要批量上传 public/ 目录的场景,用 ali-oss 写脚本更方便。ali-oss README 里的 put() 示例支持在 options 里传 headers,可以把 Cache-Control 写进去。

下面是一个只保留关键逻辑的版本:

import { posix } from 'node:path';
import OSS from 'ali-oss';
import { glob } from 'tinyglobby';

const publicDir = './public';

const client = new OSS({
  bucket: process.env.OSS_BUCKET,
  region: process.env.OSS_REGION,
  accessKeyId: process.env.OSS_ACCESS_KEY_ID,
  accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
});

const objectPaths = (
  await glob('**/*', {
    cwd: publicDir,
    dot: true,
    expandDirectories: false,
    onlyFiles: true,
  })
).sort();

for (const objectPath of objectPaths) {
  const filePath = posix.join(publicDir, objectPath);
  const objectKey = objectPath;

  const { name, url } = await client.put(objectKey, filePath, {
    headers: {
      'Cache-Control': 'no-cache',
    },
  });

  console.log(`${name} -> ${url}`);
}

这里有几个细节值得留意。

第一,AccessKey 不要写进脚本,应该从环境变量读取。发布脚本会进 Git,密钥不应该跟源码走。

第二,objectKey 是远端 key,要使用 / 分隔,但它不是 URL 路径,不需要以 / 开头。当前脚本里 tinyglobby 返回的 objectPath 已经是 css/main.css 这种 slash 风格,直接作为 object key 更清楚。OSS 里的目录本质上是 object key 前缀,不是本地文件系统目录。

第三,改了脚本后要重新上传。Cache-Control 是已经上传到 OSS 的 object 元数据,不会因为本地脚本改了就自动影响旧对象。

第四,如果前面已经错误发布过一年强缓存,还要考虑 CDN 和浏览器里已经存在的副本。CDN 可以做刷新或失效;浏览器里已经 fresh 的旧响应,不一定会主动向服务器请求新 header。关键入口如果已经被错误强缓存,必要时只能通过换 URL、让用户硬刷新,或者在下一次能返回响应时谨慎使用 Clear-Site-Data: cache 这类更强手段。

怎么验证

验证缓存头时,不要只用 curl -I。有些对象存储的静态网站模式对 HEADGET 的 index fallback 行为不完全一样,目录路由可能在 HEAD 下看起来像 404,但浏览器真实 GET 是 200。

更稳的方式是用 GET 但不下载正文:

curl -sS -D - -o /dev/null https://example.com/
curl -sS -D - -o /dev/null https://example.com/index.html
curl -sS -D - -o /dev/null https://example.com/css/main.css

重点看这些响应头:

Cache-Control: no-cache
ETag: "..."
Last-Modified: ...

再手动发一次条件请求,确认服务端能返回 304

curl -sS -D headers.txt -o /dev/null https://example.com/index.html

ETAG=$(awk 'BEGIN{IGNORECASE=1} /^ETag:/ {
  gsub(/\r/, "");
  sub(/^ETag:[[:space:]]*/, "");
  print;
  exit;
}' headers.txt)

curl -sS -D - -o /dev/null \
  -H "If-None-Match: ${ETAG}" \
  https://example.com/index.html

如果返回:

HTTP/1.1 304 Not Modified

就说明协商缓存链路是通的。

最后再按 URL 类型抽样检查。无 hash 站点可以全站 no-cache;有 hash 的现代构建站点,要分别看 HTML、固定 URL、hash 资源和 404。只看一个首页,不能证明整站缓存策略是对的。

最后留一个小清单

给静态站点配缓存时,可以按这个顺序检查:

  1. 先看最终产物,不要只看源码目录。
  2. 标出哪些 URL 内容变化后仍然不变。
  3. 固定 URL 默认用 no-cache
  4. 内容 hash URL 才用 public, max-age=31536000, immutable
  5. public/ 原样复制的文件默认按固定 URL 处理。
  6. 404、sitemap、robots、feed、搜索索引不要跟 hash 资源混在一起。
  7. OSS 上传时把 Cache-Control 当 object 元数据写入。
  8. 改策略后重新上传,并按需刷新 CDN。
  9. 验证时优先用真实 GET,再测条件请求是否返回 304

这次的教训很简单:缓存策略不要按「它是不是静态文件」判断,而要按「这个 URL 能不能代表唯一内容」判断。Hexo 这种没有 hash 的站点,全站协商缓存最稳;Astro/Vite 这类会生成 hash 资源的站点,则把 HTML 和固定文件保守处理,把确认带 hash 的构建资源交给强缓存。