Vue3+TS动态路由终极方案|后端权限、刷新不丢、按钮权限、解决所有404BUG

一、为什么需要动态路由?

在后台管理系统开发中,动态路由是权限体系的核心刚需。

如果前端写死路由,会存在致命问题:

  • 不同角色权限不同,无法做到菜单按需展示
  • 新增菜单、改权限需要改代码、重新打包部署
  • 权限粒度不可控,极易出现越权访问页面的安全漏洞

企业级标准方案:前端只写静态基础路由 + 后端返回权限路由 + 前端递归解析动态挂载

二、整体实现思路(核心原理)

标准生产流程,99%公司都是这套方案:

  1. 用户登录,获取 Token
  2. 携带 Token 请求后端 权限菜单路由接口
  3. 前端拿到后端路由数组,递归筛选、格式化路由
  4. 调用 addRoute 动态挂载路由
  5. 存储路由到 Pinia,解决 页面刷新路由丢失 问题
  6. 路由守卫拦截,无权限跳转 404

三、环境与前置准备

技术栈:Vue3 + Vite + TypeScript + Vue-Router4 + Pinia

安装路由:

css 复制代码
npm install vue-router@4

四、路由类型 TS 定义(规范路由结构)

新建 types/router.d.ts,严格约束后端返回路由格式,杜绝类型混乱。

typescript 复制代码
/** 后端返回原始路由结构 */
export interface BackendRoute {
  id: number
  parentId: number
  path: string
  name: string
  component: string
  redirect?: string
  meta: {
    title: string
    icon?: string
    hidden?: boolean
  }
  children?: BackendRoute[]
}

/** 系统标准路由结构 */
export interface CustomRoute {
  path: string
  name?: string
  component: any
  redirect?: string
  meta: {
    title: string
    icon?: string
    hidden?: boolean
  }
  children?: CustomRoute[]
}

五、初始化静态路由(固定基础路由)

新建 router/index.ts,配置所有人都能访问的静态路由(登录、404、首页)。

typescript 复制代码
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

// 静态路由(无需权限、所有用户可访问)
export const staticRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录' }
  },
  {
    path: '/',
    name: 'Layout',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { title: '首页', icon: 'HomeFilled' }
      }
    ]
  }
]

// 单独抽取404路由,禁止放入静态路由,需动态后置挂载
export const NotFoundRoute: RouteRecordRaw = {
  path: '/:pathMatch(.*)*',
  name: 'NotFound',
  component: () => import('@/views/error/404.vue'),
  meta: { title: '404' }
}

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: staticRoutes
})

// 重置路由方法(解决退出登录路由残留问题)
export const resetRouter = () => {
  router.getRoutes().forEach(route => {
    const { name } = route
    if (name && !staticRoutes.find(item => item.name === name)) {
      router.removeRoute(name)
    }
  })
}

export default router

六、核心:后端路由解析工具函数

后端返回的 component 是字符串路径,Vue-Router 无法直接识别,需要动态批量导入组件并递归格式化。

新建 utils/route.ts

typescript 复制代码
import type { BackendRoute, CustomRoute } from '@/types/router'

/**
 * 动态导入页面组件
 * @param componentStr 组件路径字符串
 */
export const loadComponent = (componentStr: string) => {
  return () => import(`@/views/${componentStr}.vue`)
}

/**
 * 递归格式化后端路由为前端可用路由
 * @param routes 后端原始路由数组
 */
export const formatBackendRoutes = (routes: BackendRoute[]): CustomRoute[] => {
  const res: CustomRoute[] = []
  routes.forEach(item => {
    const route: CustomRoute = {
      path: item.path,
      name: item.name,
      redirect: item.redirect,
      meta: item.meta,
      component: loadComponent(item.component)
    }
    // 递归处理子路由
    if (item.children && item.children.length > 0) {
      route.children = formatBackendRoutes(item.children)
    }
    res.push(route)
  })
  return res
}

七、Pinia 存储动态路由(解决刷新丢失)

默认 addRoute 添加的路由页面刷新会丢失,必须用 Pinia 持久存储路由状态。

新建 stores/route.ts

typescript 复制代码
import { defineStore } from 'pinia'
import type { CustomRoute } from '@/types/router'

export const useRouteStore = defineStore(
  'route',
  {
    state: () => {
      return {
        // 动态权限路由
        dynamicRoutes: [] as CustomRoute[]
      }
    },
    actions: {
      // 保存动态路由
      setDynamicRoutes(routes: CustomRoute[]) {
        this.dynamicRoutes = routes
      }
    },
    persist: true // 开启持久化
  }
)

八、登录后获取路由 + 动态挂载核心逻辑

登录成功后请求权限菜单,格式化路由、批量添加路由、存入 Pinia。

typescript 复制代码
import { useRouteStore } from '@/stores/route'
import { formatBackendRoutes } from '@/utils/route'
import router, { NotFoundRoute } from '@/router'
import type { BackendRoute } from '@/types/router'
// 模拟你的权限接口
import { getMenuApi } from '@/api/user'

export const initDynamicRoute = async () => {
  const routeStore = useRouteStore()

  // 1. 请求后端菜单路由
  const res = await getMenuApi()
  // 假设 res.data 为后端返回的路由数组
  const backendRoutes: BackendRoute[] = res.data

  // 2. 格式化路由
  const formatRoutes = formatBackendRoutes(backendRoutes)

  // 3. 批量动态添加路由
  formatRoutes.forEach(route => {
    router.addRoute('Layout', route)
  })

  // 4. 【核心修复】动态路由挂载完毕后,最后挂载404路由,杜绝刷新404
  router.addRoute(NotFoundRoute)

  // 5. 存储到pinia持久化
  routeStore.setDynamicRoutes(formatRoutes)
}

九、路由守卫恢复动态路由(刷新不丢失终极方案)

页面刷新后 Vue 实例重新加载,addRoute 挂载记录清空,需要在守卫中读取 Pinia 路由重新挂载。

router/index.ts 末尾添加守卫:

javascript 复制代码
import { useRouteStore } from '@/stores/route'
import router, { NotFoundRoute } from '@/router'

// 全局路由守卫
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  const routeStore = useRouteStore()

  // 未登录跳转登录页
  if (!token) {
    if (to.path === '/login') {
      next()
    } else {
      next('/login')
    }
    return
  }

  // 已登录访问登录页,跳转首页
  if (token && to.path === '/login') {
    next('/')
    return
  }

  // 刷新页面重新挂载动态路由 + 后置404
  if (token && routeStore.dynamicRoutes.length > 0) {
    // 重新挂载动态权限路由
    routeStore.dynamicRoutes.forEach(route => {
      router.addRoute('Layout', route)
    })
    // 重新挂载404兜底路由
    router.addRoute(NotFoundRoute)
    // 防止无限循环刷新
    next({ ...to, replace: true })
  } else if (token && routeStore.dynamicRoutes.length === 0) {
    // 有token但无路由,初始化动态路由后再放行
    next({ ...to, replace: true })
  } else {
    next()
  }
})

export default router

十、侧边栏菜单动态渲染(读取 Pinia 路由)

菜单不需要请求接口,直接读取 Pinia 中存储的动态路由渲染,不同角色自动展示不同菜单。

xml 复制代码
<script setup lang="ts">
import { useRouteStore } from '@/stores/route'
const routeStore = useRouteStore()

// 动态权限菜单
const menuList = routeStore.dynamicRoutes
</script>

十一、生产高频坑点 & 解决方案(必看)

11.1 刷新页面路由丢失、菜单消失

原因:addRoute 是运行时挂载,刷新重置。

解决方案:Pinia 持久化 + 路由守卫重新挂载路由。

11.2 动态路由 404 报错

原因:404 路由写在静态最前面,动态路由还没挂载就匹配 404。

终极解决方案 :抽取独立404路由,不写入静态路由,在所有动态路由挂载完成后再后置挂载404,同时刷新页面时重新挂载404,彻底解决动态路由未渲染完成就匹配404的BUG。

11.3 Vite 动态导入路径报错

Vite 不支持完全变量导入,必须固定前缀。

正确写法:import(`@/views/${str}.vue`)

11.4 退出登录路由残留、权限错乱

解决方案:退出登录清空 Pinia 路由、刷新页面或重置路由实例。

javascript 复制代码
import router, { resetRouter } from '@/router'
import { useRouteStore } from '@/stores/route'

// 退出登录清空路由、清除残留权限
const logout = () => {
  const routeStore = useRouteStore()
  // 1. 清空pinia路由缓存
  routeStore.setDynamicRoutes([])
  // 2. 重置路由实例,清除所有动态路由残留
  resetRouter()
  // 3. 清除token
  localStorage.removeItem('token')
  // 4. 跳转登录页
  router.push('/login')
}

十二、按钮级权限控制(完整企业级方案)

动态路由仅控制页面级访问权限 ,实际项目中还需要精细化的按钮级权限(新增、编辑、删除、导出等按钮显隐控制)。下面封装一套TS标准、可全局复用的按钮权限方案,适配所有业务场景。

12.1 扩展TS权限类型

types/router.d.ts 中新增按钮权限类型,约束后端返回权限标识格式。

typescript 复制代码
// 单个按钮权限标识
export interface PermissionBtn {
  permissionKey: string // 权限唯一标识:system:user:add
}

// 扩展后端路由类型,支持携带按钮权限
export interface BackendRoute {
  id: number
  parentId: number
  path: string
  name: string
  component: string
  redirect?: string
  meta: {
    title: string
    icon?: string
    hidden?: boolean
  }
  children?: BackendRoute[]
  // 新增:当前页面按钮权限集合
  btnPermissionList?: PermissionBtn[]
}

// 全局权限列表类型
export interface PermissionState {
  permissionKeys: string[]
}

12.2 Pinia全局存储所有权限标识

修改 stores/route.ts,新增全局权限标识存储,统一管理所有页面按钮权限,持久化防止刷新失效。

typescript 复制代码
import { defineStore } from 'pinia'
import type { CustomRoute } from '@/types/router'

export const useRouteStore = defineStore(
  'route',
  {
    state: () => {
      return {
        // 动态权限路由
        dynamicRoutes: [] as CustomRoute[],
        // 全局所有按钮权限标识
        permissionKeys: [] as string[]
      }
    },
    actions: {
      // 保存动态路由
      setDynamicRoutes(routes: CustomRoute[]) {
        this.dynamicRoutes = routes
      },
      // 批量设置全局权限标识
      setPermissionKeys(keys: string[]) {
        this.permissionKeys = keys
      },
      // 清空所有权限
      clearPermission() {
        this.dynamicRoutes = []
        this.permissionKeys = []
      }
    },
    persist: true // 开启持久化
  }
)

12.3 递归提取全局权限标识

utils/route.ts 新增工具函数,递归遍历所有路由,提取页面中所有按钮权限标识。

typescript 复制代码
import type { BackendRoute, CustomRoute } from '@/types/router'

/**
 * 动态导入页面组件
 * @param componentStr 组件路径字符串
 */
export const loadComponent = (componentStr: string) => {
  return () => import(`@/views/${componentStr}.vue`)
}

/**
 * 递归格式化后端路由为前端可用路由
 * @param routes 后端原始路由数组
 */
export const formatBackendRoutes = (routes: BackendRoute[]): CustomRoute[] => {
  const res: CustomRoute[] = []
  routes.forEach(item => {
    const route: CustomRoute = {
      path: item.path,
      name: item.name,
      redirect: item.redirect,
      meta: item.meta,
      component: loadComponent(item.component)
    }
    // 递归处理子路由
    if (item.children && item.children.length > 0) {
      route.children = formatBackendRoutes(item.children)
    }
    res.push(route)
  })
  return res
}

/**
 * 递归提取所有按钮权限标识
 * @param routes 格式化后的路由数组
 * @returns 权限标识数组
 */
export const getAllPermissionKeys = (routes: BackendRoute[]): string[] => {
  let keys: string[] = []
  routes.forEach(item => {
    // 收集当前页面按钮权限
    if (item.btnPermissionList && item.btnPermissionList.length > 0) {
      const currentKeys = item.btnPermissionList.map(btn => btn.permissionKey)
      keys = [...keys, ...currentKeys]
    }
    // 递归遍历子路由权限
    if (item.children && item.children.length > 0) {
      const childKeys = getAllPermissionKeys(item.children)
      keys = [...keys, ...childKeys]
    }
  })
  // 去重返回
  return [...new Set(keys)]
}

12.4 挂载路由时同步加载权限

修改动态路由初始化方法,解析路由的同时提取权限标识,存入Pinia全局状态。

typescript 复制代码
import { useRouteStore } from '@/stores/route'
import { formatBackendRoutes, getAllPermissionKeys } from '@/utils/route'
import router, { NotFoundRoute } from '@/router'
import type { BackendRoute } from '@/types/router'
// 模拟你的权限接口
import { getMenuApi } from '@/api/user'

export const initDynamicRoute = async () => {
  const routeStore = useRouteStore()

  // 1. 请求后端菜单路由
  const res = await getMenuApi()
  const backendRoutes: BackendRoute[] = res.data

  // 2. 格式化路由 + 提取所有权限标识
  const formatRoutes = formatBackendRoutes(backendRoutes)
  const permissionKeys = getAllPermissionKeys(backendRoutes)

  // 3. 批量动态添加路由
  formatRoutes.forEach(route => {
    router.addRoute('Layout', route)
  })

  // 4. 后置挂载404路由
  router.addRoute(NotFoundRoute)

  // 5. 存储路由和权限到pinia
  routeStore.setDynamicRoutes(formatRoutes)
  routeStore.setPermissionKeys(permissionKeys)
}

12.5 封装全局权限自定义指令(核心)

新建 directive/permission.ts 全局权限指令,实现无权限自动移除DOM元素。

typescript 复制代码
import type { Directive } from 'vue'
import { useRouteStore } from '@/stores/route'

// 权限指令 v-permission="['system:user:add']"
export const permission: Directive = {
  mounted(el, binding) {
    const routeStore = useRouteStore()
    // 获取传入的权限标识
    const checkKeys: string[] = binding.value
    // 无传入权限标识直接放行
    if (!checkKeys || !checkKeys.length) return
    // 判断是否包含对应权限
    const hasPermission = checkKeys.some(key => routeStore.permissionKeys.includes(key))
    // 无权限则移除按钮
    if (!hasPermission) {
      el.parentNode?.removeChild(el)
    }
  }
}

main.ts 全局注册指令

javascript 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import { permission } from '@/directive/permission'

const app = createApp(App)
// 全局注册权限指令
app.directive('permission', permission)

app.use(pinia).use(router).mount('#app')

12.6 页面业务使用案例

页面中直接使用 v-permission 指令控制按钮显隐,精准匹配后端权限,无需手动判断。

xml 复制代码
<template>
  <div class="user-page">
    <!-- 有 system:user:add 权限才显示新增按钮 -->
    <button v-permission="['system:user:add']">新增用户</button>

    <!-- 有 system:user:edit 权限才显示编辑按钮 -->
    <button v-permission="['system:user:edit']">编辑用户</button>

    <!-- 有 system:user:delete 权限才显示删除按钮 -->
    <button v-permission="['system:user:delete']">删除用户</button>
  </div>
</template>

12.7 退出登录清空权限

优化退出登录逻辑,清空权限状态,防止切换账号权限残留。

javascript 复制代码
import router, { resetRouter } from '@/router'
import { useRouteStore } from '@/stores/route'

// 退出登录清空路由、清除所有权限
const logout = () => {
  const routeStore = useRouteStore()
  // 1. 清空pinia路由和权限缓存
  routeStore.clearPermission()
  // 2. 重置路由实例,清除所有动态路由残留
  resetRouter()
  // 3. 清除token
  localStorage.removeItem('token')
  // 4. 跳转登录页
  router.push('/login')
}

十三、完整权限体系总结(面试满分答案)

Vue3企业级完整权限体系分为两级权限控制

  1. 页面级权限(动态路由) :后端返回角色可访问菜单路由,前端递归格式化、动态挂载、Pinia持久化,控制页面是否可访问、侧边栏菜单是否展示,解决越权访问问题。
  2. 按钮级权限(自定义指令) :后端返回页面操作权限标识,前端全局收集权限,通过自定义 v-permission 指令精准控制按钮显隐,实现精细化权限管控。

整套方案解决了:路由刷新丢失、404错位、路由残留、越权访问、按钮权限混乱等所有生产BUG,完全适配企业后台管理系统,可直接上线部署。

相关推荐
前端那点事2 小时前
Vue3+TS手写不定高虚拟列表Hooks,彻底解决长列表卡顿,生产直接复用
前端·vue.js
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_61:(构建反馈表单的结构化挑战)
前端·javascript·ui·html·音视频
卷帘依旧2 小时前
Vue2中defineProperty缺陷
前端
长安第一美人3 小时前
工业级实时监控系统开发:PHP+ZMQ+JS 前后端分离架构全解析
前端·嵌入式硬件·架构·交互·rk3588·zmq后端
ricardo19733 小时前
资源加载提速四件套:dns-prefetch / preconnect / preload / prefetch 实战
前端·面试
豹哥学前端3 小时前
JavaScript 异步编程完全指南:从回调地狱到 async/await,一次通关
前端·javascript·面试
kyriewen3 小时前
面试官让我手写Promise,我打开Cursor三秒生成,他愣了两秒说“你过了”
前端·javascript·面试
Bacon3 小时前
RAG 从入门到入土:Agent 时代,你的检索增强生成到底行不行?
前端·人工智能
软件开发技术深度爱好者3 小时前
HTML实现DOCX文档版题库图文考试系统(修订)
前端·javascript·html