Umi4 从零开始实现动态路由、动态菜单
🍕 前言
近期在写 Umi4 的练习项目,计划实现一个从服务器获取路由信息并动态生成前端路由和导航菜单的功能。本文记录了相关知识、思路以及开发过程中踩到的坑。
🍔 前期准备
📃 数据表
后端同学可以参考
CREATETABLE`menus`(`id`INT(10)NOTNULLAUTO_INCREMENT,`menu_id`VARCHAR(128)NOTNULL,`parent_id`VARCHAR(128)NULLDEFAULTNULL,`enable`TINYINT(1)NOTNULL,`name`VARCHAR(64)NOTNULL,`sort`SMALLINT(5)NOTNULLDEFAULT'0',`path`VARCHAR(512)NOTNULL,`direct`TINYINT(1)NULLDEFAULT'0',`created_at`DATETIMENOTNULL,PRIMARYKEY(`id`)USINGBTREE,UNIQUEINDEX`menu_id`(`menu_id`)USINGBTREE,UNIQUEINDEX`sort`(`sort`)USINGBTREE,UNIQUEINDEX`path`(`path`)USINGBTREE,INDEX`FK_menus_menus`(`parent_id`)USINGBTREE,CONSTRAINT`FK_menus_menus`FOREIGNKEY(`parent_id`)REFERENCES`menus`(`menu_id`)ONUPDATECASCADEONDELETECASCADE)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.tsexportdefault{'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.tsimporttype{ LazyExoticComponent, ComponentType }from'react';importtype{ Outlet }from'@umijs/max';declarenamespace DynamicRoutes {// 后端返回的路由数据为 RouteRaw[]interfaceRouteRaw{
menuId:string;
parentId:string;
enable:boolean;
name:string;
sort:number;
path:string;
direct:boolean;
createdAt:string;}// 前端根据后端返回数据生成的路由数据interfaceRoute{
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),合并路由数据interfaceParsedRoutes{[key:number]: Route;}// 此类型用于 Object.assign(routeComponents, parsedRoutes),合并路由组件interfaceParsedRouteComponent{[key:number]: RouteComponent;}// parseRoutes 函数的返回值interfaceParseRoutesReturnType{
routes: DynamicRoutes.ParsedRoutes;
routeComponents: DynamicRoutes.ParsedRouteComponent;}}
// ./typing.d.tsimporttype{ DynamicRoutes }from'@/utils/dynamicRoutes/typing';import'@umijs/max/typings';declare global {interfaceWindow{
dynamicRoutes: DynamicRoutes.RouteRaw[];}}
🎈 开始
🎃 获取路由信息
// @/global.tsimport{ message }from'antd';try{const{ data: routesData }=awaitfetch('/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.tsexportfunctionformatRoutePath(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.tsexportfunctiongenerateRoutePath(path:string){return path.toLowerCase();}
此函数将使用
formatRoutePath
转换为全小写字母的路径并提供给
DynamciRoutes.Route.path
这个函数根据实际业务需求修改,不必和我一样
转换前:/System/User
转换后:/system/user
componentPath
// @/utils/dynamicRoutes/index.tsexportfunctiongenerateComponentPath(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.tsexportfunctiongenerateFilePath(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-ignoreexportfunctionpatchRoutes({ routes, routeComponents }){if(window.dynamicRoutes){// 存在 & 成功获取动态路由数据const currentRouteIndex = Object.keys(routes).length;// 获取已在.umirc.ts 中配置的路由数目const parsedRoutes =parseRoutes(window.dynamicRoutes, currentRouteIndex);}}
传入parseRoutes函数,生成路由数据
// @/utils/dynamicRoutes/index.tsimporttype{ DynamicRoutes }from'./typing';import{ lazy }from'react';import{ Outlet }from'@umijs/max';exportfunctionparseRoutes(
routesRaw: DynamicRoutes.RouteRaw[],
beginIdx:number,): DynamicRoutes.ParseRoutesReturnType {const routes: DynamicRoutes.ParsedRoutes ={};// 转换后的路由信息const routeComponents: DynamicRoutes.ParsedRouteComponent ={};// 生成的React.lazy组件const routeParentMap =newMap<string,number>();// menuId 与路由记录在 routes 中的键 的映射。如:'role_management' -> 7let 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; /Dashboardif(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{// 非一级路由// 获取父级路由IDconst 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-ignoreexportfunctionpatchRoutes({ 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
,导致组件路径错误
版权归原作者 绿胡子大叔 所有, 如有侵权,请联系我们删除。