Vue3 + TS 动态路由系统实现总结
🎯 系统概述
完整的动态路由系统,支持基于用户角色的权限控制和菜单动态渲染。系统包含用户认证、动态路由管理、菜单生成、404处理等核心功能。
总体实现流程:
1.登录后将用户信息以及token信息保存在Pina状态管理仓库,并同步到LocalStorage
2.实现动态路由以及渲染菜单,流程如下:
a.在用户登录后,从服务器获取路由配置。
b.将动态路由添加到路由实例。
c.根据动态路由生成菜单,并处理跳转。
🏗️ 架构设计
核心模块
- 路由管理 (
src/router/index.ts) - 路由配置和动态路由管理 - 状态管理 (
src/stores/counter.ts) - 用户状态和路由数据存储 - 组件转换 (
src/utils/dynamicRoutes.ts) - 动态组件加载和路由转换 - API接口 (
src/api/api.ts) - 登录和路由数据获取 - 页面组件 - 登录页、主页、404页面等
🔄 完整实现流程
1. 用户登录流程
用户输入登录信息 表单验证 调用登录API 获取Token和用户信息 保存到Pinia和localStorage 获取动态路由配置 添加动态路由到路由实例 跳转到主页
具体实现 (src/views/loginPage/LoginIndex.vue)
typescript
// 登录成功后的处理流程
const submitForm = async (formEl: FormInstance | undefined) => {
// 1. 表单验证
// 2. 调用登录API
login(loginForm.username, loginForm.password).then(async (res: LoginResponse) => {
if (res.token) {
// 3. 保存用户信息到Pinia
userStore.setUser(res.token, res.user)
// 4. 获取动态路由
const routesData: RoutesResponse = await routes(res.token)
// 5. 保存路由到Pinia
userStore.setRoutes(routesData.routes)
// 6. 添加动态路由到路由实例
addDynamicRoutes(routesData.routes)
// 7. 跳转到主页
router.push('/home')
}
})
}
2. 动态路由管理
基础路由配置 (src/router/index.ts)
typescript
const baseRoutes: RouteRecordRaw[] = [
{
path: '/',
name: 'login',
component: login,
meta: { requiresAuth: false }
},
{
path: '/home',
name: 'home',
component: home,
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*', // 404路由 - 关键配置
name: 'NotFound',
component: () => import('@/views/errorPage/NotFound.vue'),
meta: { requiresAuth: false }
}
]
动态路由添加机制
typescript
export function addDynamicRoutes(dynamicRoutes: RouteItem[]) {
const transformedRoutes = transformRoutes(dynamicRoutes)
// 1. 临时移除404路由
if (router.hasRoute('NotFound')) {
router.removeRoute('NotFound')
}
// 2. 将动态路由添加到home路由下(嵌套路由)
transformedRoutes.forEach((route) => {
router.addRoute('home', route)
})
// 3. 重新添加404路由到最后,确保优先级正确
router.addRoute({
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/errorPage/NotFound.vue'),
meta: { requiresAuth: false }
})
}
3. 路由转换与组件加载
动态组件加载 (src/utils/dynamicRoutes.ts)
typescript
// 获取所有可用组件
const modules = import.meta.glob('../views/**/*.vue')
// 路由转换
export function transformRoutes(dynamicRoutes: RouteItem[]): RouteRecordRaw[] {
return dynamicRoutes.map((route): RouteRecordRaw => {
const baseRoute: RouteRecordRaw = {
path: route.path,
name: route.name,
component: loadComponent(route.component),
meta: {
title: route.meta.title,
icon: route.meta.icon,
requiresAuth: route.meta.requiresAuth
}
}
// 处理子路由
if (route.children && route.children.length > 0) {
const children = route.children.map((child: RouteItem): RouteRecordRaw => {
// 子路由路径处理:去掉父路由前缀
let childPath = child.path
if (childPath.startsWith(route.path)) {
childPath = childPath.replace(route.path, '')
if (!childPath.startsWith('/')) {
childPath = '/' + childPath
}
}
return {
path: childPath,
name: child.name,
component: loadComponent(child.component),
meta: {
title: child.meta.title,
icon: child.meta.icon,
requiresAuth: child.meta.requiresAuth
}
}
})
return { ...baseRoute, children }
}
return baseRoute
})
}
// 组件动态加载
function loadComponent(componentName: string) {
// 组件路径映射:'Dashboard' -> '../views/Dashboard.vue'
const componentPath = `../views/${componentName}.vue`
if (modules[componentPath]) {
return modules[componentPath]
}
// 备用路径尝试
const fallbackPaths = [
`../views/${componentName}/${componentName.split('/')[1] || componentName}.vue`,
`../views/${componentName.toLowerCase()}.vue`
]
for (const path of fallbackPaths) {
if (modules[path]) {
return modules[path]
}
}
// 找不到组件时返回404页面
return () => import('@/views/errorPage/NotFound.vue')
}
4. 菜单渲染系统
菜单组件 (src/views/homePage/HomeIndex.vue)
html
<template>
<el-menu :default-active="$route.path" class="el-menu-vertical-demo" router>
<!-- 有子路由的菜单项 -->
<template v-for="item in menuList" :key="item.id">
<el-sub-menu v-if="item.children && item.children.length > 0" :index="item.path">
<template #title>
<el-icon><component :is="item.meta.icon" /></el-icon>
<span>{{ item.meta.title }}</span>
</template>
<el-menu-item v-for="children in item.children" :key="children.id" :index="children.path">
<el-icon><component :is="children.meta.icon" /></el-icon>
<span>{{ children.meta.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 没有子路由的菜单项 -->
<el-menu-item v-else :index="item.path">
<el-icon><component :is="item.meta.icon" /></el-icon>
<span>{{ item.meta.title }}</span>
</el-menu-item>
</template>
</el-menu>
</template>
<script setup lang="ts">
const userStore = useUserStore()
const menuList = computed(() => userStore.routes)
</script>
5. 404页面处理机制
404页面设计
html
<!-- src/views/errorPage/NotFound.vue -->
<template>
<div class="not-found">
<h1>404 - 页面未找到</h1>
<p>抱歉,您访问的页面不存在</p>
<el-button type="primary" @click="$router.push('/home')">返回首页</el-button>
</div>
</template>
404处理策略
- 基础路由中的404:确保应用启动时就有404保护
- 动态路由中的404重排序:每次添加动态路由时重新排序404路由
- 组件加载失败的404回退:当动态组件加载失败时回退到404页面
typescript
// 路由守卫处理
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/') // 未认证跳转登录
} else if (to.path === '/' && token) {
next('/home') // 已认证访问登录页跳转首页
} else {
next() // 正常放行
}
})
6. 状态管理持久化
Pinia Store设计 (src/stores/counter.ts)
typescript
export const useUserStore = defineStore('user', () => {
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const routes = ref<RouteItem[]>([])
const isAuthenticated = computed(() => !!token.value)
// 设置用户信息(持久化到localStorage)
const setUser = (userToken: string, userData: UserInfo) => {
token.value = userToken
userInfo.value = userData
localStorage.setItem('token', userToken)
localStorage.setItem('userInfo', JSON.stringify(userData))
}
// 设置动态路由(持久化到localStorage)
const setRoutes = (dynamicRoutes: RouteItem[]) => {
routes.value = dynamicRoutes
localStorage.setItem('routes', JSON.stringify(dynamicRoutes))
}
// 初始化用户状态(从localStorage恢复)
const initUser = () => {
const savedToken = localStorage.getItem('token')
const savedUserInfo = localStorage.getItem('userInfo')
const savedRoutes = localStorage.getItem('routes')
if (savedToken && savedUserInfo) {
token.value = savedToken
userInfo.value = JSON.parse(savedUserInfo)
}
if (savedRoutes) {
routes.value = JSON.parse(savedRoutes)
}
}
return {
token,
userInfo,
routes,
isAuthenticated,
setUser,
setRoutes,
clearUser,
initUser
}
})
7. 应用启动路由恢复
启动时路由初始化 (src/main.ts)
typescript
// 应用启动时,如果用户已登录且有路由数据,立即加载动态路由
if (userStore.isAuthenticated && userStore.routes.length > 0) {
console.log('应用启动:用户已登录,加载动态路由')
addDynamicRoutes(userStore.routes)
}
主页路由检查 (src/views/homePage/HomeIndex.vue)
typescript
onMounted(async () => {
// 检查登录状态
if (!userStore.isAuthenticated) {
router.push('/')
return
}
// 如果路由为空,重新获取
if (menuList.value.length === 0) {
try {
clearDynamicRoutes()
const routesData: RoutesResponse = await routes(userStore.token)
userStore.setRoutes(routesData.routes)
addDynamicRoutes(routesData.routes)
} catch (error) {
console.error('获取路由失败:', error)
}
}
})
🔧 关键技术点
1. 路由优先级管理
- 404路由必须放在最后,避免误拦截正常路由
- 动态路由添加时需要重新排序404路由
2. 组件懒加载
typescript
// 使用Vite的glob API实现组件懒加载
const modules = import.meta.glob('../views/**/*.vue')
3. 嵌套路由结构
typescript
// 动态路由作为home路由的子路由
router.addRoute('home', dynamicRoute)
4. 类型安全
typescript
// 完整的TypeScript类型定义
export interface RouteItem {
id: number
index: number
path: string
name: string
component: string
meta: {
title: string
icon: string
requiresAuth: boolean
}
children?: RouteItem[]
}
🎨 系统特性
✅ 优势
- 完整的404保护:任何情况下访问不存在的路径都有404页面
- 状态持久化:刷新页面后用户状态和路由配置不丢失
- 动态权限控制:根据服务器返回的路由配置控制菜单显示
- 组件懒加载:按需加载页面组件,优化性能
- 类型安全:完整的TypeScript类型支持
- 路由重用:支持路由的动态添加和清理
🔧 处理边界情况
- 页面刷新:自动恢复路由状态
- 组件加载失败:回退到404页面
- 路由冲突:清理旧路由后添加新路由
- 未授权访问:路由守卫拦截跳转登录
🚀 使用流程总结
是 否 是 否 应用启动 用户已登录? 恢复动态路由 显示登录页 进入主页 用户登录 获取路由配置 添加动态路由 渲染菜单 用户导航 路由存在? 显示对应页面 显示404页面
📝 注意事项
- 404路由位置:确保404路由始终在路由表的最后位置
- 组件路径映射:动态组件路径必须与实际文件结构匹配
- 路由清理:重新登录时需要清理旧的动态路由
- 状态同步:Pinia状态与localStorage保持同步
- 权限控制:服务器返回的路由配置已经过权限过滤
这个动态路由系统提供了完整的用户认证、权限控制和菜单管理功能,具有良好的扩展性和维护性。