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 记录IDmenu_id 菜单的唯一IDparent_id 父级菜单的IDenable 是否启用菜单(后端或查询时进行过滤)name 路由名称、菜单名称、页面标题sort 菜单排序(后端或查询时进行排序)(xxxx 代表:一级菜单序号 子菜单序号)path 前端页面访问路径(同location.pathname)direct 是否为直接访问的菜单(即不存在子菜单和子路由,为顶级项目)created_at 记录创建时间

Mock数据

// ./mock/dynamicRoutes.tsexport 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.tsimport 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.tsimport type { DynamicRoutes } from '@/utils/dynamicRoutes/typing';import '@umijs/max/typings';declare global {interface Window {dynamicRoutes: DynamicRoutes.RouteRaw[];}}

开始

获取路由信息

// @/global.tsimport { 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.24patchRoutes方法早于 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提供的合并路由数据的方法,其参数可以解构出 routesrouteCompoents对象。
routes对象为打平到对象中的路由数据(类型详见DynamicRoutes.Route),routeComponents对象存储routes对象中对应(属性名对应)的组件(类型详见DynamicRoutes.RouteComponent

动态更新路由需要直接修改由参数解构出的 routesrouteComponents对象,使用 Object.assign(routes, newRoutes)将他们与新数据合并

生成动态路由所需的数据

以下三处需要使用DynamicRoutes.RouteRaw.path经过格式化后的路径:

  1. DynamicRoutes.Route.file在路由信息中记录组件文件位置
  2. DynamciRoutes.Route.path在路由信息中记录组件的路由路径
  3. React.lazy(() => import(path))懒加载组件所需的文件路径

要生成的路径:

  • formattedRoutePath
  • routePath
  • componentPath
  • filePath

formattedRoutePath

// @/utils/dynamicRoutes/index.tsexport function formatRoutePath(path: string) {const words = path.replace(/^\//, '').split(/(" />); // 提取路径单词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.tsexport function generateRoutePath(path: string) {return path.toLowerCase();}

此函数将使用formatRoutePath转换为全小写字母的路径并提供给DynamciRoutes.Route.path
这个函数根据实际业务需求修改,不必和我一样

转换前:/System/User转换后:/system/user

componentPath

// @/utils/dynamicRoutes/index.tsexport 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/indexReact.lazy(() => import(`@/pages/${componentPath}`)) // 使用时

filePath

// @/utils/dynamicRoutes/index.tsexport 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-ignoreexport 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.tsimport 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' -> 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;// 存储OutletrouteComponents[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-ignoreexport 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,导致组件路径错误