Vite 前端发布后「点击菜单没反应」?旧版本资源 404 的完整排查与修复实录

关键词:Vite 构建缓存 · 路由懒加载 · nginx 缓存策略 · version.json 版本探测 · bfcache

一、起因:一次平平无奇的发布

我们的项目是一个 Vue 3 + TypeScript + Vite 6 的中后台系统,部署在 nginx(Docker 容器)里。所有路由都做了懒加载 ,构建产物是带内容哈希的文件名(index-BhpEQhsP.jstable-DKCenJSm.js 这种)。

某天发布新版本后,测试反馈:"我没清浏览器缓存,点菜单没反应,好像页面死了。" 我打开用户控制台,看到一片红:

text 复制代码
:18080/assets/index-BhpEQhsP.js:1  Failed to load resource: the server responded with a status of 404 (Not Found)
:18080/assets/table-DKCenJSm.js:1  Failed to load resource: the server responded with a status of 404 (Not Found)
:18080/assets/shipping-plan-CytjclXm.js:1  Failed to load resource: the server responded with a status of 404 (Not Found)
index-D7wTRebV.js:88 TypeError: Failed to fetch dynamically imported module:
    http://192.168.10.105:18080/assets/index-BhpEQhsP.js
index-D7wTRebV.js:88 Error: Unable to preload CSS for /assets/index-DY9QyI0r.css

熟悉的味道------旧版本的入口 JS 还在浏览器里跑,但它引用的新/旧哈希 chunk 已经在服务器上被删掉了。

二、现象与报错

报错大致分三类:

报错 含义
Failed to load resource ... 404 (assets/*.js) 旧哈希 chunk 在服务器上已不存在
TypeError: Failed to fetch dynamically imported module 路由懒加载的动态 import() 拉取失败,页面级致命错误
Unable to preload CSS Vite 在动态导入时预加载 CSS 失败

另外还有一条很迷惑人的日志:

text 复制代码
WebSocket connection to 'ws://.../api/system/notifications/ws' failed: Page entered Back-Forward Cache.

我们项目有通知推送 WebSocket。这条报错差点把我带偏------以为和 WebSocket 断连有关。后面会讲它其实是什么。

三、排查过程:从被带偏到找到真凶

3.1 先被 "WebSocket + bfcache" 报错带偏

Page entered Back-Forward Cache(简称 bfcache )是浏览器的一个优化机制:用户离开页面(比如跳转、关标签页)时,把整个页面冻结存在内存里,下次返回直接恢复,秒开。

但页面被冻结时,浏览器会挂起一些资源,WebSocket 就是其中之一 。所以这条 console 信息其实是浏览器在说:"嘿,我把这个页面的 WS 挂起了" ------它是现象 ,不是根因

真正致命的是那些 404 和 Failed to fetch dynamically imported module

3.2 真正的凶手:动态导入 chunk 404

我们的路由是这样写的(Vue Router 懒加载):

ts 复制代码
{
  path: 'business/shipping-plan',
  name: 'ShippingPlan',
  component: () => import('@/views/shipping-plan/index.vue'),
}

() => import(...) 在构建时会被拆成独立 chunk(shipping-plan-CytjclXm.js),文件名里的哈希由文件内容决定------内容变了,哈希就变。

于是死循环就成立了:

javascript 复制代码
旧版本发布时  →  用户浏览器缓存了旧 HTML + 旧入口 JS
新版本发布时  →  服务器 dist/assets 里旧哈希 chunk 全部被删,只留新哈希 chunk
用户点菜单    →  旧入口 JS 触发 import('shipping-plan-CytjclXm.js')
             →  服务器:404
             →  Promise reject → 菜单"没反应"

用户浏览器里跑的是上一代的代码 ,它只能请求上一代的文件名,而这些文件已经不存在了。

核心矛盾:代码与资源之间只有"文件名哈希"这一条脆弱纽带,而部署切断了它。

3.3 更隐蔽的坑:nginx try_files 把 404 变成了 200 HTML

排查时我还发现一个坑。nginx 配置当时是这样的:

nginx 复制代码
location / {
    try_files $uri $uri/ /index.html;
}

这是 SPA 的标准写法:历史路由(/business/shipping-plan)找不到文件时回退到 index.html,让前端路由接管。

但问题来了------它也会把 assets/shipping-plan-CytjclXm.js 的 404 回退成 index.html,返回 200 状态码 + 一堆 HTML!

也就是说,浏览器拿到的不只是 404,而是:

  • 内容类型是 text/html,不是 application/javascript
  • 动态 import() 拿到 HTML 后解析失败,报出更迷惑的错误(比如 Unexpected token '<')。

旧 chunk 请求应该返回真正的 404,让前端错误处理能识别,而不是被"好心地"回退成 HTML。

3.4 还有一层:index.html 缓存

再看一层:为什么用户会拿着旧 HTML 不放?

nginx 对 index.html 没设任何缓存头。浏览器对没有 Cache-Control 的响应会做启发式缓存 (根据 Last-Modified 推断缓存时间),代理/浏览器就可能把旧 HTML 缓存住。旧 HTML → 旧入口 JS → 旧 chunk → 404,整条链路全断。

3.5 小结:三个问题叠加

# 问题 后果
1 懒加载 chunk 带哈希,部署即删旧文件 旧页面动态 import 404,点击无反应
2 try_files 把 assets 404 回退成 200 HTML 错误被掩盖,前端无法识别
3 index.html 未禁缓存 用户拿到旧 HTML,整链失效
4 bfcache 冻结 WS,恢复后不重连 WS 掉线(console 报错,非致命)

四、方案设计:四层防线

治本思路是:让"旧页面"和"新版本"之间的交接尽量无感。我设计了四层防线,从"源头不缓存"到"出错了也能自愈":

css 复制代码
第 1 层:nginx 缓存策略    ------ 入口 HTML 不缓存、哈希资源长缓存、assets 404 不兜底
第 2 层:构建期版本文件    ------ 每次构建生成唯一 version.json
第 3 层:前端版本轮询      ------ 轮询 version.json,发现新版本主动提示刷新(主动感知)
第 4 层:错误兜底          ------ 动态导入 404 自动刷新一次(最后防线)+ WS bfcache 重连

第 3 层解决"用户啥也不干,最多 5 分钟就知道有新版本 "; 第 4 层解决"用户没等到提示就点了菜单"的极端情况。

五、落地实现

5.1 第 1 层:nginx 缓存策略重塑

nginx 复制代码
# 入口 HTML:禁用缓存,保证每次访问都拿到最新版本
location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    expires 0;
}

# 版本探测文件:必须禁缓存,前端靠它轮询判断是否发布了新版本
location = /version.json {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    expires 0;
}

# SPA 路由回退(内部重定向会命中上方 index.html 的 no-cache)
location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache";
}

# 带内容哈希的静态资源:immutable 长缓存;旧哈希文件直接 404,不回退 index.html
location /assets/ {
    expires 30d;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;   # 关键:assets 不再回退 HTML
}

要点:

  • 哈希资源/assets/*)可以放心 immutable 长缓存------文件名变就代表内容变了,旧文件留着当"墓碑"也无妨(反正没人引用,30 天后被清除);
  • try_files $uri =404 是点睛之笔:旧 chunk 返回真 404,前端错误处理才能识别并自愈;
  • HTML 永远 no-cache:用户每次访问都拿最新入口,新入口引用新资源。

5.2 第 2 层:构建期生成 version.json

vite.config.ts 里加一个构建插件,每次 build 生成 dist/version.json,版本号取构建时刻(毫秒精度),保证每次构建必变:

ts 复制代码
function buildVersionFilePlugin(): Plugin {
  return {
    name: 'build-version-file',
    apply: 'build',
    generateBundle() {
      const version = new Date().toISOString()
      this.emitFile({
        type: 'asset',
        fileName: 'version.json',
        source: JSON.stringify({ version, buildTime: version }, null, 2),
      })
    },
  }
}

构建产物里会多出一个文件:

json 复制代码
{
  "version": "2026-08-29T03:11:07.691Z",
  "buildTime": "2026-08-29T03:11:07.691Z"
}

为什么用时间戳而不是 git commit?因为 CI 环境不一定有 git,而时间戳天然满足"每次构建必变"的语义------我只需要知道"服务器上的版本和我当前运行的是不是同一个"。

5.3 第 3 层:前端版本轮询 + 弹窗提示

新增 src/utils/appUpdate.ts,核心逻辑:

ts 复制代码
async function checkVersionOnce(): Promise<void> {
  // fetch 带 cache: 'no-store' 双保险,query 时间戳防中间代理缓存
  const response = await fetch(`${VERSION_FILE}?t=${Date.now()}`, { cache: 'no-store' })
  if (!response.ok) return

  const current = (await response.json()).version
  const cached = sessionStorage.getItem('app.currentVersion')

  if (cached === null) {
    // 首次进入:只记录启动时版本,不做比对
    sessionStorage.setItem('app.currentVersion', current)
    return
  }
  if (cached === current) return  // 版本一致,无事发生

  // 版本变化:新版本已部署
  sessionStorage.setItem('app.currentVersion', current)

  const action = await ElMessageBox.confirm(
    '检测到系统已发布新版本,点击「立即刷新」使用最新版本。',
    '发现新版本',
    { confirmButtonText: '立即刷新', cancelButtonText: '稍后再说', type: 'info' },
  ).then(() => 'confirm', () => 'cancel')

  if (action === 'confirm') window.location.reload()
}

export function startAppVersionCheck(): void {
  void checkVersionOnce()
  setInterval(() => void checkVersionOnce(), appConfig.appVersion.checkIntervalMs) // 默认 5 分钟
  window.addEventListener('pageshow', (event) => {
    if (event.persisted) void checkVersionOnce()  // bfcache 恢复时立即复查
  })
}

两个细节:

  1. bfcache 恢复时立即复查:用户从 bfcache 恢复的可能就是旧版本页面,不用等下一个 5 分钟;
  2. 提示冷却 20 分钟:用户点"稍后再说"后,别每 5 分钟就骚扰一次。

启动入口在 main.ts

ts 复制代码
app.mount('#app')
startAppVersionCheck()

5.4 第 4 层:动态导入失败兜底 + WS bfcache 重连

轮询是"主动感知",但还有极端情况:用户没等到提示,先点了菜单。此时必须让动态 import 失败也能自愈

(1)识别特征错误并自动刷新

ts 复制代码
const STALE_CHUNK_ERROR_PATTERNS = [
  /Failed to fetch dynamically imported module/i,
  /Importing a module script failed/i,
  /Unable to preload CSS/i,
  /Loading chunk .* failed/i,
]

export function handleStaleChunkError(error: unknown): void {
  if (!isStaleChunkError(error)) return

  console.warn('[app-update] 检测到静态资源版本失效,尝试自动刷新', error)
  if (markAutoReload()) {           // sessionStorage 60s 防循环
    window.location.reload()
    return
  }
  ElMessage.warning('检测到系统已发布新版本,请手动刷新页面(Ctrl+F5)')
}

防循环是关键:如果刷新后依然失败(比如网络问题),60 秒内不再自动刷新,改成提示用户手动硬刷新,避免"无限刷新死循环"。

注册到两个地方,覆盖所有动态导入路径:

ts 复制代码
// router/index.ts ------ 路由懒加载失败
router.onError((error) => handleStaleChunkError(error))

// main.ts ------ 组件内动态 import 等一切未捕获的 Promise 拒绝
window.addEventListener('unhandledrejection', (event) => {
  handleStaleChunkError(event.reason)
})

刷新后拿到的是 no-cache 的新 HTML → 新入口 → 新 chunk,一切恢复正常。

(2)WebSocket bfcache 重连

WebSocketClient 构造函数里注册两个事件,让连接在页面"复活"时主动重建:

ts 复制代码
constructor(private readonly options: WebSocketClientOptions) {
  if (typeof window !== 'undefined' && typeof document !== 'undefined') {
    window.addEventListener('pageshow', this.handlePageShow)               // bfcache 恢复
    document.addEventListener('visibilitychange', this.handleVisibilityChange) // 标签页重新可见
  }
}

/** bfcache 恢复:浏览器已挂起原 WS,立即强制重建连接 */
private readonly handlePageShow = (event: PageTransitionEvent): void => {
  if (event.persisted) this.forceReconnect()
}

/** 页面重新可见:后台挂起/休眠期间连接可能已断,尝试恢复 */
private readonly handleVisibilityChange = (): void => {
  if (document.visibilityState !== 'visible') return
  if (this.state !== 'open' && this.state !== 'connecting') this.connect()
}

forceReconnect() 清理旧 socket 与所有定时器后重新走一遍连接流程(含重新鉴权)。

六、验证结果

  • npm run typecheck
  • npm run build ✅ ------ 构建产物确认出现 dist/version.json
  • 单元测试 ✅ ------ 既有 WS 相关 22 个用例全部通过,新增 appUpdate.spec.ts 6 个用例覆盖错误特征识别

修复后的完整用户旅程:

arduino 复制代码
发布新版本
  ├─ 用户正在使用(≤5 分钟)→ 弹窗"发现新版本" → 一键刷新 → 正常
  ├─ 用户正在使用且先点了菜单 → 自动刷新一次 → 正常
  └─ 用户从 bfcache 恢复 → 立即复查版本 + WS 自动重连 → 正常

七、总结与经验

  1. "页面还活着但资源没了"是 SPA 的通病。代码与资源之间只有哈希文件名一条纽带,部署即切断。凡是前端懒加载 + 内容哈希构建的项目,都会踩这个坑。

  2. 排查时先分清真凶和烟雾弹Page entered Back-Forward Cache 看着吓人,实则是浏览器机制的正常提示;真正的 404 在更后面。按"哪个报错导致功能不可用"来排序,而不是按报错顺序。

  3. nginx try_files 的兜底要按目录收窄 。SPA 回退只该发生在"路由"上,不该发生在"静态资源"上------/assets/ 永远 =404

  4. 缓存策略要"两头极端":HTML 永不缓存、哈希资源永久缓存。中间地带(启发式缓存)才是事故的温床。

  5. 好的兜底要防死循环。自动刷新必须配"冷却窗口",否则一次网络抖动就是无限刷新地狱。

  6. bfcache 是新一代 Bug 温床pageshowpersisted)、visibilitychange 这类"页面还活着但底层已死"的事件,是排查 WS 掉线、定时器失效、轮询停摆的第一现场。

相关推荐
90后的晨仔32 分钟前
uni-app Vue3 状态管理 Pinia 完全指南:从概念到实战的深度解析
前端
mqiqe32 分钟前
AgentScope Java 2.0 集成 Chat Completions Web:一行依赖让你的 Agent 变身 OpenAI 兼容服务
java·开发语言·前端
程序员小八77733 分钟前
后端开发初学TypeScript
前端·javascript·typescript
90后的晨仔35 分钟前
Puppeteer 与 Playwright 深度实战指南:从零到精通,全面提升开发效率
前端
sunphp开发者43 分钟前
阿里云CDN加速配置问题
前端·阿里云·云计算
小小数媒成员1 小时前
存储器层级结构
java·服务器·前端
单线程121382 小时前
从案例分析 Vue3 Tokenizer 源码
前端·javascript·vue.js
明月_清风2 小时前
🖥️ Electron 三进程 Host 实战:从架构设计到生产落地的完整指南
前端·electron·客户端
GIS数据转换器2 小时前
智慧林草“一张图“平台
java·大数据·服务器·前端·javascript·数据库·人工智能