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: '
组件加载失败
', + }) + }) + } + + return defineAsyncComponent({ + loader: safeLoader, + loadingComponent: options?.loadingComponent, + errorComponent: options?.errorComponent, + delay: options?.delay ?? 200, + timeout: options?.timeout ?? 30000, + suspensible: options?.suspensible ?? false, + onError: options?.onError || ((error, retry, fail, attempts) => { + console.error(`异步组件加载错误 (第${attempts}次尝试):`, error) + if (attempts <= 3) { + retry() + } + else { + fail() + } + }), + }) +} + +/** + * 创建路由组件的安全加载器 + */ +export function createSafeRouteComponent(componentPath: string) { + return safeAsyncComponent( + () => import(/* @vite-ignore */ `/src/views${componentPath}.vue`), + { + delay: 100, + timeout: 10000, + onError: (error, retry, fail, attempts) => { + console.error(`路由组件加载失败: ${componentPath}`, error) + + // 对于路由组件,最多重试2次 + if (attempts <= 2) { + console.warn(`重试加载组件: ${componentPath} (第${attempts}次)`) + retry() + } + else { + console.error(`组件加载最终失败: ${componentPath}`) + fail() + } + }, + }, + ) +} + +/** + * 清理组件缓存,用于解决热更新时的问题 + */ +export function clearComponentCache() { + // 在开发环境下清理模块缓存 + if (import.meta.hot) { + import.meta.hot.invalidate() + } +} + +/** + * 组件安全性检查 + */ +export function validateComponent(component: any): boolean { + if (!component) { + console.error('组件为空或未定义') + return false + } + + if (typeof component !== 'object' && typeof component !== 'function') { + console.error('组件类型不正确:', typeof component) + return false + } + + return true +} diff --git a/src/utils/navigation-guard.ts b/src/utils/navigation-guard.ts new file mode 100644 index 0000000..acdda41 --- /dev/null +++ b/src/utils/navigation-guard.ts @@ -0,0 +1,124 @@ +import type { RouteLocationNormalized, Router } from 'vue-router' + +/** + * 导航防护类,用于防止快速路由切换导致的问题 + */ +export class NavigationGuard { + private router: Router + private isNavigating = false + private pendingNavigation: string | null = null + private navigationTimer: NodeJS.Timeout | null = null + private readonly NAVIGATION_DEBOUNCE = 100 // 100ms防抖 + + constructor(router: Router) { + this.router = router + this.setupGuards() + } + + private setupGuards() { + // 在路由开始时设置导航状态 + this.router.beforeEach((to, from, next) => { + // 如果正在导航中,取消之前的导航 + if (this.isNavigating && this.pendingNavigation === to.fullPath) { + return next(false) + } + + this.isNavigating = true + this.pendingNavigation = to.fullPath + + // 清除之前的定时器 + if (this.navigationTimer) { + clearTimeout(this.navigationTimer) + } + + // 设置导航完成的定时器 + this.navigationTimer = setTimeout(() => { + this.isNavigating = false + this.pendingNavigation = null + }, this.NAVIGATION_DEBOUNCE) + + next() + }) + + // 在路由完成或取消时重置状态 + this.router.afterEach(() => { + this.resetNavigationState() + }) + + // 监听导航错误 + this.router.onError((error) => { + console.error('Navigation error:', error) + this.resetNavigationState() + }) + } + + private resetNavigationState() { + if (this.navigationTimer) { + clearTimeout(this.navigationTimer) + this.navigationTimer = null + } + this.isNavigating = false + this.pendingNavigation = null + } + + /** + * 安全的路由跳转 + */ + async safePush(to: string | RouteLocationNormalized): Promise { + try { + // 如果正在导航到相同路由,则忽略 + if (this.pendingNavigation === (typeof to === 'string' ? to : to.fullPath)) { + return true + } + + await this.router.push(to) + return true + } + catch (error: any) { + // 忽略重复导航错误 + if (error.name === 'NavigationDuplicated') { + return true + } + console.error('Navigation failed:', error) + return false + } + } + + /** + * 安全的路由替换 + */ + async safeReplace(to: string | RouteLocationNormalized): Promise { + try { + await this.router.replace(to) + return true + } + catch (error: any) { + if (error.name === 'NavigationDuplicated') { + return true + } + console.error('Navigation replace failed:', error) + return false + } + } + + /** + * 检查是否正在导航 + */ + isNavigatingTo(path: string): boolean { + return this.isNavigating && this.pendingNavigation === path + } + + /** + * 清理资源 + */ + destroy() { + this.resetNavigationState() + } +} + +/** + * 创建导航防护实例 + */ +export function createNavigationGuard(router: Router): NavigationGuard { + return new NavigationGuard(router) +} diff --git a/src/utils/router-safety.ts b/src/utils/router-safety.ts new file mode 100644 index 0000000..0c8be74 --- /dev/null +++ b/src/utils/router-safety.ts @@ -0,0 +1,100 @@ +import type { Router } from 'vue-router' + +/** + * 路由安全包装器,用于处理路由操作中的错误 + */ +export class RouterSafetyWrapper { + private router: Router + + constructor(router: Router) { + this.router = router + } + + /** + * 安全的路由跳转 + */ + async safePush(to: string | object): Promise { + try { + await this.router.push(to) + return true + } + catch (error) { + console.error('路由跳转失败:', error) + return false + } + } + + /** + * 安全的路由替换 + */ + async safeReplace(to: string | object): Promise { + try { + await this.router.replace(to) + return true + } + catch (error) { + console.error('路由替换失败:', error) + return false + } + } + + /** + * 安全的路由回退 + */ + async safeBack(): Promise { + try { + this.router.back() + return true + } + catch (error) { + console.error('路由回退失败:', error) + return false + } + } + + /** + * 安全的路由前进 + */ + async safeForward(): Promise { + try { + this.router.forward() + return true + } + catch (error) { + console.error('路由前进失败:', error) + return false + } + } +} + +/** + * 创建路由安全包装器实例 + */ +export function createRouterSafety(router: Router): RouterSafetyWrapper { + return new RouterSafetyWrapper(router) +} + +/** + * 全局错误处理函数 + */ +export function handleRouterError(error: any, operation: string = '路由操作') { + console.error(`${operation}发生错误:`, error) + + // 如果是导航被阻止的错误,不需要特殊处理 + if (error.name === 'NavigationDuplicated' || error.message?.includes('redundant navigation')) { + return + } + + // 其他路由错误的处理 + if (error.name === 'NavigationAborted') { + console.warn('导航被中止') + return + } + + // 未知错误,记录详细信息 + console.error('未知路由错误:', { + name: error.name, + message: error.message, + stack: error.stack, + }) +}