关键词:Vite 构建缓存 · 路由懒加载 · nginx 缓存策略 · version.json 版本探测 · bfcache
一、起因:一次平平无奇的发布
我们的项目是一个 Vue 3 + TypeScript + Vite 6 的中后台系统,部署在 nginx(Docker 容器)里。所有路由都做了懒加载 ,构建产物是带内容哈希的文件名(index-BhpEQhsP.js、table-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 恢复时立即复查
})
}
两个细节:
- bfcache 恢复时立即复查:用户从 bfcache 恢复的可能就是旧版本页面,不用等下一个 5 分钟;
- 提示冷却 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.ts6 个用例覆盖错误特征识别
修复后的完整用户旅程:
arduino
发布新版本
├─ 用户正在使用(≤5 分钟)→ 弹窗"发现新版本" → 一键刷新 → 正常
├─ 用户正在使用且先点了菜单 → 自动刷新一次 → 正常
└─ 用户从 bfcache 恢复 → 立即复查版本 + WS 自动重连 → 正常
七、总结与经验
-
"页面还活着但资源没了"是 SPA 的通病。代码与资源之间只有哈希文件名一条纽带,部署即切断。凡是前端懒加载 + 内容哈希构建的项目,都会踩这个坑。
-
排查时先分清真凶和烟雾弹 。
Page entered Back-Forward Cache看着吓人,实则是浏览器机制的正常提示;真正的 404 在更后面。按"哪个报错导致功能不可用"来排序,而不是按报错顺序。 -
nginx
try_files的兜底要按目录收窄 。SPA 回退只该发生在"路由"上,不该发生在"静态资源"上------/assets/永远=404。 -
缓存策略要"两头极端":HTML 永不缓存、哈希资源永久缓存。中间地带(启发式缓存)才是事故的温床。
-
好的兜底要防死循环。自动刷新必须配"冷却窗口",否则一次网络抖动就是无限刷新地狱。
-
bfcache 是新一代 Bug 温床 。
pageshow(persisted)、visibilitychange这类"页面还活着但底层已死"的事件,是排查 WS 掉线、定时器失效、轮询停摆的第一现场。