From 5e13342f7b3544f015c9fc01b5c87bb6068df26f Mon Sep 17 00:00:00 2001 From: Leo <98382335+gaoziman@users.noreply.github.com> Date: Sun, 6 Jul 2025 02:33:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(router):=20=E4=BF=AE=E5=A4=8DVue=20Router?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E9=94=99=E8=AF=AF=E5=92=8C=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增导航防护机制 - NavigationGuard类防止快速路由切换导致的错误 - 实现防抖和安全导航方法(safePush/safeReplace) * 新增组件安全加载机制 - safeAsyncComponent包装器处理异步组件加载错误 - 支持重试机制和ChunkLoadError恢复 * 增强路由守卫错误处理 - 全面的try-catch错误捕获 - 统一的路由错误处理函数 * 优化路由配置 - 使用安全组件加载器包装所有异步组件 - 改进路由重定向逻辑 解决了"Cannot read properties of null (reading 'isUnmounted')"等Vue Router错误 --- src/router/guard.ts | 159 +++++++++++++++++++++------------- src/router/index.ts | 12 +++ src/router/routes.inner.ts | 26 ++++-- src/utils/component-guard.ts | 109 +++++++++++++++++++++++ src/utils/navigation-guard.ts | 124 ++++++++++++++++++++++++++ src/utils/router-safety.ts | 100 +++++++++++++++++++++ 6 files changed, 466 insertions(+), 64 deletions(-) create mode 100644 src/utils/component-guard.ts create mode 100644 src/utils/navigation-guard.ts create mode 100644 src/utils/router-safety.ts diff --git a/src/router/guard.ts b/src/router/guard.ts index 0bb10c9..80951c3 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -1,6 +1,7 @@ import type { Router } from 'vue-router' import { useAppStore, useRouteStore, useTabStore } from '@/store' import { local } from '@/utils' +import { handleRouterError } from '@/utils/router-safety' const title = import.meta.env.VITE_APP_NAME @@ -10,72 +11,112 @@ export function setupRouterGuard(router: Router) { const tabStore = useTabStore() router.beforeEach(async (to, from, next) => { - // 判断是否是外链,如果是直接打开网页并拦截跳转 - if (to.meta.href) { - window.open(to.meta.href) - next(false) // 取消当前导航 - return - } - // 开始 loadingBar - appStore.showProgress && window.$loadingBar?.start() - - // 判断有无TOKEN,登录鉴权 - const isLogin = Boolean(local.get('accessToken')) - - // 如果是login路由,直接放行 - if (to.name === 'login') { - // login页面不需要任何认证检查,直接放行 - // 继续执行后面的逻辑 - } - // 如果路由明确设置了requiresAuth为false,直接放行 - else if (to.meta.requiresAuth === false) { - // 明确设置为false的路由直接放行 - // 继续执行后面的逻辑 - } - // 如果路由设置了requiresAuth为true,且用户未登录,重定向到登录页 - else if (to.meta.requiresAuth === true && !isLogin) { - const redirect = to.name === '404' ? undefined : to.fullPath - next({ path: '/login', query: { redirect } }) - return - } - - // 判断路由有无进行初始化 - if (!routeStore.isInitAuthRoute) { - await routeStore.initAuthRoute() - // 动态路由加载完回到根路由 - if (to.name === 'notFound' || to.name === '404') { - // 等待权限路由加载好了,回到之前的路由,否则404 - next({ - path: to.fullPath, - replace: true, - query: to.query, - hash: to.hash, - }) + try { + // 判断是否是外链,如果是直接打开网页并拦截跳转 + if (to.meta.href) { + window.open(to.meta.href) + next(false) // 取消当前导航 return } - } + // 开始 loadingBar + appStore.showProgress && window.$loadingBar?.start() - // 如果用户已登录且访问login页面,重定向到首页 - if (to.name === 'login' && isLogin) { - next({ path: '/' }) - return - } + // 判断有无TOKEN,登录鉴权 + const isLogin = Boolean(local.get('accessToken')) - next() + // 如果是login路由,直接放行 + if (to.name === 'login') { + // login页面不需要任何认证检查,直接放行 + // 继续执行后面的逻辑 + } + // 如果路由明确设置了requiresAuth为false,直接放行 + else if (to.meta.requiresAuth === false) { + // 明确设置为false的路由直接放行 + // 继续执行后面的逻辑 + } + // 如果路由设置了requiresAuth为true,且用户未登录,重定向到登录页 + else if (to.meta.requiresAuth === true && !isLogin) { + const redirect = to.name === '404' ? undefined : to.fullPath + next({ path: '/login', query: { redirect } }) + return + } + + // 判断路由有无进行初始化 + if (!routeStore.isInitAuthRoute) { + try { + await routeStore.initAuthRoute() + } + catch (error) { + console.error('路由初始化失败:', error) + // 路由初始化失败,重定向到登录页 + next({ path: '/login' }) + return + } + + // 动态路由加载完回到根路由 + if (to.name === 'notFound' || to.name === '404') { + // 等待权限路由加载好了,回到之前的路由,否则404 + next({ + path: to.fullPath, + replace: true, + query: to.query, + hash: to.hash, + }) + return + } + } + + // 如果用户已登录且访问login页面,重定向到首页 + if (to.name === 'login' && isLogin) { + next({ path: '/' }) + return + } + + next() + } + catch (error) { + console.error('路由守卫执行错误:', error) + // 发生错误时确保loadingBar结束 + appStore.showProgress && window.$loadingBar?.error?.() + next(false) + } }) + router.beforeResolve((to) => { - // 设置菜单高亮 - routeStore.setActiveMenu(to.meta.activeMenu ?? to.fullPath) - // 添加tabs - tabStore.addTab(to) - // 设置高亮标签; - tabStore.setCurrentTab(to.fullPath as string) + try { + // 设置菜单高亮 + routeStore.setActiveMenu(to.meta.activeMenu ?? to.fullPath) + // 添加tabs + tabStore.addTab(to) + // 设置高亮标签; + tabStore.setCurrentTab(to.fullPath as string) + } + catch (error) { + console.error('路由beforeResolve错误:', error) + } }) - router.afterEach((to) => { - // 修改网页标题 - document.title = `${to.meta.title} - ${title}` - // 结束 loadingBar - appStore.showProgress && window.$loadingBar?.finish() + router.afterEach((to, from, failure) => { + try { + // 修改网页标题 + document.title = `${to.meta.title} - ${title}` + // 结束 loadingBar + if (failure) { + appStore.showProgress && window.$loadingBar?.error?.() + } + else { + appStore.showProgress && window.$loadingBar?.finish() + } + } + catch (error) { + console.error('路由afterEach错误:', error) + appStore.showProgress && window.$loadingBar?.error?.() + } + }) + + // 添加路由错误处理 + router.onError((error) => { + handleRouterError(error, '全局路由') + appStore.showProgress && window.$loadingBar?.error?.() }) } diff --git a/src/router/index.ts b/src/router/index.ts index d7f8db8..947c30c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -2,12 +2,24 @@ import type { App } from 'vue' import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router' import { setupRouterGuard } from './guard' import { routes } from './routes.inner' +import { createNavigationGuard } from '@/utils/navigation-guard' const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env export const router = createRouter({ history: VITE_ROUTE_MODE === 'hash' ? createWebHashHistory(VITE_BASE_URL) : createWebHistory(VITE_BASE_URL), routes, + // 添加路由配置优化 + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition + } + return { top: 0 } + }, }) + +// 创建导航防护实例 +export const navigationGuard = createNavigationGuard(router) + // 安装vue路由 export async function installRouter(app: App) { // 添加路由守卫 diff --git a/src/router/routes.inner.ts b/src/router/routes.inner.ts index a080117..a18cf4c 100644 --- a/src/router/routes.inner.ts +++ b/src/router/routes.inner.ts @@ -1,4 +1,5 @@ import type { RouteRecordRaw } from 'vue-router' +import { safeAsyncComponent } from '@/utils/component-guard' /* 页面中的一些固定路由,错误页等 */ export const routes: RouteRecordRaw[] = [ @@ -12,7 +13,10 @@ export const routes: RouteRecordRaw[] = [ { path: '/login', name: 'login', - component: () => import('@/views/login/index.vue'), // 注意这里要带上 文件后缀.vue + component: safeAsyncComponent( + () => import('@/views/login/index.vue'), + { delay: 0, timeout: 8000 }, + ), meta: { title: '登录', withoutTab: true, @@ -21,7 +25,10 @@ export const routes: RouteRecordRaw[] = [ { path: '/403', name: '403', - component: () => import('@/views/error/403/index.vue'), + component: safeAsyncComponent( + () => import('@/views/error/403/index.vue'), + { delay: 0, timeout: 5000 }, + ), meta: { title: '用户无权限', withoutTab: true, @@ -30,7 +37,10 @@ export const routes: RouteRecordRaw[] = [ { path: '/404', name: '404', - component: () => import('@/views/error/404/index.vue'), + component: safeAsyncComponent( + () => import('@/views/error/404/index.vue'), + { delay: 0, timeout: 5000 }, + ), meta: { title: '找不到页面', icon: 'icon-park-outline:ghost', @@ -40,7 +50,10 @@ export const routes: RouteRecordRaw[] = [ { path: '/500', name: '500', - component: () => import('@/views/error/500/index.vue'), + component: safeAsyncComponent( + () => import('@/views/error/500/index.vue'), + { delay: 0, timeout: 5000 }, + ), meta: { title: '服务器错误', icon: 'icon-park-outline:close-wifi', @@ -49,7 +62,10 @@ export const routes: RouteRecordRaw[] = [ }, { path: '/:pathMatch(.*)*', - component: () => import('@/views/error/404/index.vue'), + component: safeAsyncComponent( + () => import('@/views/error/404/index.vue'), + { delay: 0, timeout: 5000 }, + ), name: 'notFound', meta: { title: '找不到页面', diff --git a/src/utils/component-guard.ts b/src/utils/component-guard.ts new file mode 100644 index 0000000..4eaccfa --- /dev/null +++ b/src/utils/component-guard.ts @@ -0,0 +1,109 @@ +import type { AsyncComponentLoader, Component } from 'vue' +import { defineAsyncComponent } from 'vue' + +/** + * 安全的异步组件加载器 + * 防止在组件卸载时继续加载导致的内存泄漏和错误 + */ +export function safeAsyncComponent( + loader: AsyncComponentLoader, + options?: { + loadingComponent?: Component + errorComponent?: Component + delay?: number + timeout?: number + suspensible?: boolean + onError?: (error: Error, retry: () => void, fail: () => void, attempts: number) => any + }, +) { + const safeLoader: AsyncComponentLoader = () => { + return loader().catch((error) => { + console.error('异步组件加载失败:', error) + + // 如果是网络错误或者加载错误,返回一个空的组件 + if (error.name === 'ChunkLoadError' || error.message?.includes('Loading chunk')) { + console.warn('检测到代码分割加载错误,尝试重新加载页面') + // 延迟重新加载页面,避免无限循环 + setTimeout(() => { + window.location.reload() + }, 1000) + } + + // 返回一个错误组件 + return Promise.resolve({ + template: '