vue3动态路由详细效果 vue3框架仓库vue-next-admin: 基于vue3.x 、Typescript、vite、Element plus等适配手机、平板、pc 的后台开源免费模板库vue2.x请切换vue-prev-admin分支 - Gitee.comhttps://gitee.com/lyt-top/vue-next-admin/tree/master/vue3框架安装流程1. 克隆项目 git clone https://gitee.com/lyt-top/vue-next-admin.git2. 进入项目 cd vue-next-admin3. 安装依赖 npm install4. 运行项目 npm run dev什么是动态路由为什么要用很多新手写的 Vue 项目所有页面路由都是写死在代码里的固定/静态路由所有用户登录后看到的菜单、页面权限都一模一样。动态路由核心作用管理员、普通员工登录看到的侧边栏菜单、可访问的页面、按钮权限完全不一样。后端返回什么菜单前端就渲染什么路由和菜单不用改代码、不用打包重启项目。一动态路由的实现原理1. 权限获取登录成功后获取后端返回的用户菜单数据2. 路由过滤 / 数据转换读取返回的扁平菜单递归格式化生成树形路由将“后端接口返回的菜单数据”与“前端本地预设的路由表”进行比对,筛选出当前用户拥有访问权限的路由配置.3. 路由注册将处理完成的路由对象动态挂载到路由实例实现路由按需加载侧边栏根据格式化后的菜单自动渲染。二vue3动态路由完整实现步骤步骤一Vite 跨域代理配置位置vite.config.ts作用给其加上代理配置在本地开发环境中不用全局修改所有接口地址就可请求完整接口地址。前端本地启动项目时所有 /pc 开头的接口请求自动转发到后端地址 https://lhsc.xucm.cn解决跨域报错问题。根据接口内容配置代理内容可配置多个server: { host: 192.168.1.105, //监听的 IP 地址 同一局域网内其他手机、电脑可以通过 http://你电脑IP:端口 访问你的前端项目 port: env.VITE_PORT as unknown as number, open: JSON.parse(env.VITE_OPEN), hmr: true, proxy: { /pc: { //匹配接口 target: https://lhsc.xucm.cn, //接口地址 changeOrigin: true, //修改请求头Origin源 }, }, },步骤二开启动态路由模式位置src/stores/themeConfig.ts核心配置isRequestRoutes: true 134行左右 false前端写死所有路由所有人权限一致静态路由true后端控制动态路由登录后根据后端返回数据动态生成页面和菜单// 是否开启后端控制路由本项目使用登录接口返回的菜单数据动态生成路由与侧边栏 isRequestRoutes: true,步骤三Axios 请求拦截器统一处理接口位置src/utils/request.ts1. 请求拦截发请求之前自动从本地缓存读取 token塞进请求头识别当前登录用户。// 请求拦截器每次发送接口请求之前自动执行 service.interceptors.request.use( (config) { if (Local.get(token)) { // 从本地存储读取token判断用户是否已经登录 // 在请求头headers中挂载Authorization后端依靠该token识别登录身份完成鉴权 config.headers![Authorization] ${Local.get(token)}; } // 返回处理后的请求配置正式发起网络请求 return config; }, (error) { // 请求发起前出现异常将错误向外抛出交由业务页面catch捕获 return Promise.reject(error); } );2. 响应拦截拿到后端返回数据后自动判断接口是否请求成功code 0 为失败/1 为成功识别 401/4001 状态码token过期、被踢下线自动清缓存、跳回登录页统一处理网络超时、断网、404、500 报错并进行提示// 响应拦截器 service.interceptors.response.use( (response) { // 1. 获取接口返回的真实数据实体 const res response.data; // 2. 业务状态码拦截校验 使用 res.code ! undefined 替代 res.code避免 code 为 0 时被错误识别为 false 导致漏拦截。 if (res.code ! undefined res.code ! 0 res.code ! 1) { // 业务成功 code0模板默认、1本后端登录成功其余全部视为接口业务异常 // 3. 处理鉴权失败的特殊状态码401 / 4001 通常代表 token 过期或被顶号 if (res.code 401 || res.code 4001) { Session.clear(); // 清除浏览器全部临时缓存如 token, userInfo, menuList 等 window.location.href /; // 强制重定向至登录页 // 弹出提示框告知用户登录已失效 ElMessageBox.alert(你已被登出请重新登录, 提示, { confirmButtonText: 确定, type: warning }).then(() {}).catch(() {}); } // 4. 将业务错误抛出中断当前的 Promise 链 return Promise.reject(res); // 拦截器本身不是错误对象这里应当抛出接口返回的 res或 new Error以便在具体页面 API 调用处的 catch 中获取真实的报错信息。 } // 5. 状态正常直接将数据返回给页面调用 return res; }, (error) { // HTTP 协议层面的网络错误拦截如 404, 500, 请求超时, 断网等 if (error.message.includes(timeout)) { ElMessage.error(网络超时请稍后重试); } else if (error.message Network Error) { ElMessage.error(网络连接错误请检查您的网络环境); } else { // 增加安全校验 error.response 是否存在避免取 statusText 时出现 undefined 报错 if (error.response error.response.statusText) { ElMessage.error(error.response.statusText); // 提示服务器返回的标准错误文本如 Not Found, Internal Server Error } else { ElMessage.error(接口路径找不到或服务器无响应); } } return Promise.reject(error); // 继续抛出网络错误使得前端页面的 finally 或 catch 能够正确执行关闭 loading 等操作 } );步骤四登录页面核心逻辑修改1. 登录接口封装位置src/api/login/index.ts单独封装登录请求向后端提交账号密码获取登录结果。请求地址/pc/user/login请求方式post传参用户名、密码。export function useLoginApi() { return { signIn: (data: object) { return request({ url: /pc/user/login, //登录接口路径根据接口路径修改 method: post, data, }); }, }; }2. 登录页面表单绑定并修改登录逻辑位置src//views/login/component/account.vue页面绑定了 3 个数据账号、密码默认填充测试账号 admin/123456。v-model实现双向绑定绑定账号密码输入框!-- 账号输入框 -- el-input v-modelstate.ruleForm.userName/el-input !-- 密码输入框 -- el-input v-modelstate.ruleForm.password/el-input点击登录开启加载状态请求期间锁死按钮、防止重复提交、告诉用户正在加载。state.loading.signIn true;点击登录取出绑定的值传给登录接口并调用后端登录接口提交账号密码前端把页面输入的用户名、密码传给后端等待后端返回结果。const res await loginApi.signIn({ username: state.ruleForm.userName, //账号 password: state.ruleForm.password, //密码 });判断登录是否成功登陆成功本地持久化存储登录核心数据后端返回code1/200登录成功提示并存储接口返回的数据其他 code登录失败弹出错误提示并终止流程if (res.code 1 || res.code 200) { // 统一存储登录接口返回的完整数据初始化路由看看这个人有没有菜单权限 Local.set(userInfo, res.data);登录成功页面跳转 友好提示并开启页面加载动画结束 loading 状态首次进入首页会加载大量组件开启加载遮罩防止白屏卡顿最后关闭登录按钮加载状态无论成功失败都重置状态。const onSignIn async () { state.loading.signIn true; // 开启登录按钮的 loading 加载状态防止用户重复点击 try { const res await loginApi.signIn({ // 1、调用登录接口传递表单绑定的账号和密码 username: state.ruleForm.userName, password: state.ruleForm.password, }); // 2、【判断登录状态】如果 code 是 1 或 200说明请求成功 if (res.code 1 || res.code 200) { Local.set(userInfo, res.data); // 统一存储登录接口返回的完整数据初始化路由看看这个人有没有菜单权限 let isNoPower: boolean | undefined false; if (!themeConfig.value.isRequestRoutes) { isNoPower await initFrontEndControlRoutes(); } else { isNoPower await initBackEndControlRoutes(); } // 成功后的提示与跳转逻辑 if (isNoPower) { ElMessage.warning(抱歉您没有登录权限); //// 就算账号密码对了但是没有分配任何页面权限依然不让进 } else { ElMessage({ // 账号密码正确且有权限弹出成功提示 type: success, message: res.msg || 登录成功, // 提示接口返回的 msg 内容 duration: 1500, // 提示框停留 1.5 秒 onClose: () { if (route.query?.redirect) { // 【等待提示消失后才执行这里的跳转代码】 router.push({ path: stringroute.query?.redirect, query: Object.keys(stringroute.query?.params).length 0 ? JSON.parse(stringroute.query?.params) : , }); } else { router.push(/);// 正常登录跳转到首页 } NextLoading.start(); // 开启全屏加载动画防止跳转瞬间白屏卡顿 } }); } } else { // 3、【登录失败】如果 code 不是 1 也不是 200进行错误提示 ElMessage.error(res.msg || 账号或密码错误登录失败); } } catch (error) { // 【异常兜底】比如断网了或者服务器出bug了 console.error(登录请求异常:, error); } finally { state.loading.signIn false; // 无论上面是成功还是失败最后都要把按钮的 loading 状态关掉 } };步骤五动态路由效果修改1. 定义本地路由表位置src/router/route.tsimport { RouteRecordRaw } from vue-router; // 扩展 RouteMeta 接口 declare module vue-router { interface RouteMeta { title?: string; isLink?: string; isHide?: boolean; isKeepAlive?: boolean; isAffix?: boolean; isIframe?: boolean; roles?: string[]; icon?: string; } } /** * 定义动态路由 */ export const dynamicRoutes: ArrayRouteRecordRaw [ { path: /, name: /, component: () import(//layout/index.vue), redirect: /home, meta: { isKeepAlive: true, }, children: [ // 首页 { path: /home, name: home, component: () import(//views/home/index.vue), meta: { title: 首页, icon: iconfont icon-barcode-qr, isKeepAlive: true, }, }, ], }, ]; /** * 定义404、401界面 */ export const notFoundAndNoPower [ { path: /:path(.*)*, name: notFound, component: () import(//views/error/404.vue), meta: { title: message.staticRoutes.notFound, isHide: true, }, }, { path: /401, name: noPower, component: () import(//views/error/401.vue), meta: { title: message.staticRoutes.noPower, isHide: true, }, }, ]; /** * 定义静态路由默认路由 */ export const staticRoutes: ArrayRouteRecordRaw [ { path: /login, name: login, component: () import(//views/login/index.vue), meta: { title: 登录, }, }, ];2. 登录后获取菜单数据并注册路由1.读取后端原始菜单数据从Local.userInfo.menu读取登录时缓存的后端菜单数组做容错判断为空默认返回空数组避免代码报错崩溃。const menuList Local.get(userInfo)2.权限判定无菜单拦截判断后端返回菜单数组长度长度为 0 → 账号密码正确但该用户没有任何页面权限返回无权限标识有菜单数据 → 继续解析路由if (res.data.length 0) return Promise.resolve(true);3.后端菜单 → 前端标准路由格式转换核心执行backEndComponent递归转换方法做三件核心大事自动排序根据后端传的 order 序号自动从小到大排序菜单顺序由后端控制组件自动绑定通过 Vite 的import.meta.glob扫描src/views所有页面根据菜单 name 自动匹配xxx/index.vue页面找不到页面自动展示占位页递归多级菜单自动遍历子 children无限层级解析支持一级、二级、三级无限嵌套菜单最终把后端纯文本菜单数据转为 Vue-Router 可以识别的标准路由对象。export async function setAddRoute() { await setFilterRouteEnd().forEach((route: RouteRecordRaw) { router.addRoute(route); }); } export function setFilterRouteEnd() { let filterRouteEnd: any formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes)); // notFoundAndNoPower 防止 404、401 不在 layout 布局中不设置的话404、401 界面将全屏显示 // 关联问题 No match found for location with path xxx filterRouteEnd[0].children [...filterRouteEnd[0].children, ...notFoundAndNoPower]; return filterRouteEnd; }4.批量注册路由到Vue项目通过router.addRoute循环遍历所有解析好的路由动态挂载到全局路由实例。此时路由已经真正生效页面可以正常访问。export function backEndComponent(routes: any) { if (!routes) return []; return [...routes] .sort((a, b) (a.meta?.order ?? 0) - (b.meta?.order ?? 0)) .map((item: any) { const route: any { path: item.path, name: item.name, // 组件按菜单 name 约定解析未找到对应页面时使用占位页 component: resolveViewComponent(item.name), meta: { // 后端字段 locale 为菜单标题 title: item.meta?.locale || item.name, icon: item.meta?.icon || , // 后端字段 hideInMenu 控制是否在菜单中隐藏 isHide: !!item.meta?.hideInMenu, isKeepAlive: true, }, }; if (item.children item.children.length 0) { route.children backEndComponent(item.children); } return route; });5.存入Pinia全局状态渲染页面最后把解析好的路由存入 Pinia嵌套路由数据 → 渲染左侧侧边栏菜单​​​​​​​扁平化一维路由 → 渲染顶部标签页、菜单搜索export async function setFilterMenuAndCacheTagsViewRoutes() { const storesRoutesList useRoutesList(pinia); storesRoutesList.setRoutesList(dynamicRoutes[0].children as any); setCacheTagsViewRoutes(); }修改后的完整src/router/backEnd.ts 内容import { RouteRecordRaw } from vue-router; import pinia from //stores/index; import { useUserInfo } from //stores/userInfo; import { useRequestOldRoutes } from //stores/requestOldRoutes; import { Local } from //utils/storage; import { NextLoading } from //utils/loading; import { dynamicRoutes, notFoundAndNoPower } from //router/route; import { formatTwoStageRoutes, formatFlatteningRoutes, router } from //router/index; import { useRoutesList } from //stores/routesList; import { useTagsViewRoutes } from //stores/tagsViewRoutes; // 后端控制路由 /** - 获取目录下的 .vue、.tsx 全部文件用于动态组件解析 - method import.meta.glob - link 参考[https://cn.vitejs.dev/guide/features.html#json](https://cn.vitejs.dev/guide/features.html#json) */ const viewsModules: any import.meta.glob(../views/**/*.{vue,tsx}); /** - 按菜单 name 约定解析页面组件/src/views/menu.name/index.vue - description 对应页面文件存在则使用真实页面否则回退到占位页面页面建设中 - param name 后端菜单的 name 字段 - returns 返回组件函数 */ function resolveViewComponent(name: string) { const key ../views/${name}/index.vue; if (viewsModules[key]) return viewsModules[key]; return () import(//views/placeholder/index.vue); } /** - 后端控制路由初始化方法防止刷新时路由丢失 - method NextLoading 界面 loading 动画开始执行 - method useUserInfo().setUserInfos() 触发初始化用户信息 pinia - method useRequestOldRoutes().setRequestOldRoutes() 存储接口原始路由未处理component根据需求选择使用 - method setAddRoute 添加动态路由 - method setFilterMenuAndCacheTagsViewRoutes 设置路由到 pinia routesList 中已处理成多级嵌套路由及缓存多级嵌套数组处理后的一维数组 */ export async function initBackEndControlRoutes() { // 界面 loading 动画开始执行 if (window.nextLoading undefined) NextLoading.start(); // 无 token 停止执行下一步 if (!Local.get(userInfo)?.token) return false; // 触发初始化用户信息 pinia // [https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP](https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP) await useUserInfo().setUserInfos(); // 获取路由菜单数据登录接口返回已由 account.vue 存入 localStorage const res getBackEndControlRoutes(); // 无登录权限时添加判断 // [https://gitee.com/lyt-top/vue-next-admin/issues/I64HVO](https://gitee.com/lyt-top/vue-next-admin/issues/I64HVO) if (res.data.length 0) return Promise.resolve(true); // 存储接口原始路由未处理component根据需求选择使用 useRequestOldRoutes().setRequestOldRoutes(JSON.parse(JSON.stringify(res.data))); // 处理路由component替换 dynamicRoutes//router/route第一个顶级 children 的路由 dynamicRoutes[0].children await backEndComponent(res.data); // 登录后默认首页将 / 重定向到后端菜单第一个可见菜单如 /index const firstMenu (dynamicRoutes[0].children || []).find((v: any) v.path !v.meta?.isHide); if (firstMenu) dynamicRoutes[0].redirect firstMenu.path; // 添加动态路由 await setAddRoute(); // 设置路由到 pinia routesList 中已处理成多级嵌套路由及缓存多级嵌套数组处理后的一维数组 setFilterMenuAndCacheTagsViewRoutes(); } /** - 设置路由到 pinia routesList 中已处理成多级嵌套路由及缓存多级嵌套数组处理后的一维数组 - description 用于左侧菜单、横向菜单的显示 - description 用于 tagsView、菜单搜索中未过滤隐藏的(isHide) */ export async function setFilterMenuAndCacheTagsViewRoutes() { const storesRoutesList useRoutesList(pinia); storesRoutesList.setRoutesList(dynamicRoutes[0].children as any); setCacheTagsViewRoutes(); } /** - 缓存多级嵌套数组处理后的一维数组 - description 用于 tagsView、菜单搜索中未过滤隐藏的(isHide) */ export function setCacheTagsViewRoutes() { const storesTagsView useTagsViewRoutes(pinia); storesTagsView.setTagsViewRoutes(formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes))[0].children); } /** - 处理路由格式及添加捕获所有路由或 404 Not found 路由 - description 替换 dynamicRoutes//router/route第一个顶级 children 的路由 - returns 返回替换后的路由数组 */ export function setFilterRouteEnd() { let filterRouteEnd: any formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes)); // notFoundAndNoPower 防止 404、401 不在 layout 布局中不设置的话404、401 界面将全屏显示 // 关联问题 No match found for location with path xxx filterRouteEnd[0].children [...filterRouteEnd[0].children, ...notFoundAndNoPower]; return filterRouteEnd; } /** - 添加动态路由 - method router.addRoute - description 此处循环为 dynamicRoutes//router/route第一个顶级 children 的路由一维数组非多级嵌套 - link 参考[https://next.router.vuejs.org/zh/api/#addroute](https://next.router.vuejs.org/zh/api/#addroute) */ export async function setAddRoute() { await setFilterRouteEnd().forEach((route: RouteRecordRaw) { router.addRoute(route); }); } /** - 获取后端路由菜单数据 - description 数据来源登录接口返回的 menu 字段登录成功时已由 account.vue 存入 localStoragekey: menuList - returns 返回后端路由菜单数据 */ export function getBackEndControlRoutes() { // 菜单数据统一从 Local.userInfo 中获取登录时已整体存储 const menuList Local.get(userInfo)?.menu; return { data: Array.isArray(menuList) ? menuList : [] }; } /** - 重新获取后端路由菜单数据 - description 用于菜单管理界面刷新菜单未进行测试 - description 路径/src/views/system/menu/component/addMenu.vue */ export async function setBackEndControlRefreshRoutes() { await getBackEndControlRoutes(); } /** - 后端路由 component 转换兼容登录接口返回的 menu 结构 - param routes 后端返回的路由表数组字段path/name/meta.locale/meta.icon/meta.order/meta.hideInMenu/children - returns 返回处理成 vue-router 路由记录后的数组按 meta.order 升序排序 */ export function backEndComponent(routes: any) { if (!routes) return []; return [...routes] .sort((a, b) (a.meta?.order ?? 0) - (b.meta?.order ?? 0)) .map((item: any) { const route: any { path: item.path, name: item.name, // 组件按菜单 name 约定解析未找到对应页面时使用占位页 component: resolveViewComponent(item.name), meta: { // 后端字段 locale 为菜单标题 title: item.meta?.locale || item.name, icon: item.meta?.icon || , // 后端字段 hideInMenu 控制是否在菜单中隐藏 isHide: !!item.meta?.hideInMenu, isKeepAlive: true, }, }; if (item.children item.children.length 0) { route.children backEndComponent(item.children); } return route; }); }完整动态路由效果代码关注并私信