很多做B端后台的开发者都遇到过群里讨论的这两个经典痛点:
- 发版更新矛盾:前端部署新版本后,老用户长时间不关页面(尤其是B端编辑场景,经常打开页面几天不关闭),要么卡在旧版本有紧急bug没法修复,要么跳转新路由时旧chunk文件已经被覆盖,直接报Loading chunk failed白屏;增量部署留旧资源的话,又会遇到旧JS里的bug永远无法自动修复的问题。
- 动态路由打包失效:想做基于后端返回的权限动态路由,但是前端把后端返回的路径直接拼接成import语句,打包后组件被tree-shaking直接砍掉,线上访问直接报Failed to fetch dynamically imported module。
下面给出一套从部署层到前端代码的完整落地方案,兼顾用户体验、开发成本和稳定性,也是目前多数大厂的通用做法。
一、前端发版更新通知&旧版本处理方案
整体设计原则是基础部署兜底+主动柔性提示+异常自动恢复,不会打断用户的正常编辑流程。
1.1 部署层基础优化(必做,零成本)
这是所有方案的基础,不需要改前端代码就能解决80%的旧资源404问题。
静态资源加内容hash:打包时让所有JS/CSS文件名带上内容的哈希值,只要文件内容没修改,文件名就不会变,浏览器会直接读缓存;修改过的文件会自动变哈希,浏览器拉新资源。
// vite.config.js 示例
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/name.contenthash:8.js',
chunkFileNames: 'assets/name.contenthash:8.js',
assetFileNames: 'assets/name.contenthash:8.ext',
},
},
},
});
index.html禁止缓存:入口文件是控制所有资源加载的入口,必须保证每次访问都能拿到最新的版本,Nginx配置如下:
server {
location / {
index.html 禁止缓存
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
root /usr/share/nginx/html/dist;
index index.html;
}
带hash的静态资源开启强缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
增量部署不删旧资源:部署的时候不要把旧的dist目录直接rm -rf,新资源覆盖即可,旧资源保留至少7天,这样老用户访问历史版本的chunk也不会出现404。
1.2 前端主动版本检测(柔性提示,不打断用户)
针对B端用户经常打开编辑页面不关闭的场景,不要直接强制刷新,做可交互的提示。
步骤1:打包时注入版本号
打包时把package.json的版本号写入到入口文件的全局变量,同时生成一个单独的version.json文件到部署目录,两个版本号保持同步。
// vite.config.js 注入全局版本号
import pkg from './package.json';
export default defineConfig({
define: {
APP_VERSION: JSON.stringify(pkg.version),
},
build: {
// 打包时同步生成version.json
rollupOptions: {
plugins: [
{
name: 'generate-version-json',
writeBundle() {
const fs = require('fs');
const path = require('path');
const versionJson = { version: pkg.version, updateType: 'normal' };
fs.writeFileSync(
path.resolve(__dirname, 'dist/version.json'),
JSON.stringify(versionJson)
);
},
},
],
},
},
});
步骤2:定时轮询检测更新
前端每隔10分钟请求一次version.json,对比当前版本和线上版本,不一致则弹窗提示用户刷新。如果用户正在编辑表单,会拦截直接刷新操作,提示先保存内容。
// utils/versionCheck.js
let currentVersion = window.APP_VERSION;
let checkTimer = null ;
export function startVersionCheck(onUpdate) {
// 每10分钟检测一次
checkTimer = setInterval(async () => {
try {
// 请求加时间戳防止缓存
const res = await fetch(`/version.json?t=${Date.now()}`);
const data = await res.json();
if (data.version !== currentVersion) {
onUpdate?.(data);
}
} catch (e) {
// 忽略网络错误
}
}, 10 * 60 * 1000);
}
// 停止检测(比如用户退出登录后可以停掉)
export function stopVersionCheck() {
if (checkTimer) clearInterval(checkTimer);
}
步骤3:入口挂载提示逻辑
根据业务场景选择提示方式,B端建议用确认弹窗,C端可以用toast+自动刷新。
// main.js 入口文件
import { startVersionCheck } from "@/utils/versionCheck";
import { ElMessageBox } from "element-plus";
startVersionCheck((updateData) => {
// 紧急bug修复版本,直接刷新不提示
if (updateData.updateType === 'force') {
window.location.reload();
return ;
}
// 普通更新,弹窗提示
ElMessageBox.confirm(
`检测到系统已更新,建议刷新页面获取最新功能(当前编辑内容刷新后可能丢失,建议保存后再刷新)`,
"版本更新提示",
{
confirmButtonText: "立即刷新",
cancelButtonText: "稍后再说",
type: "warning",
closeOnClickModal: false , // 不允许点击遮罩关闭
}
).then(() => {
window.location.reload();
});
});
1.3 资源加载失败兜底(最后防线)
如果用户忽略了提示,或者网络波动没检测到更新,导致访问新路由时旧的chunk已经被删掉,这个时候自动刷新一次即可恢复,不用让用户手动清缓存。
// vite 项目监听资源加载失败
window.addEventListener('vite:preloadError', (event) => {
// 标记只刷新一次,防止死循环
const reloadFlag = sessionStorage.getItem('chunk_error_reload');
if (!reloadFlag) {
sessionStorage.setItem('chunk_error_reload', '1');
window.location.reload();
}
});
// webpack 项目监听chunk加载失败
const originalAddEventListener = window.addEventListener;
window.addEventListener = function (type, listener, options) {
if (type === 'unhandledrejection') {
const originalListener = listener;
listener = function (event) {
const error = event.reason;
if (error?.message?.includes('Loading chunk') || error?.message?.includes('ChunkLoadError')) {
const reloadFlag = sessionStorage.getItem('chunk_error_reload');
if (!reloadFlag) {
sessionStorage.setItem('chunk_error_reload', '1');
window.location.reload();
}
}
originalListener?.(event);
};
}
originalAddEventListener.call(this , type, listener, options);
};
二、Vue动态路由打包丢组件解决方案
核心逻辑:后端只负责返回用户有权限的路由路径列表,前端提前用import.meta.glob预加载所有可能的路由组件,生成路径映射表,匹配后端的权限列表注册路由,这样打包的时候所有组件都会被正常打包,不会被tree-shaking砍掉。
2.1 预生成组件路径映射表
把所有路由组件统一放到src/views目录下,用import.meta.glob获取所有组件的加载函数。
// router/dynamicRouter.js
// 预加载所有views目录下的vue组件
const componentModules = import .meta.glob('../views/**/*.vue');
// 生成路径到组件加载函数的映射表
const componentMap = {};
Object.keys(componentModules).forEach((path) => {
// 转换路径格式:../views/user/index.vue -> user/index
const match = path.match(/\/views\/(.+)\.vue$/);
if (match) {
componentMapmatch\[1] = componentModulespath;
}
});
/**
* 根据后端返回的路由列表生成真实路由配置
* @param {Array} routeList 后端返回的路由列表,格式如 { path: '/user', component: 'user/index', meta: { title: '用户管理' } }
*/
export function generateRoutes(routeList) {
const routes = \[\];
routeList.forEach((item) => {
const componentLoader = componentMapitem.component;
if (componentLoader) {
routes.push({
path: item.path,
component: componentLoader,
meta: item.meta || {},
});
}
});
return routes;
}
2.2 路由守卫中动态注册路由
用户登录后获取权限路由列表,动态添加到路由中。
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { generateRoutes } from './dynamicRouter';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import ('../views/login/index.vue') },
],
});
// 标记是否已经注册过动态路由
let hasRegistered = false ;
router.beforeEach(async (to, from , next) => {
const token = localStorage.getItem('token');
if (!token && to.path !== '/login') {
next('/login');
return ;
}
if (token && !hasRegistered) {
try {
// 请求后端获取当前用户有权限的路由列表
const { data: routeList } = await fetch('/api/user/routes');
// 生成动态路由并注册
const dynamicRoutes = generateRoutes(routeList);
dynamicRoutes.forEach((route) => router.addRoute(route));
hasRegistered = true ;
// 重新访问当前路由,触发新的路由匹配
next({ ...to, replace: true });
} catch (e) {
localStorage.removeItem('token');
next('/login');
}
} else {
next();
}
});
export default router;
后端返回的权限路由示例:
{ "path": "/user", "component": "user/index", "meta": { "title": "用户管理", "icon": "user" } }, { "path": "/role", "component": "system/role/index", "meta": { "title": "角色管理", "icon": "role" } }
这样设计的好处是:前端打包的时候所有组件都会被正常打包,不需要把路由配置放到后端;后端只需要维护用户的权限路径列表,修改权限不需要重新发版;所有路由组件都能正常被构建工具识别,不会出现打包后找不到模块的问题。
三、方案落地效果
|------------|------------------------|---------------|
| 场景 | 处理方式 | 用户体验 |
| 普通功能更新 | 弹窗提示,用户可选择稍后刷新,编辑内容不丢失 | 无感知,不打断工作 |
| 紧急bug修复 | 检测到force标记直接自动刷新 | 无感知自动修复问题 |
| 旧chunk加载失败 | 自动刷新一次恢复 | 最多一次刷新,不会出现白屏 |
| 动态路由权限调整 | 后端修改权限列表即可,无需前端发版 | 权限实时生效,无打包问题 |
四、总结
本文围绕B端后台开发中两个高频痛点给出了可落地的完整方案:
- 发版更新通知采用"部署层基础优化 + 前端主动轮询检测 + 资源加载失败兜底"三层架构,既保证了紧急bug能强制修复,又兼顾了用户编辑场景的体验。
- 动态路由打包通过import.meta.glob预加载+路径映射表的方式,解决了tree-shaking导致组件丢失的问题,后端只需维护权限列表即可实现动态路由。
以上方案已在多个生产环境验证,可根据团队实际情况灵活调整。欢迎交流讨论!