# 一、前言
接触和使用 vue3 快 1 年了, 时至今日, vue3 的生态已经趋于成熟, 已结陆陆续续有不少公司开始使用 vue3 开发项目, 学习和使用 vue3 已是大势所趋. vue3-ts-antd-admin是基于 vue3、ts、tsx、vite 开发的一套中后台管理系统模板, 简洁轻量, 适合中小型中后台项目的开发.
本系列会从零开始介绍该系统的构造过程. 本文是该系列的第二篇, 主要介绍动态路由的设计和实现, 以及该功能涉及的 vue-router、pinia、axios 等核心库的引入封装和使用
Github: 传送门
演示地址:传送门
编辑器预览:
# 二、vue-Router 的基本使用
Vue Router 是 Vue.js 的官方路由
用过 vue2 的开发者们, 都应该对 Vue Router 很熟悉.
# 1. 安装
vue3 使用的 vue-router 是 v4.x 版本
yarn add vue-router@4
# 2. 使用
导入 createRouter,createWebHistory 这两个方法,使用 createWebHistory 方法创建一个 routerHistory 对象,使用 createRouter 创建路由器
// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import common from "./modules/common";
const routes = [...common];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
// src/router/modules/common/index.ts
/**
* @description 公共的一些路由,不属于功能模块的都放这里统一管理
* @author fhtwl */
import UserLayout from "@/layouts/UserLayout.vue";
/**
*
* 基础路由
*/
export const constantRouterMap = [
{
path: "/auth",
component: UserLayout,
redirect: "/auth/login",
hidden: true,
children: [
{
path: "login",
name: "login",
component: () => import("@/views/system/auth/Login/index.vue"),
},
{
path: "register",
name: "register",
component: () => import("@/views/system/auth/Register/index.vue"),
},
{
path: "register-result",
name: "registerResult",
component: () => import("@/views/system/auth/RegisterResult/index.vue"),
},
],
},
{
path: "/404",
component: () => import("@/views/system/exception/404/index.vue"),
},
];
export default [...constantRouterMap];
注册路由并挂载在全局
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
const app = createApp(App);
app.use(router);
app.mount("#app");
# 三、动态路由设计
在中后台系统中, 动态路由功能基本是必须的, 因为多角色多权限的系统设计, 必然会体现在路由上, 路由权限是最外层也是最基本的权限, 里层根据实际情况可能还涉及操作等权限
这一节我们只涉及动态路由的设计与实现.
# 1. 布局设计
常规的中后台管理系统, 布局一般有 3 种:
左右布局
左侧导航菜单 + 右侧内容主体
上下布局
头部导航菜单 + 底部内容主体
上左联动布局
头部父导航菜单 + 左侧子导航菜单 + 底部内容主体
这些布局在路由的角度上看来, 其实都是一样的, 即一级路由渲染导航菜单等公共模块和放置二级路由, 二级路由渲染内容主体, 也就是一个个页面
因此, 最终的路由树大致是这样的
[
// 公共路由, 不需要权限
{
name: "auth",
redirect: "/auth/login", // 重定向到子路由login
children: [
{
name: "login",
path: "/auth/login",
},
{
name: "register",
path: "/auth/register",
},
],
},
// 动态路由
{
name: "dynamicRouter",
redirect: "xxx", // 重定向到所有角色都拥有的子路由上
children: [
// ...
],
},
// 如果无法匹配到, 则跳转到404
{
name: "404",
redirect: "/404",
path: "/:catchAll(.*)",
},
];
# 2. 权限设计
动态路由的权限分两部分, 一是限制当前角色只能看到特定的某些菜单(后续深入到操作级的权限, 再细谈可跳转的按钮和链接), 二是当页面跳转时校验当前角色是否该路由权限(有就正常跳转, 没有就重定向到 404)
# 四、依赖库的封装
在前面的设计中, 实现动态路由需要向后台请求接口, 同时接口返回的数据也需要作为公共变量在多处使用, 因此在开发之前, 还需要引入 axios、pinia 等库并进行简单的封装
# 1. axios
# (1). 安装
yarn add axios
# (2). 配置
// src/utils/http.ts
import axios, { AxiosError, AxiosRequestConfig } from "axios";
import { notification } from "ant-design-vue";
import { useStore } from "@/store/system/user";
import { ACCESS_TOKEN } from "@/store/system/user/const";
import router from "@/router";
import { loginRoutePath } from "@/permission";
// 设置请求头和请求路径
axios.defaults.baseURL = import.meta.env.VITE_APP_API_BASE_URL;
axios.defaults.timeout = 10000;
axios.defaults.headers.post["Content-Type"] = "application/json;charset=UTF-8";
axios.interceptors.request.use(
(config): AxiosRequestConfig<unknown> => {
const userStore = useStore();
const token = userStore.token;
if (token) {
config.headers![ACCESS_TOKEN] = token;
}
return config;
},
(error) => {
return error;
}
);
// 异常拦截处理器
const errorHandler = (error: AxiosError) => {
if (error.response) {
const userStore = useStore();
const data = error.response.data as Common.ResponseData<unknown>;
const token = userStore.token;
if (error.response.status === 403) {
notification.error({
message: "权限不足",
description: data.msg,
});
}
if (error.response.status === 401 && !data.data) {
notification.error({
message: "登录失效",
description: data.msg,
});
const reload = () => {
setTimeout(() => {
// 跳转到登录
router.push(loginRoutePath);
}, 1500);
};
if (token) {
userStore.deleteToken();
}
reload();
}
}
return Promise.reject(error);
};
// 响应拦截
axios.interceptors.response.use((response) => {
if (response.data?.errorCode !== 10000) {
notification.error({
message: "请求失败",
description: response.data.msg,
});
return Promise.reject(response);
}
return response.data.data;
}, errorHandler);
export default axios;
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import axios from "@/utils/http";
const app = createApp(App);
app.use(router);
app.config.globalProperties.$axios = axios;
app.mount("#app");
# 2. pinia
Pinia 是尤雨溪强烈推荐的一款 Vue 状态管理工具,也被认为是下一代 Vuex 的替代产品. Pinia 同样由 Vue 团队开发, Vue 官方更推荐在 Vue3 中使用 Pinia. 相比Vuex, Pinia 没有 mutations, 不需要注入、导入函数, 调用时会自动补全, 无需动态添加 stores, 没有命名空间, 只保留了 state, getter 和 action, 更符合 Vue3 的 Composition api. 整体更为轻量简洁, 以及更好的 typescript 支持
# (1). 安装
yarn add pinia
# (2). 挂载 pinia
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import axios from "@/utils/http";
import { createPinia } from "pinia";
const app = createApp(App);
app.use(router);
app.config.globalProperties.$axios = axios;
app.use(createPinia());
app.mount("#app");
# (3). 创建 store
src/store 目录存放项目中的 pinia store, system目录存放系统公共 store, asyncRouter.ts 存放动态路由相关的数据.
pinia 通过 defineStore 方法创建 store, defineStore 接受 2 个参数, 第一个是 store 的 id(或者说唯一的名称), 第二个是 store 的配置项, 支持 Vue 的 Composition API 和 Option API
- Option API
// src/store/system/asyncRouter.ts
import { defineStore } from "pinia";
export const defineRouterStore = defineStore("asyncRouter", {
state: () => ({
addRouters: [],
}),
getters: {
routersLength: (state) => state.addRouters.length,
},
actions: {
generateRoutes(routers) {
this.addRouters = routers;
},
},
});
- Composition API
// src/store/system/asyncRouter.ts
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export const defineRouterStore = defineStore("asyncRouter", () => {
const addRouters = ref([]);
const routersLength = computed(() => addRouters.value.length);
function generateRoutes(routers) {
addRouters.value = routers;
}
return {
addRouters,
routersLength,
addRouters,
};
});
# (4). 使用 pinia
import { defineRouterStore } from "@/store/system/asyncRouter";
export default {
setup() {
const routerStore = defineRouterStore();
const addRouters = routerStore.addRouters;
routerStore.addRouters([]);
return {
// 如果需要在template中使用, 则必须return除去
routerStore,
};
},
};
# 五、动态路由实现
# 1. 动态获取路由
添加获取动态路由接口
// src/api/system/user/index/ts
import http from "@/utils/http";
const User = `/system/user`;
const api = {
getUserInfo: `${User}/query`,
getUserMenu: `${User}/getUserMenu`,
};
/**
* 获取用户信息
* @returns
*/
export function getUserInfo(): Promise<UserRes.GetUserInfo> {
return http.get(api.getUserInfo);
}
/**
* 获取动态路由
* @returns
*/
export function getUserMenu(): Promise<UserRes.GetUserMenu[]> {
return http.post(api.getUserMenu);
}
# 2. 将后台返回的 json 解析为路由树
# (1). 基础路由
创建基础路由表 constantRouterComponents , 包含 BasicLayout、RouteView 等必须的路由组件
BasicLayout 组件 作为一级路由, 也就是动态路由(不包含登录注册等公共路由)的根节点, 页面包含 菜单栏等公共区 和 内容区
RouteView 组件 用于渲染内容区的页面, 并添加了 keep-alive 、transition 等功能
// src/router/modules/generatorRouters.ts
import { BasicLayout, RouteView } from "@/layouts";
import { markRaw } from "vue";
// 前端路由表
const constantRouterComponents: {
[propsName: string]: unknown;
} = {
// 基础页面 layout 必须引入
// 一级路由包裹组件
BasicLayout: markRaw(BasicLayout),
// 二级路由
RouteView: markRaw(RouteView),
404: () => import("@/views/system/exception/404/index.vue"),
};
# (2). 路由懒加载
通过 import.meta.glob 自动引入 views 目录下的所有组件, 使用 懒加载 的方式 , 将所有页面(约定 isPage === true 的组件为页面) 挂在 constantRouterComponents 下. 此时 constantRouterComponents 包含所有的页面
// src/router/modules/generatorRouters.ts
import { getUserMenu } from "@/api/system/user";
const modules = getModules();
function loadAllPage() {
for (const path in modules) {
modules[path]().then((mod) => {
const file = mod.default;
if (file.isPage) {
constantRouterComponents[`${file.name}`] = () => Promise.resolve(file);
}
});
}
}
// 获取所有的页面并放入路由表中
loadAllPage();
/**
* 加载views目录下的所有组件
* @returns
*/
function getModules() {
const components = import.meta.glob("../../views/**/*.tsx");
return components;
}
# (3). 动态路由树构建
// src/router/modules/generatorRouters.ts
import { getUserMenu } from "@/api/system/user";
import { BasicLayout, RouteView } from "@/layouts";
import { listToTree } from "@/utils/utils";
import { markRaw } from "vue";
export const ROOT_NAME = -1;
// 前端未找到页面路由
const notFoundRouter = {
path: "/:catchAll(.*)", // 不识别的path自动匹配404
redirect: "/404",
hidden: true,
serialNum: 0,
parentId: 0,
name: "404",
id: "404",
component: undefined,
meta: {
title: "404",
icon: undefined,
hiddenHeaderContent: undefined,
permission: undefined,
type: undefined,
actions: [],
},
hideChildrenInMenu: false,
children: [],
};
// 根级菜单
const rootRouter: UserRes.GetUserMenu = {
key: "",
name: "index",
path: "",
component: "BasicLayout",
redirect: "/dashboard",
meta: {
title: "首页",
},
children: [],
serialNum: 0,
parentId: -11,
id: ROOT_NAME,
hideChildrenInMenu: false,
};
/**
* 动态生成菜单
* @returns
*/
export function generatorDynamicRouter(): Promise<Common.Router[]> {
return new Promise((resolve, reject) => {
getUserMenu()
.then((result) => {
const menuNav = [];
const childrenNav: UserRes.GetUserMenu[] = [];
// 后端数据, 根级树数组, 根级 PID
listToTree(result as unknown as Common.List, childrenNav, 0);
rootRouter.children = childrenNav;
menuNav.push(rootRouter);
const routers = generator(menuNav);
routers.push(notFoundRouter);
resolve(routers);
})
.catch((err) => {
reject(err);
});
});
}
/**
* 格式化树形结构数据 生成 vue-router 层级路由表
* @param routerMap
* @param parent
* @returns
*/
export const generator = (
routerMap: UserRes.GetUserMenu[],
parent?: Common.Router
) => {
return routerMap.map((item) => {
const { component, meta, key, permission, type } = item;
const { title, show, hideChildren, hiddenHeaderContent, icon } = meta;
const currentRouter: Common.Router = {
// 如果路由设置了 path,则作为默认 path,否则 路由地址 动态拼接生成如 /dashboard/my-dashboard
path: item.path || `${parent?.path || ""}/${key}`,
// 路由名称,建议唯一
name: item.id.toString(),
// 该路由对应页面的组件
component: constantRouterComponents[
component!
] as unknown as Common.VueComponent,
// meta: 页面标题, 菜单图标, 页面权限(供指令权限用,可去掉)
meta: {
title,
icon,
hiddenHeaderContent,
permission,
type,
actions: (item.children || []).filter(
(action: UserRes.GetUserMenu) => action.type === 3
),
},
// 是否设置了隐藏菜单
hidden: show === false,
// 是否设置了隐藏子菜单
hideChildrenInMenu: !!hideChildren,
children: [],
redirect: "",
};
// 为了防止出现后端返回结果不规范,处理有可能出现拼接出两个 反斜杠
if (!currentRouter.path.startsWith("http")) {
currentRouter.path = currentRouter.path.replace("//", "/");
}
// 重定向
item.redirect && (currentRouter.redirect = item.redirect);
// 是否有子菜单,并递归处理
if (item.children && item.children.length > 0) {
currentRouter.children = generator(item.children, currentRouter);
}
return currentRouter;
});
};
// src/utils/utils.ts
/**
* 数组转树形结构
* @param list 源数组
* @param tree 树
* @param parentId 父ID
*/
export function listToTree(
list: Common.List,
tree: Common.TreeNode[],
parentId: number
) {
list.forEach((item) => {
// 判断是否为父级菜单
if (item.parentId === parentId) {
const child = {
...item,
id: item.id!,
key: item.id || item.name,
children: [],
serialNum: item.serialNum as number,
parentId: item.parentId,
};
// 迭代 list, 找到当前菜单相符合的所有子菜单
listToTree(list, child.children, item.id!);
// 加入到树中
tree.push(child);
}
});
}
# (4). 保存动态路由树
// src/store/system/asyncRouter/index.ts
import { defineStore } from "pinia";
import { generatorDynamicRouter } from "@/router/modules/generatorRouters";
export const defineRouterStore = defineStore("asyncRouter", {
state: () => ({
addRouters: [] as Common.Router[],
}),
actions: {
generateRoutes() {
return new Promise((resolve) => {
generatorDynamicRouter().then((routers) => {
this.addRouters = routers;
resolve(undefined);
});
});
},
},
});
# 3. 将路由树加入到路由
import router from "@/router";
import { ROOT_NAME } from "@/router/modules/generatorRouters";
import { defineRouterStore } from "@/store/system/asyncRouter";
import { RouteRecordRaw } from "vue-router";
export function resetMenuRouter() {
// 动态路由都挂在name为ROOT_NAME的路由下
if (router.hasRoute(ROOT_NAME.toString())) {
// 更新前先清空动态路由
router.removeRoute(ROOT_NAME.toString());
}
}
/**
* 更新系统导航和路由
*/
export async function updateMenuRouter() {
const routerStore = defineRouterStore();
await routerStore.generateRoutes().then(() => {
routerStore.addRouters.forEach((r) => {
router.addRoute({
...r,
} as unknown as RouteRecordRaw);
});
});
}
// 登录后
login().then(() => {
// ...
updateMenuRouter();
// ...
});
# 4. 路由守卫
当登录有效时, 访问登录页直接重定向到默认系统页面
当登录有效时, 却没有角色信息时, 尝试重新获取路由等角色信息, 获取失败则要求重新登录
当登录有效时, 且路由携带重定向时, 则重定向
当未登录时, 重定向到登录
其他情况不拦截
import router from "./router";
import { useStore } from "./store/system/user";
import NProgress from "nprogress";
import "@/components/NProgress/nprogress.less";
import { notification } from "ant-design-vue";
import { resetMenuRouter, updateMenuRouter } from "./utils/router";
NProgress.configure({ showSpinner: false });
const allowList = ["login", "register", "registerResult"];
export const loginRoutePath = "/auth/login";
const defaultRoutePath = "/dashboard/my-dashboard";
router.beforeEach((to, from, next) => {
NProgress.start();
const userStore = useStore();
// 如果已经登录
if (userStore.token) {
// 而跳转路径是登录页面
if (to.path === loginRoutePath) {
// 则直接跳转到默认页
next({ path: defaultRoutePath });
NProgress.done();
} else {
// 如果没有角色信息
if (!userStore.role) {
// 重新获取
userStore
.getInfo()
.then(() => {
// 重置之前的动态路由
resetMenuRouter();
// 更新动态路由
updateMenuRouter().then(() => {
// 请求带有 redirect 重定向时,登录自动重定向到该地址
const redirect = decodeURIComponent(
(from.query?.redirect as string | undefined) || to.path
);
if (to.path === redirect) {
next({ ...to, replace: true });
} else {
// 跳转到目的路由
next({ path: redirect });
}
});
})
.catch(() => {
notification.error({
message: "错误",
description: "请求用户信息失败,请重试",
});
// 失败时,获取用户信息失败时,调用登出,来清空历史保留信息
userStore.logout().then(() => {
next({ path: loginRoutePath, query: { redirect: to.fullPath } });
});
});
} else {
next();
}
}
} else {
if (to.name && allowList.includes(to.name.toString())) {
// 在免登录名单,直接进入
next();
} else {
next({ path: loginRoutePath, query: { redirect: to.fullPath } });
NProgress.done();
}
}
});
router.afterEach(() => {
NProgress.done();
});
# 总结
本文是 从零打造基于 vue3+ts+tsx+vite 的 antdv 中后台管理系统 系列的第二篇, 主要介绍了动态路由的设计与实现, 下一篇介绍菜单导航、框架布局等相关内容的实现
文中若有错误或者可优化之处, 望请不吝赐教. 如果对你有帮助的话, 麻烦点个赞