发版后 30% 用户白屏:Cache-Control 与资源指纹的配合

白屏报 ChunkLoadError,强刷一下就好,因为 index.html 被缓存了七天而旧 chunk 已被删除。入口用 no-cache、带哈希的资源用 immutable、发版保留旧版本目录,再加上一层路由层的兜底捕获。

发版十分钟后,客服群开始报「白屏」。奇怪的是不是所有人都白屏——有人刷新一下就好了,有人反复刷新还是白屏,还有一部分人完全正常。

控制台里的报错很一致:

Failed to fetch dynamically imported module: https://ispt.top/assets/OrderList-a3f9c1.js

ChunkLoadError。这个文件名带哈希,说明它属于旧版本。新版本发布后,旧哈希的 chunk 被删掉了,而浏览器还在按旧的 index.html 去找它。

问题是:为什么浏览器还在用旧的 index.html?

一、现象:一部分人拿着旧入口

我先把几个关键事实列出来:

事实 值 含义
白屏用户占比 约 30% 不是全量,说明有缓存分层
index.html 响应头 Cache-Control: max-age=604800 被缓存 7 天
带哈希的 JS 同一条 Cache-Control 也被缓存 7 天
发版方式 直接覆盖 dist 目录 旧文件被物理删除
强制刷新后 恢复正常 回到最新 index.html

第一行和第四行加起来就是完整的因果:index.html 被缓存 7 天,发版把旧 chunk 删了,还没过期的那部分用户就卡在一个「入口是旧的、资源已经不存在」的状态里。

这不是 CDN 的锅,也不是构建工具的锅,是缓存策略配错了:给不该长期缓存的入口文件,配了和静态资源一样的强缓存。

二、强缓存和协商缓存,差的是一次请求

浏览器缓存只有两条路:

  • 强缓存:Cache-Control: max-age=N 或 Expires。在 N 秒内,浏览器完全不发请求,直接用本地副本。服务端连「有人来拿过」都不知道。
  • 协商缓存:Cache-Control: no-cache 配合 ETag / Last-Modified。浏览器每次都会问服务端,服务端比对后返回 304 Not Modified(无 body)或 200(新内容)。

两者的差别不在「快不快」,而在「能不能被更新」。max-age=604800 意味着七天内你没有任何手段让用户拿到新文件——换 CDN、重新部署、改文件名都没用,因为请求根本没发出来。

一个健康的组合是这样的:

资源类型 文件名特征 推荐头 理由
HTML 入口 index.html no-cache(配 ETag) 必须每次校验,它是「版本指针」
带哈希的 JS/CSS app-a3f9c1.js max-age=31536000, immutable 内容变则文件名变,可永久缓存
图片/字体 logo-v2.png max-age=2592000 变更不频繁,可接受一个月
接口响应 /api/* no-store 不能留任何副本

关键在第二行:immutable 让浏览器在用户按刷新时也不去校验这个文件。因为文件名里带内容哈希,同一个 URL 的内容永远不变,校验纯属浪费。

三、改法:把入口和资源分开

我原来只有一段笼统的配置:

# 错误:一刀切,入口和资源用同一套策略
location / {
    root /var/www/ispt;
    try_files $uri $uri/ /index.html;
    expires 7d;
    add_header Cache-Control "public, max-age=604800";
}

改成按路径分流。先给带哈希的资源单独一条,再让 HTML 走协商缓存:

server {
    listen 443 ssl http2;
    server_name ispt.top;

    root /var/www/ispt;

    # 1. 带内容哈希的静态资源:一年 + immutable
    location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
        try_files $uri =404;      # 资源缺失就该 404,不要回落 HTML
    }

    # 2. HTML 入口:每次都校验
    location = /index.html {
        add_header Cache-Control "no-cache, must-revalidate";
        add_header ETag "";        # 交给 nginx 自行生成
        try_files $uri =404;
    }

    # 3. SPA 路由回落
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache";
    }
}

三个地方值得单独说。

try_files $uri =404 而不是回落 index.html。 如果某个 JS 已经不存在,返回 HTML 会让浏览器拿到 200 和一段 HTML,然后在解析时报 Unexpected token '<',报错信息完全指不到「文件没了」这个事实。直接 404 才能一眼看出是版本错配。

location = /index.html 用精确匹配。 SPA 回落最终内部跳转到 /index.html,命中这条精确规则,才拿得到 no-cache;如果只写 location /,回落后拿到的还是笼统那条的头。

no-cache 不是「不缓存」。 这是最常见的误解。no-cache 允许缓存,但每次使用前必须向服务端校验;真正「不许存」的是 no-store。入口文件用 no-cache + ETag,未变更时返回 304,带宽成本几乎为零,这是最优解。

四、验证:别只看配置文件,要看响应头

改完 nginx -t && systemctl reload nginx,然后用 curl 直接看头,别靠浏览器缓存后的现象猜:

# HTML 入口
curl -sI https://ispt.top/ | grep -iE 'cache-control|etag|last-modified'

# 期望:cache-control: no-cache, must-revalidate
#       etag: "66f3a1c2-5b8"

# 带哈希的 JS
curl -sI https://ispt.top/assets/app-a3f9c1.js | grep -iE 'cache-control|expires'

# 期望:cache-control: public, max-age=31536000, immutable

# 验证协商缓存真的生效:带 If-None-Match 请求,应返回 304
ETAG=$(curl -sI https://ispt.top/ | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" https://ispt.top/ | head -1

最后一条返回 HTTP/2 304 才算真的通了。如果返回 200,说明 ETag 没生成(某些编译参数下 etag off 会关掉它),协商缓存就退化成了每次都全量下载。

还有个容易漏的地方:CDN 会覆盖源站的头。如果前面有 CDN,源站改对了但 CDN 侧的缓存规则还是「HTML 缓存 7 天」,用户拿到的依然是旧头。这类问题我用 curl -I 分别打源站 IP 和域名来对比:

# 打源站(绕过 CDN)
curl -sI --resolve ispt.top:443:10.0.3.21 https://ispt.top/ | grep -i cache-control
# 打 CDN
curl -sI https://ispt.top/ | grep -i cache-control

两条输出不一致,就说明 CDN 侧要单独配置。

五、发版流程也得配合缓存策略

缓存头改对之后,还剩一个窗口期:用户手里那份旧 index.html 还没过期(no-cache 下窗口很短,但仍在)。如果发版时直接把旧的 assets 目录删掉,这个窗口里的用户还是会 404。

所以发版改成「先加后删」:

#!/bin/bash
set -euo pipefail

REL="/var/www/ispt/releases/$(date +%Y%m%d%H%M%S)"
KEEP=3

# 1. 新版本放到独立目录,不动线上
rsync -a --delete dist/ "$REL/"

# 2. 原子切换软链
ln -sfn "$REL" /var/www/ispt/current.new
mv -Tf /var/www/ispt/current.new /var/www/ispt/current

# 3. 只保留最近 3 个版本,旧 chunk 因此还能被访问到
ls -1dt /var/www/ispt/releases/*/ | tail -n +$((KEEP+1)) | xargs -r rm -rf

保留 3 个版本,等于给「旧入口 + 旧资源」留了一个安全窗口。真正要彻底解决,还得配合前端:路由懒加载失败时捕获 ChunkLoadError,提示用户刷新一次。

router.onError((error, to) => {
  const isChunkError = /Loading chunk [\w-]+ failed|dynamically imported module/.test(error.message)
  if (!isChunkError) return
  // 用 sessionStorage 防止刷新死循环
  const key = 'chunk-reload:' + to.fullPath
  if (sessionStorage.getItem(key)) return
  sessionStorage.setItem(key, '1')
  window.location.reload()
})

这段逻辑不是「兜底缓存策略」,而是「兜住已经发生的错配」。加 sessionStorage 标记是必须的:如果新版本本身有问题,没有这个标记会变成刷新死循环。

六、几个反复出现的误判

现象 容易误判为 实际原因
只有部分用户白屏 前端代码 bug 缓存分层,老用户手里是旧 HTML
强刷就好 CDN 抽风 强刷绕过了强缓存
图片更新了但页面没变 部署失败 图片同名同 URL,被 max-age 挡住
304 越来越多 缓存失效 正常现象,说明协商缓存生效
CDN 命中率低 配置错 no-cache 的 HTML 本来就不该高命中

第三条我踩过:换了一张 banner.png,同名覆盖上传,结果一半用户还在看旧图,因为那张图的 URL 没变。后来所有静态资源都走构建工具的内容哈希,人工替换的运营图则加版本号 banner-20261107.png,不再同名覆盖。

小结

  • 强缓存期间浏览器一个请求都不发,服务端没有任何补救手段;入口 HTML 绝不能配 max-age。
  • no-cache 是「每次校验」,no-store 才是「不存」,入口文件要的是前者。
  • 带内容哈希的资源用 max-age=31536000, immutable,HTML 用 no-cache + ETag,这是固定搭配。
  • 资源 404 要真返回 404,回落 index.html 会制造 Unexpected token '<' 这种误导性报错。
  • 验证以 curl -I 的响应头为准,并分别打源站和 CDN,两边头不一致就是 CDN 覆盖了。
  • 发版保留最近几个版本目录,再加一层 ChunkLoadError 捕获,才能兜住窗口期的老用户。
查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

发版后 30% 用户白屏:Cache-Control 与资源指纹的配合 - 起航社区