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.git

  2. 进入项目 cd vue-next-admin

  3. 安装依赖 npm install

  4. 运行项目 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.ts

1. 请求拦截(发请求之前)

自动从本地缓存读取 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) {   // 业务成功 code:0(模板默认)、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-model="state.ruleForm.userName"></el-input>

 <!-- 密码输入框 -->
 <el-input v-model="state.ruleForm.password"></el-input>

点击登录,开启加载状态请求期间锁死按钮、防止重复提交、告诉用户正在加载。

复制代码
state.loading.signIn = true;

点击登录,取出绑定的值传给登录接口,并调用后端登录接口,提交账号密码

前端把页面输入的用户名、密码传给后端,等待后端返回结果。

复制代码
const res = await loginApi.signIn({
  username: state.ruleForm.userName,   //账号
  password: state.ruleForm.password,   //密码
});

判断登录是否成功,登陆成功本地持久化存储登录核心数据

后端返回 code=1/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: <string>route.query?.redirect,
                                query: Object.keys(<string>route.query?.params).length > 0 ? JSON.parse(<string>route.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.ts

复制代码
import { 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: Array<RouteRecordRaw> = [
    {
        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: Array<RouteRecordRaw> = [
    {
        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 递归转换方法,做三件核心大事:

  1. 自动排序:根据后端传的 order 序号,自动从小到大排序,菜单顺序由后端控制

  2. 组件自动绑定 :通过 Vite 的 import.meta.glob 扫描 src/views 所有页面,根据菜单 name 自动匹配 xxx/index.vue 页面,找不到页面自动展示占位页

  3. 递归多级菜单:自动遍历子 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 存入 localStorage(key: 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;
 });

}

完整动态路由效果代码关注并私信

相关推荐
使用小功能大师2 小时前
从零搭建高可用Web应用:全栈架构实战与成本优化完全指南
前端·阿里云·架构·服务搭建
IT_陈寒2 小时前
SpringBoot这个特性差点让我加班到凌晨
前端·人工智能·后端
qq_267612892 小时前
Gitee DevOps的度量驱动持续改进:从构建数据到交付效率的量化管理与优化路径
前端·gitee·自动化
meilindehuzi_a2 小时前
从 Vite 到 Axios 与 Mock:React Todos 全栈项目架构及请求链路详解
前端·react.js·架构
亿元程序员2 小时前
项目经理让我在 2D 项目里做 3D 音乐球球,我只好做了个“假的”
前端
糖果店的幽灵2 小时前
Codex官网前端可抄吗?从模仿到创新的技术实践指南
前端·人工智能
youqingyike3 小时前
【无标题】
前端·qt
恋猫de小郭3 小时前
Jetpack Compose 8 月版正式发布,核心模块 1.12
android·前端·flutter
纵道软件3 小时前
使用 Docker 构建自定义 SeaTunnel Web 服务镜像
前端·docker·容器