告别「幽灵旧版本」:SPA 静态资源缓存与版本检测方案
前端做单页应用(SPA)久了,基本都会遇到一个经典问题:
新版本已经发布了,但用户还在旧页面上点来点去,突然有一块功能点击没反应,控制台一片红。
典型报错长这样:
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html".
Strict MIME type checking is enforced for module scripts per HTML spec.原因也很熟悉:
你用的是
index.html + hashed assets的模式(Vite、Webpack 那一套)。新版本发布时,老的
assets/*.js被删掉或覆盖。用户还停留在旧版页面,路由切来切去时会去加载 旧 hash 对应的 JS。
服务器此时已经没有这个文件了,于是:
要么直接 404;
要么你的 Nginx 把
/assets/*.js回退到了index.html,浏览器收到一份 HTML,当 JS 加载,报 MIME 错误。
这篇文章总结了一套我在实际项目里跑通的方案,主要解决三个问题:
Nginx 静态资源与入口文件的缓存策略:避免旧页面突然读不到静态资源;
前端版本检测与更新提醒机制:让客户端自己知道「我已经是旧版」;
前端静态资源发布与过期清理脚本:让
dist/assets既不无限膨胀,又不会误删当前版本仍在使用的文件。
项目背景是 Vite 构建的 SPA,部署目录为:
项目发布目录:
/home/app/wefreestar-vueNginx root:
/home/app/wefreestar-vue/dist
下面按「服务端 → 部署脚本 → 前端」的顺序展开。
一、Nginx:入口文件禁用强缓存,静态资源启用长缓存
目标很简单:
index.html/version.json:每次请求前都跟服务端再验证 → 不使用强缓存;/assets下的静态资源:带 hash 的 js / css / 图片 → 可以大胆长缓存;任意路径(前端路由):都回退到
index.html,由前端路由接管。
1.1 核心 Nginx 配置示例
只保留跟本文前端相关的部分, 内容大致如下:
server {
# 整个站点的根目录,指向 dist
root /home/app/wefreestar-vue/dist;
server_name wefreestar.com www.wefreestar.com;
# SPA 入口:所有前端路由最终回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# index.html:不长缓存,强制每次再验证
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
# 前端版本文件:/version.json,同样不缓存
location = /version.json {
try_files $uri =404;
add_header Cache-Control "no-cache, must-revalidate";
}
# 静态资源:js/css/图片/字体等(带 hash),长缓存且 immutable
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=604800, immutable";
# 604800 秒 = 7 天,可根据需要调整
}
...
}这里几个点说明一下:
1.2 为什么要单独写 location = /index.html?
location /已经有try_filesuriuri/ /index.html;但我们想对
index.html设置更严格的缓存策略;单独写一个
location = /index.html,可以明确加上:
add_header Cache-Control "no-cache, must-revalidate";这样浏览器每次请求 index.html 时都会去跟服务器确认版本,足够让新版本较快生效。
1.3 version.json 的作用
后面前端版本检测会用到 /version.json,所以:
也应该设置为不缓存或仅再验证;
对应 Nginx 配置就是:
location = /version.json {
try_files $uri =404;
add_header Cache-Control "no-cache, must-revalidate";
}前端请求时再多加一个时间戳参数,基本可以避开所有缓存干扰。
1.4 /assets/ 为何可以长缓存?
因为:
构建产物一般是带 hash 的,例如
assets/messageNotification-DzBiWmfy.js;内容变了 → 文件名也变 → 不会跟旧缓存撞车;
对于旧页面来说,只要这些旧 hash 对应的文件还在服务器上,被缓存住反而是好事。
所以 /assets/ 典型配置可以是:
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=604800, immutable";
}这里我用的是 7 天(604800 秒)作为缓存时间,你也可以改成 30 天甚至更久,只要你有一个后台清理策略就行。
接下来要解决的是:长缓存意味着旧文件可能会很多,怎么安全地清理它们?
二、部署策略:不再暴力删除 dist,而是记录差集 + 延迟清理
很多前端项目一开始的部署流程是这样的:
rm -rf dist && unzip dist.zip
# 或者
rsync -av dist_new/ dist/ --delete这会导致:
新版本发布的那一刻,
dist/assets/里所有“旧 hash 文件”直接消失;但线上还有大量用户停留在旧页面,手上握着的 JS 里写着旧 hash 地址;
一旦他们触发某个懒加载 / 路由跳转,就会去请求一个已经不存在的
assets/*.js,直接 404 或 MIME 错误。
更安全的做法是:
部署新版本时,只负责把新文件覆盖到
dist/,不删旧文件;在部署过程中计算「旧版本有、新版本没有」的 assets 差集,记录到清单中;
用一个独立的清理脚本,在「文件被废弃一段时间」后删除这些旧资源。
2.1 目录约定
我们约定:
项目发布目录:
/home/app/wefreestar-vueNginx root:
/home/app/wefreestar-vue/dist用来存储清单文件的目录:
/home/app/wefreestar-vue/asset_gc
打包过程产生的 dist.zip 上传到 /home/app/wefreestar-vue/dist.zip。
三、部署脚本:生成「版本差集清单」并覆盖部署
下面这份是部署脚本 deploy_frontend.sh,它做了几件事:
读取当前线上版本的
dist/assets列表(旧版本);解压新的
dist.zip到临时目录dist_new,读取其中的assets列表(新版本);计算「旧有新无」的差集 → 写入
asset_gc/outdated_resources_时间戳.txt;把新版本
dist_new/用rsync覆盖同步到dist/(不删除旧文件);保存一份最新版本使用的 assets 列表,用于后面清理时做保护。
#!/usr/bin/env bash
set -euo pipefail
########################################
# 配置区
########################################
deploy_base_directory="/home/app/wefreestar-vue"
dist_directory="${deploy_base_directory}/dist"
asset_gc_directory="${deploy_base_directory}/asset_gc" # 存放待删除资源清单文件的目录
dist_zip_file="${deploy_base_directory}/dist.zip" # 每次发版上传的 zip 包路径
current_assets_list_file="${asset_gc_directory}/current_assets_latest.txt"
########################################
# 函数
########################################
log_info() {
echo "[INFO] $*"
}
log_error() {
echo "[ERROR] $*" >&2
}
########################################
# 步骤 0:基本检查
########################################
if [ ! -f "${dist_zip_file}" ]; then
log_error "找不到构建产物 ${dist_zip_file},请先上传 dist.zip 再执行本脚本。"
exit 1
fi
mkdir -p "${asset_gc_directory}"
cd "${deploy_base_directory}"
########################################
# 步骤 1:先生成“旧版本”的 assets 列表
########################################
old_asset_list_file="${asset_gc_directory}/old_assets_tmp.txt"
if [ -d "${dist_directory}/assets" ]; then
log_info "生成旧版本 assets 列表..."
# 输出相对路径,后面好拼 dist/assets/${path}
find "${dist_directory}/assets" -type f -printf '%P\n' | sort > "${old_asset_list_file}"
else
log_info "旧版本 dist/assets 不存在,视为首次发布。"
: > "${old_asset_list_file}"
fi
########################################
# 步骤 2:解压新版本到单独的临时目录 dist_unpack/dist_new
########################################
log_info "清理临时目录 dist_new 和 dist_unpack..."
rm -rf dist_new dist_unpack
log_info "解压 ${dist_zip_file} 到 dist_unpack..."
mkdir -p dist_unpack
unzip -q "${dist_zip_file}" -d dist_unpack
# 假设 zip 里是 dist/xxx 结构
if [ -d "dist_unpack/dist" ]; then
mv dist_unpack/dist dist_new
rm -rf dist_unpack
else
log_error "解压后未在 dist_unpack 下找到 dist 目录,请确认 dist.zip 内部结构是否正确。"
rm -rf dist_unpack
exit 1
fi
########################################
# 步骤 3:生成“新版本”的 assets 列表
########################################
new_asset_list_file="${asset_gc_directory}/new_assets_tmp.txt"
if [ -d "dist_new/assets" ]; then
log_info "生成新版本 assets 列表..."
find "dist_new/assets" -type f -printf '%P\n' | sort > "${new_asset_list_file}"
else
log_info "新版本 dist_new/assets 不存在(可能构建没有任何 assets 目录)。"
: > "${new_asset_list_file}"
fi
########################################
# 步骤 4:生成“旧有新无”的差集 -> 写入 outdated_resources_时间戳.txt
########################################
timestamp_string=$(date +'%Y%m%d%H%M%S')
outdated_list_file="${asset_gc_directory}/outdated_resources_${timestamp_string}.txt"
log_info "计算旧版本中已被新版本淘汰的 assets 差集..."
# comm -23: 只保留出现在 old_asset_list_file 中、但不在 new_asset_list_file 中的行
comm -23 "${old_asset_list_file}" "${new_asset_list_file}" > "${outdated_list_file}" || true
# 清理旧列表
rm -f "${old_asset_list_file}"
if [ ! -s "${outdated_list_file}" ]; then
log_info "本次发布没有需要标记为过期的 assets(差集为空)。"
rm -f "${outdated_list_file}"
else
log_info "已生成待清理资源清单:${outdated_list_file}"
fi
########################################
# 步骤 5:更新“当前版本 assets 列表”
########################################
log_info "更新当前版本 assets 列表:${current_assets_list_file}"
mv "${new_asset_list_file}" "${current_assets_list_file}"
########################################
# 步骤 6:用新版本覆盖 dist(不删除旧文件)
########################################
log_info "将 dist_new 内容同步到 dist(不删除旧文件)..."
mkdir -p "${dist_directory}"
rsync -av dist_new/ "${dist_directory}/"
log_info "删除临时目录 dist_new..."
rm -rf dist_new
log_info "部署完成。记得根据需要清理 dist.zip。"这样做有几个好处:
部署时不删旧文件,旧页面依然能拿到自己需要的 JS、CSS;
每次发版自动生成一个「本次被淘汰的资源清单」;
每次发版还会保存一份「当前版本实际在用的 assets 列表」,后面清理时可以避免误删。
四、清理脚本:只删「被淘汰且不再被当前版本使用」的资源
有了 asset_gc/outdated_resources_时间戳.txt 清单之后,我们就可以用一个定时任务(比如每天凌晨 4 点)去清理超过 7 天的过期资源。
下面是 cleanup_outdated_assets.sh 示例:
#!/usr/bin/env bash
set -euo pipefail
########################################
# 配置区
########################################
deploy_base_directory="/home/app/wefreestar-vue"
dist_directory="${deploy_base_directory}/dist"
asset_gc_directory="${deploy_base_directory}/asset_gc"
# 清单文件保留天数,超过这个天数的清单文件所记录的资源将被删除
retention_days=7
current_assets_list_file="${asset_gc_directory}/current_assets_latest.txt"
########################################
# 函数
########################################
log_info() {
echo "[INFO] $*"
}
log_error() {
echo "[ERROR] $*" >&2
}
########################################
# 主逻辑
########################################
if [ ! -d "${asset_gc_directory}" ]; then
log_info "未找到 ${asset_gc_directory} 目录,无需清理。"
exit 0
fi
cd "${deploy_base_directory}"
# 读取当前版本 assets 列表(如果存在)
if [ -f "${current_assets_list_file}" ]; then
log_info "发现当前版本 assets 列表:${current_assets_list_file}"
has_current_assets_list="yes"
else
log_info "当前版本 assets 列表不存在,将视为没有任何资源在使用(仅极端场景)。"
has_current_assets_list="no"
fi
# 找出超过 retention_days 天的 outdated_resources_* 清单文件
outdated_lists=$(find "${asset_gc_directory}" -type f -name 'outdated_resources_*' -mtime +${retention_days} || true)
if [ -z "${outdated_lists}" ]; then
log_info "没有超过 ${retention_days} 天的待清理资源清单文件。"
exit 0
fi
echo "${outdated_lists}" | while read -r outdated_list_file; do
if [ -z "${outdated_list_file}" ]; then
continue;
fi
log_info "开始处理资源清单:${outdated_list_file}"
# 逐行读取清单文件中的相对路径(相对于 dist/assets)
while read -r asset_relative_path; do
# 跳过空行
if [ -z "${asset_relative_path}" ]; then
continue;
fi
# 如果有当前版本列表,先判断该资源是否仍在使用
if [ "${has_current_assets_list}" = "yes" ]; then
if grep -Fxq "${asset_relative_path}" "${current_assets_list_file}"; then
log_info "当前版本仍在使用该资源,跳过删除:${asset_relative_path}"
continue;
fi
fi
asset_full_path="${dist_directory}/assets/${asset_relative_path}"
if [ -f "${asset_full_path}" ]; then
rm -f "${asset_full_path}"
log_info "已删除过期资源文件:${asset_full_path}"
else
log_info "资源文件不存在(可能已被其他清单或人工删除),跳过:${asset_full_path}"
fi
done < "${outdated_list_file}"
# 清单文件处理完毕,删除自身
rm -f "${outdated_list_file}"
log_info "已删除资源清单文件:${outdated_list_file}"
done
log_info "过期静态资源清理任务执行完毕。"然后在 /etc/crontab 或 crontab -e 加一条定时任务,比如:
0 4 * * * root /home/app/wefreestar-vue/cleanup_outdated_assets.sh >> /var/log/asset_gc.log 2>&1这样就完成了一个完整闭环:
发版时:不删旧文件,只记录差集;
清理时:只删:
「旧有新无」清单中的文件,且
当前版本不再使用 的那些文件,且
清单文件已经存在超过 7 天。
既控制了磁盘体积,又不会误伤当前版本仍在使用的资源。
五、前端:基于构建常量 + version.json 的版本检测与更新提醒
服务端这条线通了之后,前端还需要自己能感知到:
“我这份 JS 已经落后于服务器版本,是一个旧版本。”
这里我的实现是:
构建时注入当前版本号到前端代码(构建常量);
服务端暴露
/version.json返回最新版本号;App 根组件中定时请求
version.json,做版本号比较;如果发现自己是旧版本,就弹出 Element Plus 的确认框,让用户刷新;
用户点「稍后再说」时,用
localStorage记下忽略的版本,避免每分钟弹一次。
5.1 构建常量
const clientAppVersion = import.meta.env.VITE_APP_VERSION;VITE_APP_VERSION 建议在 CI 或打包脚本中统一设置。
5.2 App 根组件中轮询检查版本
简化版关键代码如下(Options API):
`5.3 多 Tab 场景下的行为
在这种实现下:
每个 Tab 自己的 JS 版本号是独立的(构建常量写死在 JS 里);
是否「落后于服务器版本」由各自 Tab 独立判断;
但
localStorage.lastIgnoredVersion是浏览器级别共享的:某个 Tab 对
v3.1.1点了「稍后再说」;其他 Tab 在同一浏览器里轮询时,发现
serverVersion === lastIgnoredVersion,就不再弹提示;新版本
v3.1.2上来后,serverVersion变了,自然又会再提示一次。
如果以后你想做到「一个 Tab 更新,其他 Tab 静默跟着刷新」,可以在这个基础上加上 storage 事件或 BroadcastChannel 做跨 Tab 通知,这里就不展开。
六、小结
这套方案分为三个层面协同工作:
Nginx 层
index.html与version.json配置为禁用强缓存(no-cache, must-revalidate),每次使用前都会向服务端再验证;/assets/下的静态资源采用带内容哈希的文件名,并配置长缓存(例如public, max-age=604800, immutable);所有前端路由统一回退到
index.html,由前端路由系统接管。
前端静态资源发布与清理层
新版本发布时不立即删除旧文件,而是计算「旧版本存在、新版本不存在」的静态资源差集;
将差集写入形如
outdated_resources_时间戳.txt的清单文件,并同时生成当前版本实际使用的assets全量列表;通过定时任务,在清单文件存在超过设定天数(例如 7 天)后,按清单删除对应资源文件;删除前会与当前版本的
assets列表对比,避免删除仍被当前版本引用的文件。
前端版本检测与更新提示层
构建时写入版本常量
clientAppVersion,用于标识当前运行的前端版本;定期请求
/version.json获取serverVersion;当检测到
serverVersion !== clientAppVersion时,通过弹窗提示用户刷新页面;使用
localStorage.lastIgnoredVersion记录用户已选择「稍后再说」的服务器版本,避免在同一版本下重复打扰;使用
isVersionDialogVisible标记当前是否已有版本更新弹窗,避免轮询过程中重复弹出多个对话框。
通过上述三层配合,可以同时解决以下问题:
新版本发布后,旧页面在路由跳转或按需加载时请求不到对应静态资源,导致 404 或 MIME 错误;
用户长时间停留在旧页面,前端与线上版本不一致,出现「幽灵旧版本」导致的异常行为;
dist/assets目录持续膨胀,又担心误删当前版本仍在使用的静态资源。
在此基础上,你可以根据项目需求进一步调整参数,例如:
将静态资源的缓存时长从 7 天调整为 30 天或更长;
修改
cleanup_outdated_assets.sh中的retention_days,控制旧版本资源的保留周期;调整版本检测轮询间隔(例如从 60 秒调整为 30 秒或 5 分钟)。
整体思路是:入口文件与版本信息禁用强缓存,静态资源采用长缓存;发布阶段仅追加资源并记录差集,延迟清理;前端用构建常量与 version.json 做版本对比,并在检测到版本不一致时引导用户刷新页面。