Firebase Hosting 通过 Google CDN 分发静态文件,如果你什么都不配,默认 Cache-Control 是 max-age=3600(一小时)。这个默认值平时够用,关键时刻不行:你推了个 CSS 修复,CDN 和每个浏览器把旧文件再扣一小时,用户就开始抱怨了。firebase.json 里的 headers 块负责覆盖这套默认值——管缓存、管安全、管 CORS。一次配对,以后就不再 debug 旧资源、不再丢 AdSense 位置、不再为字体跨域报错收尾。
速览
- 带指纹的资源(
/_astro/*.js、*.[hash].css)用public, max-age=31536000, immutable缓存一年;HTML 用public, max-age=0, must-revalidate,每次部署立刻可见。 - Firebase 的默认值:静态文件
max-age=3600,Cloud Function 或 Cloud Run 返回的内容是private(CDN 完全不缓存)。默认不合适时自己配头。 - 五个安全头一次性配好(
Strict-Transport-Security、X-Content-Type-Options、X-Frame-Options、Referrer-Policy、Permissions-Policy),然后就别管了。Content-Security-Policy放到第二轮,先用 report-only。 - CORS 只给「会被另一个 origin 的 JavaScript 拉取」的具体资源加,绝不全站套
*。 - 头规则按
sourceglob 从上到下取第一条匹配。具体规则放在宽规则上面。
Firebase 头的行为(文档里藏得深的部分)
每个 HTTP 响应都带头,告诉浏览器缓存多久、能不能进 iframe、哪些脚本能跑、其他域名能不能拉资源。Firebase Hosting 给了默认值,但读不出你的意图。firebase.json 里的 headers 数组按路径覆盖默认值。有三个事实决定你的配置能不能成(依据 Firebase Configure Hosting behavior 和 Manage cache behavior 官方文档核对,截至 2026 年 6 月):
- 默认缓存就是一小时。 没配
Cache-Control的静态内容拿到max-age=3600。所以「旧内容扣一小时」是默认行为,不是配错了。 - 第一条匹配的规则生效。 和 redirect、rewrite 一样,Hosting 取从上到下第一条
sourceglob 匹配的头规则。数组要按「先具体后宽泛」排。 - 头规则在 rewrite 之前执行。 头是针对请求的 URL 路径匹配的,在任何 rewrite 之前,所以它瞄准的是用户请求的 URL,而不是 rewrite 后的目标。
推荐用 source glob 写匹配模式;需要的话 regex(RE2)也支持。
指向这里的症状
- 部署完 CSS 修复,用户最多一小时还看到旧版。
- Lighthouse 报「Serve static assets with an efficient cache policy」。
- 站点嵌进 iframe 是空白,或者合作方嵌不进去。
- 自托管字体在合作方域名上报 CORS 错误,控制台里能看到。
- 加了 Content Security Policy 之后 AdSense 或 analytics 脚本被拦掉。
真正能用的缓存头
让站点出问题的写法是一条规则对所有路径都套 max-age=31536000。带指纹的 JS 和 CSS 缓存一年没问题,因为每次构建文件名都变;HTML 不行——缓存太久,用户会在浏览器缓存过期前一直看旧页面。
{
"hosting": {
"headers": [
{
"source": "**/*.@(js|css|woff2|jpg|jpeg|png|webp|avif|svg)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "**/*.html",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
]
},
{
"source": "/sitemap.xml",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=3600" }
]
}
]
}
}
immutable 告诉浏览器永远不要校验,只在文件名带 hash 时才安全。HTML 用 max-age=0, must-revalidate,浏览器可以保留文件,但每次加载都发 conditional 请求,用户总是落到最新部署。
把 CDN 和浏览器分开调
max-age 管浏览器,s-maxage 管共享 CDN 缓存,并在 CDN 上覆盖 max-age。如果你想让浏览器频繁校验、但让 Google 边缘节点多扣一会儿,就两个都设:
{ "key": "Cache-Control", "value": "public, max-age=0, s-maxage=600, must-revalidate" }
这个写法对 Cloud Function 或 Cloud Run 的动态响应最有用——它们默认根本不进 CDN 缓存(默认是 private)。你得显式加 public 再加 s-maxage 才能让它缓存。
| 资源类型 | 推荐 Cache-Control | 原因 |
|---|---|---|
带 hash 的 JS/CSS/图片(*.[hash].*) | public, max-age=31536000, immutable | 每次构建文件名都变,可永久缓存 |
| HTML 页面 | public, max-age=0, must-revalidate | 每次部署都要立刻可见 |
sitemap.xml、robots.txt | public, max-age=3600 | 爬虫能容忍一小时的滞后 |
| Cloud Function JSON | public, max-age=0, s-maxage=600 | 边缘缓存 10 分钟;浏览器每次重查 |
| Firebase 默认(不配头) | max-age=3600 | 一小时,什么都不设时生效 |
安全头,一次配好
五个头能挡掉大多数常见攻击。放在 ** source 下,配好就别管。
{
"hosting": {
"headers": [
{
"source": "**",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
}
]
}
}
第一轮别加 Content-Security-Policy,配错会让站上所有脚本停止加载。真要加,先上 Content-Security-Policy-Report-Only,在 DevTools 里盯一周违规报告,再换成强制生效的头。每个 directive 可以查 MDN CSP 参考。
CORS 只给需要的资源
常见错误是为了「安全」给每个资源都加 Access-Control-Allow-Origin: *,这反而把你的 JSON 接口和 API 对全网放开。CORS 只在「会被另一个 origin 的 JavaScript 拉取的资源」上需要:通常是给合作方加载的字体,或者另一个域名消费的 JSON 接口。
{
"source": "**/*.@(woff|woff2)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" }
]
}
JSON 接口给一个已知 origin 用,就明确列出来,别用 *,并加 Vary: Origin 防止缓存把错误的 CORS 响应发给别人:
{
"source": "/api/public/**",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "https://partner.example.com" },
{ "key": "Vary", "value": "Origin" }
]
}
容易踩的坑
- 一条
**规则套max-age=31536000。HTML 缓存一年,用户除非手动清缓存,否则看不到新部署。 - 加了
X-Frame-Options: DENY然后纳闷 AdSense Auto Ads 为什么挂了——AdSense iframe 是同源,默认值用SAMEORIGIN。 - 第一次就上严格
Content-Security-Policy而不先用 report-only。第一次部署就会把 analytics、字体、第三方脚本全挡掉。 - 把宽
**规则放在具体规则上面。第一条匹配生效,宽规则先命中,具体规则永远跑不到。要「先具体后宽泛」排。 - 同时用
Access-Control-Allow-Origin: *和Access-Control-Allow-Credentials: true。浏览器拒绝这个组合。要么去掉 credentials,要么列具体 origin。 - 指望
firebase serve遵守你的头。它不一定模拟得出来。要在真实部署后验证。
验证部署
firebase deploy --only hosting 之后,确认头真的下发了:
curl -sI https://yoursite.com/_astro/app.abcd1234.js | grep -i cache-control
# 预期:cache-control: public, max-age=31536000, immutable
curl -sI https://yoursite.com/index.html | grep -i cache-control
# 预期:cache-control: public, max-age=0, must-revalidate
如果还是旧值,CDN 在部署后会短暂保留上一版头。在 URL 后面加一个一次性查询串(?v=test)绕过缓存,确认源头已经在发新头。
FAQ
- 怎么验证某个头真的下发了?: 跑
curl -sI https://yoursite.com/path读响应头,或者打开 DevTools,在 Network tab 点资源,看 Response Headers。 - 新的 Cache-Control 为什么没生效?: Firebase 重新部署会清 CDN,但边缘节点可能短暂还在发上一版头。等几分钟,或者用
?v=test命中 URL,确认源头已经在发新头。 - 什么都不配时默认的 Cache-Control 是什么?: 静态文件拿到
max-age=3600(一小时)。Cloud Function 或 Cloud Run 返回的内容是private,CDN 完全不缓存。 /index.html能缓存一小时吗?: 只在能接受用户部署后一小时内看到旧内容的前提下可以。活跃站点用max-age=0, must-revalidate更稳。- 这些头对 redirect 也生效吗?: 不生效。301/302 自己带响应头。
headers块只对返回内容(200 或 304)的 URL 生效,不管 redirect 响应。 - 怎么给 Cloud Function 响应配头,而不是 Hosting 层?: 在函数里调
res.set('Cache-Control', '...')。注意只有__session这个 cookie 会传到后端代码,而且它会成为 CDN 缓存键的一部分。