Umi4 从零开始实现动态路由、动态菜单
- 🍕 前言
- 🍔 前期准备
- 📃 数据表
- 🤗 Mock数据
- 🔗 定义类型
- 🎈 开始
- 🎃 获取路由信息
- 🧵 patchRoutes({ routes, routeComponents})
- 📸 生成动态路由所需的数据
- formattedRoutePath
- routePath
- componentPath
- filePath
- 🍖 生成动态路由数据及组件
- 😋 完成
- ✨ 踩坑
🍕 前言
近期在写 Umi4 的练习项目,计划实现一个从服务器获取路由信息并动态生成前端路由和导航菜单的功能。本文记录了相关知识、思路以及开发过程中踩到的坑。
🍔 前期准备
📃 数据表
后端同学可以参考
CREATE TABLE `menus` (
`id` INT(10) NOT NULL AUTO_INCREMENT,
`menu_id` VARCHAR(128) NOT NULL,
`parent_id` VARCHAR(128) NULL DEFAULT NULL,
`enable` TINYINT(1) NOT NULL,
`name` VARCHAR(64) NOT NULL,
`sort` SMALLINT(5) NOT NULL DEFAULT '0',
`path` VARCHAR(512) NOT NULL,
`direct` TINYINT(1) NULL DEFAULT '0',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `menu_id` (`menu_id`) USING BTREE,
UNIQUE INDEX `sort` (`sort`) USING BTREE,
UNIQUE INDEX `path` (`path`) USING BTREE,
INDEX `FK_menus_menus` (`parent_id`) USING BTREE,
CONSTRAINT `FK_menus_menus` FOREIGN KEY (`parent_id`) REFERENCES `menus` (`menu_id`) ON UPDATE CASCADE ON DELETE CASCADE
)
COLLATE='utf8mb4_0900_ai_ci'
ENGINE=InnoDB
;
id 记录ID
menu_id 菜单的唯一ID
parent_id 父级菜单的ID
enable 是否启用菜单(后端或查询时进行过滤)
name 路由名称、菜单名称、页面标题
sort 菜单排序(后端或查询时进行排序)(xxxx 代表:一级菜单序号 子菜单序号)
path 前端页面访问路径(同location.pathname)
direct 是否为直接访问的菜单(即不存在子菜单和子路由,为顶级项目)
created_at 记录创建时间
🤗 Mock数据
// ./mock/dynamicRoutes.ts
export default {
'POST /api/system/routes': {
"code": 200,
"msg": "请求成功",
"data": [
{
"id": 1,
"menuId": "dashboard",
"parentId": "",
"enable": true,
"name": "仪表盘",
"sort": 1000,
"path": "/dashboard",
"direct": true,
"createdAt": "1992-08-17 07:29:03"
},
{
"id": 2,
"menuId": "system_management",
"parentId": "",
"enable": true,
"name": "系统管理",
"sort": 2000,
"path": "/system",
"direct": false,
"createdAt": "2011-01-21 09:25:49"
},
{
"id": 3,
"menuId": "user_management",
"parentId": "system_management",
"enable": true,
"name": "用户管理",
"sort": 2001,
"path": "/system/user",
"direct": false,
"createdAt": "1986-06-03 02:38:12"
},
{
"id": 4,
"menuId": "role_management",
"parentId": "system_management",
"enable": true,
"name": "角色管理",
"sort": 2002,
"path": "/system/role",
"direct": false,
"createdAt": "1986-06-03 02:38:12"
},
{
"id": 5,
"menuId": "permission_management",
"parentId": "system_management",
"enable": true,
"name": "权限管理",
"sort": 2003,
"path": "/system/permission",
"direct": false,
"createdAt": "1986-06-03 02:38:12"
},
{
"id": 6,
"menuId": "app_management",
"parentId": "system_management",
"enable": true,
"name": "应用管理",
"sort": 2004,
"path": "/system/app",
"direct": false,
"createdAt": "1986-06-03 02:38:12"
}
]
}
}
🔗 定义类型
// @/utils/dynamicRoutes/typing.d.ts
import type { LazyExoticComponent, ComponentType } from 'react';
import type { Outlet } from '@umijs/max';
declare namespace DynamicRoutes {
// 后端返回的路由数据为 RouteRaw[]
interface RouteRaw {
menuId: string;
parentId: string;
enable: boolean;
name: string;
sort: number;
path: string;
direct: boolean;
createdAt: string;
}
// 前端根据后端返回数据生成的路由数据
interface Route {
id: string;
parentId: 'ant-design-pro-layout' | string;
name: string;
path: string;
file?: string;
children?: Route[];
}
// 前端根据后端返回数据生成的React.lazy懒加载组件或Outlet(一级路由)
type RouteComponent = LazyExoticComponent<ComponentType<any>> | typeof Outlet;
// patchRoutes 函数的参数可以解构出 { routes, routeComponents }
// 此类型用于 Object.assign(routes, parsedRoutes),合并路由数据
interface ParsedRoutes {
[key: number]: Route;
}
// 此类型用于 Object.assign(routeComponents, parsedRoutes),合并路由组件
interface ParsedRouteComponent {
[key: number]: RouteComponent;
}
// parseRoutes 函数的返回值
interface ParseRoutesReturnType {
routes: DynamicRoutes.ParsedRoutes;
routeComponents: DynamicRoutes.ParsedRouteComponent;
}
}
// ./typing.d.ts
import type { DynamicRoutes } from '@/utils/dynamicRoutes/typing';
import '@umijs/max/typings';
declare global {
interface Window {
dynamicRoutes: DynamicRoutes.RouteRaw[];
}
}
🎈 开始
🎃 获取路由信息
// @/global.ts
import { message } from 'antd';
try {
const { data: routesData } = await fetch('/api/system/routes', {
method: 'POST',
}).then((res) => res.json());
if (routesData) {
window.dynamicRoutes = routesData;
}
} catch {
message.error('路由加载失败');
}
export {};
在 umi v4.0.24
中 patchRoutes
方法早于 render
方法执行,所以 umi v3
中在 render
函数中获取路由数据的方法目前不可用。不清楚这个行为属于bug还是 umi 4的特性
我在Github提的issue: [Bug] umi 4 运行时配置中 patchRoutes 早于 render 执行 #9486
经过测试,global.tsx
中的代码早于 patchRoutes
执行,所以在此文件中获取数据。
由于执行 global.tsx
时,app.tsx
中的运行时响应/请求拦截器还未生效,使用 @umijs/max
提供的 request
会报错,所以这里使用 fetch
获取数据,并写入 window.dynamicRoutes
。
🧵 patchRoutes({ routes, routeComponents})
此函数为 umi v4
提供的合并路由数据的方法,其参数可以解构出 routes
、routeCompoents
对象。routes
对象为打平到对象中的路由数据(类型详见DynamicRoutes.Route
),routeComponents
对象存储routes
对象中对应(属性名对应)的组件(类型详见DynamicRoutes.RouteComponent
)
动态更新路由需要直接修改由参数解构出的 routes
和 routeComponents
对象,使用 Object.assign(routes, newRoutes)
将他们与新数据合并
📸 生成动态路由所需的数据
以下三处需要使用DynamicRoutes.RouteRaw.path
经过格式化后的路径:
DynamicRoutes.Route.file
在路由信息中记录组件文件位置DynamciRoutes.Route.path
在路由信息中记录组件的路由路径React.lazy(() => import(path))
懒加载组件所需的文件路径
要生成的路径:
- formattedRoutePath
- routePath
- componentPath
- filePath
formattedRoutePath
// @/utils/dynamicRoutes/index.ts
export function formatRoutePath(path: string) {
const words = path.replace(/^\//, '').split(/(?<=\w+)\//); // 提取路径单词
return `/${words
.map((word: string) =>
word.toLowerCase().replace(word[0], word[0].toUpperCase()),
)
.join('/')}`;
}
约定使用
@/pages/Aaaa/pages/Bbbb
文件夹结构存储组件
DynamicRoutes.RouteRaw.path
中,路径字母大小写可能是不同的,首先使用此方法将大小写不一的路径转换为单词首字母大写的路径,供其他方法进行下一步转换。
转换前:/SYSTEM/user
转换后:/System/User
routePath
// @/utils/dynamicRoutes/index.ts
export function generateRoutePath(path: string) {
return path.toLowerCase();
}
此函数将使用formatRoutePath
转换为全小写字母的路径并提供给DynamciRoutes.Route.path
这个函数根据实际业务需求修改,不必和我一样
转换前:/System/User
转换后:/system/user
componentPath
// @/utils/dynamicRoutes/index.ts
export function generateComponentPath(path: string) {
const words = path.replace(/^\//, '').split(/(?<=\w+)\//); // 提取路径单词
return `${words.join('/pages/')}/index`;
}
此函数生成React.lazy(() => import(path))
所需路径,用于懒加载组件。但此方法生成的不是完整组件路径,由于webpack alias
处理机制,需要在() => import(path)
的参数中编写一个模板字符串 @/pages/${componentPath}
,直接传递将导致@别名失效无法正常加载组件
// 转换前:/System/User
// 转换后:/System/pages/User/index
React.lazy(() => import(`@/pages/${componentPath}`)) // 使用时
filePath
// @/utils/dynamicRoutes/index.ts
export function generateFilePath(path: string) {
const words = path.replace(/^\//, '').split(/(?<=\w+)\//);
return `@/pages/${words.join('/pages/')}/index.tsx`;
}
此函数生成DynamicRoutes.Route.file
所需的完整组件路径
转换前:/System/User
转换后:@/pages/System/pages/User/index.tsx
🍖 生成动态路由数据及组件
首先,在app.tsx
中生成patchRoutes
方法,并获取已在.umirc.ts
中配置的路由数目
// @/app.tsx
// @ts-ignore
export function patchRoutes({ routes, routeComponents }) {
if (window.dynamicRoutes) {
// 存在 & 成功获取动态路由数据
const currentRouteIndex = Object.keys(routes).length; // 获取已在.umirc.ts 中配置的路由数目
const parsedRoutes = parseRoutes(window.dynamicRoutes, currentRouteIndex);
}
}
传入parseRoutes函数,生成路由数据
// @/utils/dynamicRoutes/index.ts
import type { DynamicRoutes } from './typing';
import { lazy } from 'react';
import { Outlet } from '@umijs/max';
export function parseRoutes(
routesRaw: DynamicRoutes.RouteRaw[],
beginIdx: number,
): DynamicRoutes.ParseRoutesReturnType {
const routes: DynamicRoutes.ParsedRoutes = {}; // 转换后的路由信息
const routeComponents: DynamicRoutes.ParsedRouteComponent = {}; // 生成的React.lazy组件
const routeParentMap = new Map<string, number>(); // menuId 与路由记录在 routes 中的键 的映射。如:'role_management' -> 7
let currentIdx = beginIdx; // 当前处理的路由项的键。把 patchRoutes 传进来的 routes 看作一个数组,这里就是元素的下标。
routesRaw.forEach((route) => {
let effectiveRoute = true; // 当前处理中的路由是否有效
const formattedRoutePath = formatRoutePath(route.path); // 将服务器返回的路由路径中的单词转换为首字母大写其余小写
const routePath = generateRoutePath(formattedRoutePath); // 全小写的路由路径
const componentPath = generateComponentPath(formattedRoutePath); // 组件路径 不含 @/pages/
const filePath = generateFilePath(formattedRoutePath); // 路由信息中的组件文件路径
// 是否为直接显示(不含子路由)的路由记录,如:/home; /Dashboard
if (route.direct) {
// 生成路由信息
const tempRoute: DynamicRoutes.Route = {
id: currentIdx.toString(),
parentId: 'ant-design-pro-layout',
name: route.name,
path: routePath,
file: filePath,
};
// 存储路由信息
routes[currentIdx] = tempRoute;
// 生成组件
const tempComponent = lazy(() => import(`@/pages/${componentPath}`));
// 存储组件
routeComponents[currentIdx] = tempComponent;
} else {
// 判断是否非一级路由
if (!route.parentId) {
// 正在处理的项为一级路由
// 生成路由信息
const tempRoute: DynamicRoutes.Route = {
id: currentIdx.toString(),
parentId: 'ant-design-pro-layout',
name: route.name,
path: routePath,
};
// 存储路由信息
routes[currentIdx] = tempRoute;
// 一级路由没有它自己的页面,这里生成一个Outlet用于显示子路由页面
const tempComponent = Outlet;
// 存储Outlet
routeComponents[currentIdx] = tempComponent;
// 记录菜单ID与当前项下标的映射
routeParentMap.set(route.menuId, currentIdx);
} else {
// 非一级路由
// 获取父级路由ID
const realParentId = routeParentMap.get(route.parentId);
if (realParentId) {
// 生成路由信息
const tempRoute: DynamicRoutes.Route = {
id: currentIdx.toString(),
parentId: realParentId.toString(),
name: route.name,
path: routePath,
file: filePath,
};
// 存储路由信息
routes[currentIdx] = tempRoute;
// 生成组件
const tempComponent = lazy(() => import(`@/pages/${componentPath}`));
// 存储组件
routeComponents[currentIdx] = tempComponent;
} else {
// 找不到父级路由,路由无效,workingIdx不自增
effectiveRoute = false;
}
}
}
if (effectiveRoute) {
// 当路由有效时,将workingIdx加一
currentIdx += 1;
}
});
return {
routes,
routeComponents,
};
}
在app.tsx
中合并处理后的路由数据
// @ts-ignore
export function patchRoutes({ routes, routeComponents }) {
if (window.dynamicRoutes) {
const currentRouteIndex = Object.keys(routes).length;
const parsedRoutes = parseRoutes(window.dynamicRoutes, currentRouteIndex);
Object.assign(routes, parsedRoutes.routes); // 参数传递的为引用类型,直接操作原对象,合并路由数据
Object.assign(routeComponents, parsedRoutes.routeComponents); // 合并组件
}
}
😋 完成
✨ 踩坑
- 目前需要在
global.tsx
中获取路由数据,因为patchRoutes
发生于render
之前 patchRoutes
的原始路由数据与新数据需要使用Object.assign
合并,不能直接赋值- 使用
React.lazy
生成懒加载组件时,不能直接传入完整路径。传入完整路径使webpack无法处理alias
,导致组件路径错误