实现React、Vue3的动态路由
后端
说明
简单说一下常见的
RBAC (Role Based Access Control)
模型, 主要是由User
、Role
、Resource
三个表组成, 如图所示:User
表存储用户信息,Role
表存储角色信息,Resource
表存储资源信息,User
和Role
是多对多的关系,Role
和Resource
是多对多的关系, 通过UserRole
和RoleResource
两张中间表来实现多对多的关系,UserRole
表存储User
和Role
的关系,RoleResource
表存储Role
和Resource
的关系
流程
用户登录之后得到用户 Id
user_id
在
user_role
表中 根据user_id
得到对应的角色role_id
在
role_resource
表中 根据role_id
得到对应的资源resource_id
列表在
resource
表中 根据resource_id
得到对应的resource
,最后组合成列表就是用户拥有的资源
示例一
后端返回处理好的树形结构的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15[
{ "id": 1, "name": "仪表盘", "icon": "DashboardOutlined", "menuUrl": "/dashboard", "children": [] },
{
"id": 2,
"name": "系统管理",
"icon": "SettingOutlined",
"menuUrl": "/system",
"children": [
{ "id": 3, "name": "用户管理", "icon": "UserOutlined", "menuUrl": "/system/user", "children": [] },
{ "id": 7, "name": "角色管理", "icon": "ClusterOutlined", "menuUrl": "/system/role", "children": [] },
{ "id": 8, "name": "资源管理", "icon": "DatabaseOutlined", "menuUrl": "/system/resource", "children": [] }
]
},
{ "id": 9, "name": "日志记录", "icon": "CloudServerOutlined", "menuUrl": "/log", "children": [] }
]1
2
3
4
5
6
7
8
9
10
11
12
13
14
### 示例二
- 后端返回的是资源列表, 前端需要自己处理成树形结构
```json
[
{ "id": 1, "pid": 0, "icon": "DashboardOutlined", "menuUrl": "/dashboard", "name": "仪表盘" },
{ "id": 2, "pid": 0, "icon": "SettingOutlined", "menuUrl": "/system", "name": "系统管理" },
{ "id": 3, "pid": 2, "icon": "UserOutlined", "menuUrl": "/system/user", "name": "用户管理" },
{ "id": 7, "pid": 2, "icon": "ClusterOutlined", "menuUrl": "/system/role", "name": "角色管理" },
{ "id": 8, "pid": 2, "icon": "DatabaseOutlined", "menuUrl": "/system/resource", "name": "资源管理" },
{ "id": 9, "pid": 0, "icon": "CloudServerOutlined", "menuUrl": "/log", "name": "日志记录" }
]处理资源列表. 前端常见的面试题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const convertToTree = (menuList, parentId = 0) => {
const result = [];
for (const menu of menuList) {
if (menu.pid === parentId) {
const children = convertToTree(menuList, menu.id);
if (children.length) {
menu.children = children;
}
result.push(menu);
}
}
return result;
};
React
示例代码
项目目录
1 | ⨽ src |
核心代码
main.tsx
1
2
3
4
5
6
7
8ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
{/* react-router-dom 的 BrowserRouter 组件: 使用浏览器的内置历史记录堆栈进行导航 */}
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29function App() {
const [menus, setMenus] = useState<any>([]); // 动态路由
const [route, setRoute] = useState(routers); // 所有路由
const element = useRoutes(route); // set的时候会重新渲染
const location = useLocation();
// 模拟请求: 建议使用 useSWR -- https://github.com/zxiaosi/react-springboot/blob/master/frontend/web/src/App.tsx
useEffect(() => {
const menuStorage = getLocal('menus') || []; // 这里给一个默认值,防止序列化报错
if (menuStorage?.length == 0 && location.pathname != '/login') {
setMenus(menusData); // 当本地缓存中没有菜单且当前路由不是登录页的时候, 去请求接口
} else {
setMenus(menuStorage);
}
}, [location.pathname]);
useEffect(() => {
const newRoute = generateRouter(menus);
setRoute(newRoute);
menus?.length > 0 && setLocal('menus', menus);
}, [menus]);
// <></> 组件 -- https://react.dev/reference/react/Fragment
return <>{element}</>;
}
export default App;router/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100import { lazy, Suspense } from 'react';
import { Navigate } from 'react-router-dom';
import * as Icons from '@ant-design/icons';
import React from 'react';
import IsAuth from './isAuth';
// 参考 https://juejin.cn/post/7132393527501127687
const LAYOUT_PAGE = 'layout';
/** 导入指定文件下的路由模块 */
const modules = import.meta.glob('../views/**/*.tsx');
console.log('modules', modules);
/** 异步懒加载组件 */
const lazyLoad = (moduleName: string) => {
// 根据模块名匹配对应的组件
const Module = lazy(modules[`../views${moduleName}/index.tsx`] as any);
return (
<Suspense fallback={<div>loading...</div>}>
<Module />
</Suspense>
);
};
/** 动态创建Icon */
const dynamicIcon = (icon: string) => {
const antIcon: { [key: string]: any } = Icons; // 防止类型报错
return React.createElement(antIcon[icon]);
};
/** 静态路由 */
export const routers: any[] = [
{
path: '/',
element: <Navigate to="/dashboard" replace={true} />,
},
{
path: '/login',
meta: { title: '登录' },
element: lazyLoad('/login'),
},
{
path: '/',
id: LAYOUT_PAGE, // 判断是否插入动态路由的标识
element: <IsAuth>{lazyLoad('/' + LAYOUT_PAGE)}</IsAuth>,
},
{
path: '*',
meta: { title: '404' },
element: lazyLoad('/error'),
},
];
/** 生成菜单 */
export const generateMenu = (data: any) => {
const result: any = [];
data.forEach((item: any, index: number) => {
result.push({
key: item.menuUrl,
label: item.name,
icon: dynamicIcon(item.icon),
});
if (item.children.length > 0) result[index].children = generateMenu(item.children);
});
return result;
};
/** 迭代动态路由 */
const iterateRouter = (data: any) => {
const result: any[] = [];
// 动态路由
data?.forEach((item: any, index: number) => {
result.push({
path: item.menuUrl,
meta: { title: item.name, icon: item.icon },
element: lazyLoad('/' + LAYOUT_PAGE + item.menuUrl),
});
if (item.children?.length > 0) result[index].children = iterateRouter(item.children);
});
return result;
};
/** 生成路由 */
export const generateRouter = (data: any) => {
// 1. 迭代动态路由
const dynamicRouters = data ? iterateRouter(data) : [];
// 2. 合并所有路由 = 动态路由 + 静态路由
const idx = routers.findIndex((item: any) => item.id == LAYOUT_PAGE);
routers[idx].children = [...dynamicRouters];
return routers.slice(); // 注意这里要浅拷贝返回一个新的数组,否则会报错
};isAuth.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import { Navigate } from 'react-router-dom';
import { clearLocal } from '../utils/auth';
/** 判断是否登录 -- 路由拦截 */
const IsAuth = ({ children }: { children: JSX.Element }) => {
// const cookie = document.cookie; // cookie 标识
const token = localStorage.getItem('token'); // token 标识
/**
* 这里应该在响应拦截器中处理. 后端返回401状态码,清除本地缓存
* 但是这里只是模拟,所以直接清除
*/
!token && clearLocal();
return token ? children : <Navigate to={'/login'} replace />;
};
export default IsAuth;
Vue3
示例代码
项目目录
1 | ⨽ src |
核心代码
main.tx
1
2
3
4
5
6
7
8
9
10const app = createApp(App);
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(router); // 挂载路由
app.mount('#app');App.vue
1
2
3
4<template>
<!-- 路由出口 -->
<RouterView />
</template>router/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129const LayoutPage = 'Layout'; // 布局页
const modules = import.meta.glob('@/views/pages/**/*.vue'); // 获取 views/home/ 下的vue文件
// 静态路由
const staticRoutes = [
{ path: '/', redirect: '/dashboard' }, // 重定向
{
path: '/login',
meta: { title: '登录' },
component: () => import('@/views/Login.vue'),
},
{
path: '/',
name: LayoutPage, // 路由名称, 用于添加动态路由 (详见: https://router.vuejs.org/zh/guide/advanced/dynamic-routing.html#%E6%B7%BB%E5%8A%A0%E5%B5%8C%E5%A5%97%E8%B7%AF%E7%94%B1)
component: () => import(`@/views/${LayoutPage}.vue`),
},
{
path: '/:pathMatch(.*)*',
meta: { title: '404' },
component: () => import('@/views/404.vue'),
},
];
/**
* 创建路由实例
*/
const router = createRouter({
history: createWebHistory(),
routes: staticRoutes,
});
/**
* 路由守卫
*/
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
document.title = `${to.meta.title} | Demo`; // 页面名
if (getLocal('token')) {
// 检查是否存在token, 不存在则用户登录状态失效
if (to.path == '/login') {
next();
} else {
const menusStorage = getLocal('menus') || [];
// 用户已登录
if (menusStorage.length > 0) {
if (router.getRoutes().length == staticRoutes.length) {
// 如果路由表中的路由数量等于静态路由的数量, 则添加动态路由 (防止重复添加动态路由)
mergeRoutes(menusStorage, router); // 从缓存中获取菜单列表
next({ ...to, replace: true }); // 确保 addRoutes 已完成
} else {
next();
}
} else {
const menusData = mockData; // 替换为真实接口
setLocal('menus', menusData); // 存储到本地缓存中
if (menusData.length > 0) {
mergeRoutes(menusData, router); // 添加动态路由
next({ ...to, replace: true });
} else {
throw new Error('菜单列表为空');
}
}
}
} else {
// 用户未登录 (防止无限重定向)
if (to.path == '/login') {
// 如果用户访问的是登录页, 直接放行
next();
} else {
// 如果用户访问的不是登录页, 则重定向到登录页
next('/login');
}
}
});
/**
* 组装菜单列表
* @param data 菜单列表
*/
export const iterateMenu = (data: any) => {
let result: any = [];
data.forEach((item: any, index: number) => {
result.push({
path: item.menuUrl,
meta: { title: item.name, icon: item.icon },
component: modules[`/src/views/pages${uriToFileName(item.menuUrl)}.vue`],
});
if (item.children.length > 0) result[index].children = iterateMenu(item.children);
});
return result;
};
/**
* 获取路由对应的文件名 eg: /user => User | /home/user => /home/User
* @param uri 路由地址
* @returns 文件名
*/
export const uriToFileName = (uri: string) => {
let strList = uri.split('/');
let fileName = strList[strList.length - 1];
let prefix = uri.replace(fileName, '');
return `${prefix + fileName[0]?.toUpperCase() + fileName?.slice(1)}`;
};
/**
* 添加动态路由,并同步到状态管理器中
* @param menus 路由列表
* @param router 路由实例
*/
export const mergeRoutes = (menus: any, router: any) => {
// 动态路由
const dynamicMenu = iterateMenu(menus);
// 添加到指定路径下得动态路由
dynamicMenu.forEach((item: any) => router.addRoute('Layout', item));
/**
* router.getRoutes() 获取的是所有路由,扁平化输出,看不到嵌套的路由,但实际有嵌套的路由
* 详见: https://github.com/vuejs/router/issues/600
*/
// console.log("router", router.getRoutes());
};
总结
其实实现思路都差不多, 都是
静态路由
+后端返回的路由
=动态路由
Vue-Router
是通过router.beforeEach
路由守卫来实现的, 通过addRoute
来添加动态路由React-Router
是通过监听location.pathname
路径变化实现的, 通过useRoutes
来添加所有路由
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 小四先生的云!
评论