fix(router): 修复Vue Router导航错误和组件生命周期问题
* 新增导航防护机制 - NavigationGuard类防止快速路由切换导致的错误 - 实现防抖和安全导航方法(safePush/safeReplace) * 新增组件安全加载机制 - safeAsyncComponent包装器处理异步组件加载错误 - 支持重试机制和ChunkLoadError恢复 * 增强路由守卫错误处理 - 全面的try-catch错误捕获 - 统一的路由错误处理函数 * 优化路由配置 - 使用安全组件加载器包装所有异步组件 - 改进路由重定向逻辑 解决了"Cannot read properties of null (reading 'isUnmounted')"等Vue Router错误
This commit is contained in:
parent
792a787425
commit
5e13342f7b
@ -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?.()
|
||||
})
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
// 添加路由守卫
|
||||
|
||||
@ -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: '找不到页面',
|
||||
|
||||
109
src/utils/component-guard.ts
Normal file
109
src/utils/component-guard.ts
Normal file
@ -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: '<div class="error-component">组件加载失败</div>',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
124
src/utils/navigation-guard.ts
Normal file
124
src/utils/navigation-guard.ts
Normal file
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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)
|
||||
}
|
||||
100
src/utils/router-safety.ts
Normal file
100
src/utils/router-safety.ts
Normal file
@ -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<boolean> {
|
||||
try {
|
||||
await this.router.push(to)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由跳转失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由替换
|
||||
*/
|
||||
async safeReplace(to: string | object): Promise<boolean> {
|
||||
try {
|
||||
await this.router.replace(to)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由替换失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由回退
|
||||
*/
|
||||
async safeBack(): Promise<boolean> {
|
||||
try {
|
||||
this.router.back()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('路由回退失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的路由前进
|
||||
*/
|
||||
async safeForward(): Promise<boolean> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user