静态站点缓存怎么配:从 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-cacheno-cache 这个名字很容易误导人。它不是「不要缓存」,而是「可以存副本,但每次复用前必须向服务器验证」。浏览器可以保存文件,下次访问时带上 ETag 或 Last-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-cacheMDN 的 Cache-Control 文档也按这个思路解释:带版本或 hash 的静态资源可以配长 max-age 和 immutable;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-cachemax-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.xml、404.html。因为它们都没有内容 hash,任何一个文件都可能在同一个 URL 下被覆盖。
404 也不要长缓存。比如某篇文章还没发布时,有人提前访问过 URL,浏览器拿到 404;如果这个 404 被一年缓存,等文章发布后,用户仍然可能继续看到旧的 404。静态站点里 404.html、缺失路径兜底页、robots.txt、sitemap.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.svgCache-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.shCache-Control: no-cacheAstro 站点可以按这个规则切:
| URL 类型 | 推荐缓存 |
|---|---|
HTML 页面、目录路由、404.html |
no-cache |
sitemap*.xml、robots.txt、manifest.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。有些对象存储的静态网站模式对 HEAD 和 GET 的 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。只看一个首页,不能证明整站缓存策略是对的。
最后留一个小清单
给静态站点配缓存时,可以按这个顺序检查:
- 先看最终产物,不要只看源码目录。
- 标出哪些 URL 内容变化后仍然不变。
- 固定 URL 默认用
no-cache。 - 内容 hash URL 才用
public, max-age=31536000, immutable。 public/原样复制的文件默认按固定 URL 处理。- 404、sitemap、robots、feed、搜索索引不要跟 hash 资源混在一起。
- OSS 上传时把
Cache-Control当 object 元数据写入。 - 改策略后重新上传,并按需刷新 CDN。
- 验证时优先用真实
GET,再测条件请求是否返回304。
这次的教训很简单:缓存策略不要按「它是不是静态文件」判断,而要按「这个 URL 能不能代表唯一内容」判断。Hexo 这种没有 hash 的站点,全站协商缓存最稳;Astro/Vite 这类会生成 hash 资源的站点,则把 HTML 和固定文件保守处理,把确认带 hash 的构建资源交给强缓存。