前端做单页应用(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 错误。

这篇文章总结了一套我在实际项目里跑通的方案,主要解决三个问题:

  1. Nginx 静态资源与入口文件的缓存策略:避免旧页面突然读不到静态资源;

  2. 前端版本检测与更新提醒机制:让客户端自己知道「我已经是旧版」;

  3. 前端静态资源发布与过期清理脚本:让 dist/assets 既不无限膨胀,又不会误删当前版本仍在使用的文件。

项目背景是 Vite 构建的 SPA,部署目录为:

  • 项目发布目录:/home/app/wefreestar-vue

  • Nginx 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_filesuri uri/ /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 错误。

更安全的做法是:

  1. 部署新版本时,只负责把新文件覆盖到 dist/,不删旧文件;

  2. 在部署过程中计算「旧版本有、新版本没有」的 assets 差集,记录到清单中;

  3. 用一个独立的清理脚本,在「文件被废弃一段时间」后删除这些旧资源。

2.1 目录约定

我们约定:

  • 项目发布目录:/home/app/wefreestar-vue

  • Nginx root:/home/app/wefreestar-vue/dist

  • 用来存储清单文件的目录:/home/app/wefreestar-vue/asset_gc

打包过程产生的 dist.zip 上传到 /home/app/wefreestar-vue/dist.zip

三、部署脚本:生成「版本差集清单」并覆盖部署

下面这份是部署脚本 deploy_frontend.sh,它做了几件事:

  1. 读取当前线上版本的 dist/assets 列表(旧版本);

  2. 解压新的 dist.zip 到临时目录 dist_new,读取其中的 assets 列表(新版本);

  3. 计算「旧有新无」的差集 → 写入 asset_gc/outdated_resources_时间戳.txt

  4. 把新版本 dist_new/rsync 覆盖同步到 dist/(不删除旧文件);

  5. 保存一份最新版本使用的 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/crontabcrontab -e 加一条定时任务,比如:

0 4 * * * root /home/app/wefreestar-vue/cleanup_outdated_assets.sh >> /var/log/asset_gc.log 2>&1

这样就完成了一个完整闭环:

  • 发版时:不删旧文件,只记录差集

  • 清理时:只删:

    • 「旧有新无」清单中的文件,且

    • 当前版本不再使用 的那些文件,且

    • 清单文件已经存在超过 7 天。

既控制了磁盘体积,又不会误伤当前版本仍在使用的资源。

五、前端:基于构建常量 + version.json 的版本检测与更新提醒

服务端这条线通了之后,前端还需要自己能感知到:

“我这份 JS 已经落后于服务器版本,是一个旧版本。”

这里我的实现是:

  1. 构建时注入当前版本号到前端代码(构建常量);

  2. 服务端暴露 /version.json 返回最新版本号;

  3. App 根组件中定时请求 version.json,做版本号比较;

  4. 如果发现自己是旧版本,就弹出 Element Plus 的确认框,让用户刷新;

  5. 用户点「稍后再说」时,用 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.htmlversion.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 做版本对比,并在检测到版本不一致时引导用户刷新页面。