jjzjj

Umi4 从零开始实现动态路由、动态菜单

绿胡子大叔 2023-04-15 原文

Umi4 从零开始实现动态路由、动态菜单

🍕 前言

近期在写 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.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.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,导致组件路径错误

有关Umi4 从零开始实现动态路由、动态菜单的更多相关文章

  1. ruby-on-rails - Rails 3 中的多个路由文件 - 2

    Rails2.3可以选择随时使用RouteSet#add_configuration_file添加更多路由。是否可以在Rails3项目中做同样的事情? 最佳答案 在config/application.rb中:config.paths.config.routes在Rails3.2(也可能是Rails3.1)中,使用:config.paths["config/routes"] 关于ruby-on-rails-Rails3中的多个路由文件,我们在StackOverflow上找到一个类似的问题

  2. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  3. ruby - rails 3 redirect_to 将参数传递给命名路由 - 2

    我没有找到太多关于如何执行此操作的信息,尽管有很多关于如何使用像这样的redirect_to将参数传递给重定向的建议:action=>'something',:controller=>'something'在我的应用程序中,我在路由文件中有以下内容match'profile'=>'User#show'我的表演Action是这样的defshow@user=User.find(params[:user])@title=@user.first_nameend重定向发生在同一个用户Controller中,就像这样defregister@title="Registration"@user=Use

  4. ruby-on-rails - Rails 3,嵌套资源,没有路由匹配 [PUT] - 2

    我真的为这个而疯狂。我一直在搜索答案并尝试我找到的所有内容,包括相关问题和stackoverflow上的答案,但仍然无法正常工作。我正在使用嵌套资源,但无法使表单正常工作。我总是遇到错误,例如没有路线匹配[PUT]"/galleries/1/photos"表格在这里:/galleries/1/photos/1/edit路线.rbresources:galleriesdoresources:photosendresources:galleriesresources:photos照片Controller.rbdefnew@gallery=Gallery.find(params[:galle

  5. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  6. Observability:从零开始创建 Java 微服务并监控它 (二) - 2

    这篇文章是继上一篇文章“Observability:从零开始创建Java微服务并监控它(一)”的续篇。在上一篇文章中,我们讲述了如何创建一个Javaweb应用,并使用Filebeat来收集应用所生成的日志。在今天的文章中,我来详述如何收集应用的指标,使用APM来监控应用并监督web服务的在线情况。源码可以在地址 https://github.com/liu-xiao-guo/java_observability 进行下载。摄入指标指标被视为可以随时更改的时间点值。当前请求的数量可以改变任何毫秒。你可能有1000个请求的峰值,然后一切都回到一个请求。这也意味着这些指标可能不准确,你还想提取最小/

  7. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  8. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

  9. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  10. ruby-on-rails - Rails - 从命名路由中提取 HTTP 动词 - 2

    Rails中有没有一种方法可以提取与路由关联的HTTP动词?例如,给定这样的路线:将“users”匹配到:“users#show”,通过:[:get,:post]我能实现这样的目标吗?users_path.respond_to?(:get)(显然#respond_to不是正确的方法)我最接近的是通过执行以下操作,但它似乎并不令人满意。Rails.application.routes.routes.named_routes["users"].constraints[:request_method]#=>/^GET$/对于上下文,我有一个设置cookie然后执行redirect_to:ba

随机推荐