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:
Leo 2025-07-06 02:33:37 +08:00
parent 792a787425
commit 5e13342f7b
6 changed files with 466 additions and 64 deletions

View File

@ -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?.()
})
}

View File

@ -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) {
// 添加路由守卫

View File

@ -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: '找不到页面',

View 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
}

View 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
View 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,
})
}