Vue3 发版后路由点不开:动态加载模块 404 的根治方案
问题现象
前端是 Vue 3 + Vite + Vue Router(history 模式)构建的 SPA,存在多个懒加载路由。每次发布新版本后,用户会间歇性遇到:
arduino
Uncaught (in promise) TypeError: Failed to fetch dynamically imported module:
https://xxx.com/assets/index-EHq0xuRG.js
表现:旧页面还开着时,点击某个路由"点不开"(白屏或报错),刷新浏览器后又恢复正常。
根因分析
1. Vite 的构建产物命名机制
Vite 在生产构建时,会把每个入口和懒加载 chunk 输出为带内容哈希的文件名:
bash
dist/assets/index-EHq0xuRG.js
dist/assets/index-AB3cD9fP.js ← 代码变了,哈希就变
哈希由文件内容决定,代码一改,哈希必然变,旧文件名在构建后即被删除。
2. 动态 import 与「脏页面」的时差
路由懒加载本质是:
ts
// 等价于 const Page = () => import('../views/GenericView.vue')
首次加载时,浏览器拿到的是当时最新 的 index.html,里面引用的 JS 也是最新哈希,一切正常。
问题发生在发版过程中,用户恰好停留在旧页面:
- 用户的旧页面还在内存里运行,模块图是旧构建的
- 此时发版 → 服务器上旧的
index-XXXX.js被删除 - 用户点击触发懒加载的
import()→ 浏览器去请求已被删除的旧 JS - 服务器返回 404 → 浏览器抛
Failed to fetch dynamically imported module - 刷新页面 :重新拿到新
index.html→ 引用的也是新哈希 → 恢复正常
所以这不是"路由配置错了",而是旧页面内存里的模块引用,与服务器最新资源之间出现版本错位。刷新能解决,但用户体验是"点不开"。
3. 关键认知
首次加载(页面刷新/首次访问)永远安全,因为
index.html会给出当前最新资源引用。 只有旧页面持续运行时的懒加载才可能踩到已被删的旧 chunk。
因此修复的两个突破口:
- 让旧页面能感知"有新版本发布了",然后强制刷新
- 让懒加载失败时能即时触发刷新兜底
修复思路(方案选型)
针对"旧页面如何得知新版本",主流有几种方案:
| 方案 | 机制 | 成本 | 是否真实时 |
|---|---|---|---|
定时轮询 setInterval |
周期 fetch index.html 比对版本 | 低 | 有延迟,浪费流量 |
| 页面聚焦时探测(本文采用) | visibilitychange + focus 触发 |
极低,零请求开销 | 回到页面即检 |
| SSE / WebSocket 推送 | 后端长连接推版本信号 | 高,需后端配合 | 是 |
本文采用「聚焦探测 + 懒加载失败兜底」双保险:不发轮询请求,用户回到页面时检查一次,同时懒加载失败瞬间立即触发------覆盖全部场景,效率最高。
完整实现
整体分三层:
- 构建层 (Vite 插件):注入
build-version版本号到index.html - 运行层(探测模块):聚焦时比对版本,变化则整页刷新;懒加载失败兜底
- 部署层 (nginx):
index.html不缓存,/assets/长缓存
第一步:Vite 插件注入构建版本号
创建 vite/version-probe.ts:
ts
import type { Plugin } from 'vite'
// 注入构建版本号到 index.html,供前端检测到新版本发布后强制刷新。
// index.html 本身必须 no-cache,版本值每次构建都变,reload 即拿到最新引用。
const BUILD_VERSION = String(Date.now())
export function versionProbe(): Plugin {
return {
name: 'version-probe',
transformIndexHtml(html) {
return {
html,
tags: [
{
tag: 'meta',
attrs: { name: 'build-version', content: BUILD_VERSION },
injectTo: 'head-prepend',
},
],
}
},
}
}
要点:
- 版本值用
Date.now()毫秒时间戳 :每次构建必变,天然唯一,无副作用。若需要对应业务版本号,可换成process.env注入的 Git commit/short sha。 transformIndexHtml钩子 :Vite 官方 HTML 转换钩子,injectTo: 'head-prepend'把 meta 插到<head>最前面。- 生效范围:
dev和build都会执行此钩子,本地开发也带着版本号,便于联调。
注册到 vite.config.ts:
ts
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { versionProbe } from './vite/version-probe.js'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), versionProbe()],
})
⚠️ 注意:
tsconfig若用node16/nodenext模块解析,相对导入需显式写.js扩展名(源码用.ts,编译/运行时按.js解析)。若报TS2835,把./vite/version-probe写成./vite/version-probe.js即可。
构建后 dist/index.html 会多出:
html
<meta name="build-version" content="1712345678901" />
第二步:前端运行时探测模块
创建 src/utils/version-probe.ts:
ts
// 版本发布探测:页面回到前台/聚焦时比对 build-version,不一致则整页刷新。
// 页面始终开着时,懒加载失败由 router.onError 即时触发同一函数兜底。
// 不发主动轮询请求(interval 浪费流量),仅在用户重新聚焦页面时检查。
const VERSION_META = 'meta[name="build-version"]'
function currentVersion(): string | null {
return document.querySelector(VERSION_META)?.getAttribute('content') ?? null
}
// 核心:拉取服务器最新 index.html,对比版本号,不一致则整页刷新
async function check() {
const current = currentVersion()
// 本页无版本标签时,跳过(旧构建或未启用版本机制)
if (!current) return
try {
// cache: 'no-store' 绕过 HTTP/浏览器缓存,强制读最新 index.html
const res = await fetch(location.href, { cache: 'no-store' })
if (!res.ok) return // 网络/服务异常,不误刷
const html = await res.text()
const latest = html.match(/name="build-version"\s+content="([^"]+)"/)?.[1]
if (latest && latest !== current) {
console.info('[version-probe] 检测到新版本,刷新页面')
location.reload()
}
} catch {
/* 探测失败不误伤当前会话 */
}
}
// 懒加载 chunk 404(Vite 动态 import 失败)→ 大概率是发版导致旧资源失效,立即刷新
function onImportError(error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
if (msg.includes('Failed to fetch dynamically imported module')) {
check()
}
}
export function startVersionProbe() {
const onVisible = () => {
if (document.visibilityState === 'visible') check()
}
document.addEventListener('visibilitychange', onVisible)
// focus 兜底(visibilitychange 对部分嵌入/后台窗口不触发时)
window.addEventListener('focus', onVisible)
}
export const versionProbeApi = { check, onImportError }
实现细节:
fetch(location.href, { cache: 'no-store' }):no-store强制绕过 HTTP 缓存和浏览器缓存,保证读到服务器最新 的index.html。这一步依赖 nginx 对 index.html 不缓存(见第四步)。- 正则提取版本 :
/name="build-version"\s+content="([^"]+)"/,匹配第一步注入的 meta。 location.reload():整页刷新,重新加载最新index.html,进而加载新哈希的 JS/CSS → 自愈。- 容错 :
res.ok非 2xx 或抛异常都不刷新,避免网络抖动误杀用户会话。 onImportError只匹配动态 import 失败:防止其他路由错误触发刷新。onVisible闭包 :被两个事件监听器共用作为回调,保持同一身份,方便将来removeEventListener。
第三步:接入 App 启动与路由错误兜底
src/main.ts:应用挂载后启动聚焦探测。
ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { startVersionProbe } from './utils/version-probe'
createApp(App).use(createPinia()).use(router).mount('#app')
startVersionProbe()
src/router/index.ts:捕获懒加载失败,即时触发版本探测。
ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { versionProbeApi } from '../utils/version-probe'
// ... 其他视图导入
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
// 懒加载 chunk 失效(发版后旧模块 404)时,触发版本探测决定是否整页刷新。
router.onError(versionProbeApi.onImportError)
export default router
router.onError 是 vue-router 的导航钩子,路由懒加载(() => import())失败时会触发。我们在其中判断是否 Failed to fetch dynamically imported module------若是,说明旧 chunk 已失效,调 check() 拉最新版本决定刷新。
第四步:nginx 缓存策略(部署层配合)
整个方案成立的前提:index.html 不能被缓存。否则探测永远读到旧版本,版本永远不变化,自愈失效。
nginx.conf 关键配置:
nginx
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# history 路由回退:直接访问/刷新子路径也兜回 index.html,避免 404
try_files $uri $uri/ /index.html;
# index.html 不缓存 ------ 版本探测需每次读到最新,否则懒加载/版本自愈失效
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# Vite 构建产物带内容哈希,内容变则文件名变 → 可永久缓存
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
}
}
}
配置说明:
location = /index.html(精确匹配) :Cache-Control: no-cache, no-store, must-revalidate让浏览器每次都向服务器校验最新版本;expires 0不缓存。location /assets/:哈希文件名天然内容寻址,内容不变则文件名不变,max-age=31536000, immutable长缓存可极大加速重复访问(一年不重下)。try_files $uri $uri/ /index.html:这是 history 路由必需的 SPA fallback ,否则用户直接刷新/users这种子路径会 404。与本问题相关但独立------务必确认已配置。- 若资源经 CDN,需保证 CDN 透传这些
Cache-Control(源站已设置时默认透传)。
部署后 nginx -s reload 生效,可用 curl -I 验证:
bash
# index.html 应返回 no-cache
curl -I https://your-domain.com/index.html
# 产物应返回 immutable
curl -I https://your-domain.com/assets/index-xxxx.js
完整流程串起来
scss
[构建] Vite 插件注入 build-version=<时间戳> 到 index.html
│
▼
[部署] nginx:index.html no-cache,assets/ 永久缓存
│
▼
[运行] 用户开着旧页面(内存中是旧模块图,旧 build-version)
│
发版 → 服务器 index.html 变为新 build-version,旧 JS 被删
│
场景A:用户点击懒加载路由
│ → import() 旧 chunk 404
│ → router.onError 捕获 'Failed to fetch dynamically imported module'
│ → check() 拉新 index.html,版本不一致 → location.reload()
│ → 拿到新 index.html → 新资源 → 自愈 ✅
│
场景B:用户切走再切回页面
│ → visibilitychange/focus 触发 check()
│ → 版本不一致 → 整页刷新 → 自愈 ✅
│
场景C:发版后用户首次刷新/新访客
│ → 直接拿新 index.html → 无需探测 ✅
验证方法(本地模拟发版)
-
npm run build,确认dist/index.html含name="build-version"。 -
npm run dev,浏览器打开页面。 -
DevTools 执行,把本页版本改成伪造的"新版",模拟"服务器已更新但本页还是旧版":
jsdocument.querySelector('meta[name=build-version]') .setAttribute('content', 'a-fake-new-version') -
触发聚焦(
window.dispatchEvent(new Event('focus')))或切走再切回页面。 -
若页面发生整页刷新(导航导致执行上下文销毁、URL 重载),即说明探测→比对→reload 全链路生效。
常见问题 FAQ
Q:为什么刷新一下就好了,还要做这些? A:刷新让浏览器重新拿 index.html,引用的天然是当前最新资源,所以正常。我们的方案就是把"刷新"这件事自动化,且只做必要时机(聚焦/失败),不影响用户体验。
Q:为什么不直接用 setInterval 一直轮询? A:可以,但用户停留在页面的每分钟都发一次全量 HTML 请求,浪费流量且无必要。聚焦探测覆盖了绝大多数"用户回来用"的场景,配合懒加载失败的即时兜底,零轮询开销。
Q:build-version 用 Date.now() 行吗,构建会不会相同? A:秒级时间戳理论上极限下可能重复,但毫秒级 Date.now() 两次构建撞同值得概率可忽略。若部署在 CI 且同秒多次构建,可换成 Git 短 commit 或 CI 版本号,更语义化。
Q:CDN 缓存了 index.html 怎么办? A:方案依赖 index.html 不被缓存。若 CDN 强制缓存了 HTML,需在 CDN 控制台给 HTML 请求配置缓存过期或校验逻辑;否则探测永远读到旧版,退化为"刷新才恢复",但不会报错。这是必须的部署前提。
Q:SSE / WebSocket 不是更"事件化"吗? A:确实,但需要后端维护长连接和推送端点,成本高。对绝大多数管理后台/业务系统,聚焦探测 + 失败兜底已完全覆盖,属过度设计。若你的场景要求"秒级无感更新且页面长驻",再考虑。
总结
| 层面 | 做了什么事 | 关键文件 |
|---|---|---|
| 构建 | 注入版本号 meta | vite/version-probe.ts、vite.config.ts |
| 运行 | 聚焦探测 + 懒加载失败兜底刷新 | src/utils/version-probe.ts、src/main.ts、src/router/index.ts |
| 部署 | index.html 不缓存、assets 长缓存、SPA fallback | nginx.conf |
这套方案零第三方依赖、零轮询开销、逻辑清晰、可审计,从构建到部署三层配合,系统性解决 SPA 发版后动态路由打不开的问题。