跳到主要内容

React之umi中台框架

about

procomponents官方使用说明中有ProLayout 与 Umi 配合使用会有最好的效果,Umi 会把 config.ts 中的路由自动注入到配置的 layout 中,免去我们手写菜单的烦恼。,但我测试时一直没成功过。只是在umi-plugin及umi-max只可以,但layout的配置灵活性差。在纯umi + procomponents模式下,一直未实现将config.ts路由导入procomponents。

本例在纯umi + procomponents模式下,通过自定义函数loopMenuItemconfig.ts的路由转换成prolayout中的route参数值的方式实现路由与菜单的统一配置。

Alt text

提示

  • 基于umi + prolayout,框架菜单由路由自动生成。虽没有reat+antd/layout组件灵活,但更实用。pc端和移动端自适应。
  • 本示例,文字说明较少,可查看注解。
  • 支持页面url方式打开时的状态保持。
  • sider时,页面底部不配置页脚。此时在sider底部配置页脚。

安装配置

# yarn create umi myapp    # 选"Simple App"
? Pick Umi App Template › - Use arrow-keys. Return to submit.
❯ Simple App
Ant Design Pro
Vue Simple App
# cd myapp
# yarn add @ant-design/pro-components

项目目录

# tree myapp
myapp
├── config
│ ├── config.ts # 配置文件
│ └── router.tsx # 路由配置文件
├── node_modules
├── package.json
├── src
│ ├── assets
│ │ ├── logo.png
│ │ └── logo.scss
│ ├── layouts # 默认layout配置
│ │ ├── _defaultProps.tsx # prolayout配置
│ │ └── index.tsx # prolayout主框架
│ ├── test # 页面代码
│ │ ├── 404.tsx
│ │ ├── about.tsx
│ │ ├── home.tsx
│ │ ├── login.js
│ │ ├── menu1.tsx
│ │ ├── menu2.tsx # 有子菜单,其内容为 return <Outlet />
│ │ ├── menu21.tsx
│ │ ├── menu22.tsx
│ │ ├── menu23.tsx # 有子菜单,其内容为 return <Outlet />
│ │ ├── menu231.tsx
│ │ ├── menu232.tsx
│ │ └── userconf.tsx
│ └── favicon.png # 浏览器小图标
├── tsconfig.json
├── typings.d.ts
└── yarn.lock

路由配置

config/router.tsx

import React from 'react';   //此行为必须项
import { Icon } from '@iconify/react';

import {
MailOutlined,
AppstoreTwoTone,
AppstoreOutlined,
HomeOutlined,
} from '@ant-design/icons';

import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: [
'//at.alicdn.com/t/font_1788044_0dwu4guekcwr.js', // icon-javascript, icon-java, icon-shoppingcart (overridden)
'//at.alicdn.com/t/font_1788592_a5xf2bdic3u.js', // icon-shoppingcart, icon-python
],
});

//定义数据类型
interface LayoutRouterType {
key?: string;
path: string;
name: string;
icon?: any;
component?: string;
hideInMenu?: boolean;
routes?:LayoutRouterType[];
}

//这类路由用于prolayout框架内。
const RouterListProLayout:LayoutRouterType[] = [
//直接采用url
{
key:"about",
path:"/about",
name:"about",
icon: 'https://gw.alipayobjects.com/zos/antfincdn/upvrAjAPQX/Logo_Tech%252520UI.svg',
//icon: <MailOutlined />,
component: "@/test/about.tsx",
},
//采用第三方图标:iconify
{
key:"home",
path:"/home",
name:"Home",
//icon:<HomeOutlined />,
icon: <Icon icon="ri:mail-open-line" />,
component: "@/test/home.tsx",
},
//采用第三方图标:Iconfont
{
key:"menu1",
path:"/menu1",
name:"menu1",
//icon:<MailOutlined />,
icon: <IconFont type="icon-python" />,
component: "@/test/menu1.tsx",
},
//采用antd标准图标
{
key:"menu2",
path:"/menu2",
name:"sider示例",
icon:<AppstoreOutlined />,
component: "@/test/menu2.tsx",
routes: [
{
key:"menu21",
path:"menu21",
name:"menu21",
icon:<MailOutlined />,
component: "@/test/menu21.tsx",
},
{
key:"menu22",
path:"menu22",
name:"menu22",
icon:<MailOutlined />,
component: "@/test/menu22.tsx",
},
{
key:"menu23",
path:"menu23",
name:"menu23",
icon:<MailOutlined />,
component: "@/test/menu23.tsx",
routes: [
{
key:"menu231",
path:"menu231",
name:"menu231",
icon:<MailOutlined />,
component: "@/test/menu231.tsx",
},
{
key:"menu232",
path:"menu232",
name:"menu232",
icon:<MailOutlined />,
component: "@/test/menu232.tsx",
},
]
},
],
},
]

//这类路由用于prolayout框架外
const RouterListExtr = [
{ path: "/", redirect: '/about'},
{
key:"login",
path:"/login",
name:"login",
component: "@/test/login.js",
layout:false,
},
{
key:"userconf",
path:"/userconf",
name:"userconf",
//icon:<MailOutlined />,
//hideInMenu:true,
component: "@/test/userconf.tsx",
//layout:false,
},
{ path: "*", component: "@/test/404.tsx"},
]

//在umi中,路由中配置图标时采用字串方式,如
// icon:"MailOutlined"
//而在prolayout中,菜单的图标配置采用dom对像方式,如下
// icon:<MailOutlined />
//此函数功能就是将图标格式转换一下。
// 先配置成icon:<MailOutlined />供prolayout使用,再转换成icon:"MailOutlined"供路由配置使用。
// 若icon本身是http方式,不再转换。
//其实路由不需要图标,可以不配置,在转换过程过程中直接配置空该项即可。
function loopMenuItem(routerlist: LayoutRouterType[]):any {
return (
routerlist.map(
( { icon, routes, ...item } ) => {
let iconname = ''
//const iconname = icon.type.displayName
//console.log("icon name:",iconname)
if (icon) {
if ((typeof icon) === "string") {
iconname = icon
} else {
iconname = icon.type.displayName
}
} {
iconname = ''
}
//返回新item
return ({
...item,
icon: iconname,
routes: routes && loopMenuItem(routes),
})
}
)
)
}

//将prolayout菜单配置格式转换成umi路由识别格式。
const UmiRouter = loopMenuItem(RouterListProLayout)


export {
RouterListProLayout, //供prolayout导航菜单使用,作为其参数routes值使用。与UmiRouter条目一一对应。
UmiRouter, //供umi路由配置,此类路由嵌套在prolayout框架内有菜单条目
RouterListExtr, //供umi路由配置,此类路由在prolayout框架没有菜单条目。显示内容可以框架内或外。
}

config/config.ts

import { defineConfig } from "umi";
import {UmiRouter,RouterListExtr} from "./router";

export default defineConfig({
//路由配置
routes :[
...UmiRouter,
...RouterListExtr,
],

npmClient: 'yarn',
});

layouts框架

src/layouts/_defaultProps.tsx

import React from 'react';
export default {
//title配置
title: "DarryG",

//背景图片
/*
bgLayoutImgList: [
{
src: 'https://img.alicdn.com/imgextra/i2/O1CN01O4etvp1DvpFLKfuWq_!!6000000000279-2-tps-609-606.png',
left: 85,
bottom: 100,
height: '303px',
},
{
src: 'https://img.alicdn.com/imgextra/i2/O1CN01O4etvp1DvpFLKfuWq_!!6000000000279-2-tps-609-606.png',
bottom: -68,
right: -45,
height: '303px',
},
{
src: 'https://img.alicdn.com/imgextra/i3/O1CN018NxReL1shX85Yz6Cx_!!6000000005798-2-tps-884-496.png',
bottom: 0,
left: 0,
width: '331px',
},
],
*/
//跨站点导航列表: logo前面的应用列表
appList: [
{
icon: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg',
title: 'Ant Design',
desc: '杭州市较知名的 UI 设计语言',
url: 'https://ant.design',
},
{
icon: 'https://gw.alipayobjects.com/zos/antfincdn/FLrTNDvlna/antv.png',
title: 'AntV',
desc: '蚂蚁集团全新一代数据可视化解决方案',
url: 'https://antv.vision/',
target: '_blank',
},
{
icon: 'https://gw.alipayobjects.com/zos/antfincdn/upvrAjAPQX/Logo_Tech%252520UI.svg',
title: 'Pro Components',
desc: '专业级 UI 组件库',
url: 'https://procomponents.ant.design/',
},
{
icon: 'https://img.alicdn.com/tfs/TB1zomHwxv1gK0jSZFFXXb0sXXa-200-200.png',
title: 'umi',
desc: '插件化的企业级前端应用框架。',
url: 'https://umijs.org/zh-CN/docs',
},
{
icon: 'https://gw.alipayobjects.com/zos/bmw-prod/8a74c1d3-16f3-4719-be63-15e467a68a24/km0cv8vn_w500_h500.png',
title: 'qiankun',
desc: '可能是你见过最完善的微前端解决方案🧐',
url: 'https://qiankun.umijs.org/',
},
{
icon: 'https://gw.alipayobjects.com/zos/rmsportal/XuVpGqBFxXplzvLjJBZB.svg',
title: '语雀',
desc: '知识创作与分享工具',
url: 'https://www.yuque.com/',
},
],

//通过 token 修改样式
/*
token: {
colorBgAppListIconHover: 'rgba(0,0,0,0.06)',
colorTextAppListIconHover: 'rgba(255,255,255,0.95)',
colorTextAppListIcon: 'rgba(255,255,255,0.85)',
sider: {
colorBgCollapsedButton: '#fff',
colorTextCollapsedButtonHover: 'rgba(0,0,0,0.65)',
colorTextCollapsedButton: 'rgba(0,0,0,0.45)',
colorMenuBackground: '#004FD9',
colorBgMenuItemCollapsedElevated: 'rgba(0,0,0,0.85)',
colorMenuItemDivider: 'rgba(255,255,255,0.15)',
colorBgMenuItemHover: 'rgba(0,0,0,0.06)',
colorBgMenuItemSelected: 'rgba(0,0,0,0.15)',
colorTextMenuSelected: '#fff',
colorTextMenuItemHover: 'rgba(255,255,255,0.75)',
colorTextMenu: 'rgba(255,255,255,0.75)',
colorTextMenuSecondary: 'rgba(255,255,255,0.65)',
colorTextMenuTitle: 'rgba(255,255,255,0.95)',
colorTextMenuActive: 'rgba(255,255,255,0.95)',
colorTextSubMenuSelected: '#fff',
},
header: {
colorBgHeader: '#004FD9',
colorBgRightActionsItemHover: 'rgba(0,0,0,0.06)',
colorTextRightActionsItem: 'rgba(255,255,255,0.65)',
colorHeaderTitle: '#fff',
colorBgMenuItemHover: 'rgba(0,0,0,0.06)',
colorBgMenuItemSelected: 'rgba(0,0,0,0.15)',
colorTextMenuSelected: '#fff',
colorTextMenu: 'rgba(255,255,255,0.75)',
colorTextMenuSecondary: 'rgba(255,255,255,0.65)',
colorTextMenuActive: 'rgba(255,255,255,0.95)',
},
},
*/
}

src/layouts/index.tsx

import React, {useEffect, useState } from 'react';
import { useNavigate} from "react-router-dom";
import { Outlet } from 'umi'
import { useLocation } from 'react-router-dom';

import {
Dropdown,
} from 'antd';

import {
PageContainer,
ProLayout,
DefaultFooter,
MenuDataItem,
ProConfigProvider,
} from '@ant-design/pro-components';

import {
LogoutOutlined,
EditOutlined,
} from '@ant-design/icons';

import type { MenuProps } from 'antd';

import defaultProps from './_defaultProps';

//引入菜单文件(从路由提取)
import {RouterListProLayout} from '../../config/router.tsx'

//图标动画css
import '../assets/logo.scss';

//网站图标
const logoPNG = require('../assets/logo.png');

//-------用户配置的菜单-----------------------------------------------------
const Useritems: MenuProps['items'] = [
{
key: 'userconf',
icon: <EditOutlined />,
label: '配置修改',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
},
]

//-----主程序-----------------------------------------------------------------------
const BasicLayout: React.FC<{}> = () => {
//跳转函数
const navigate = useNavigate();

//当前应用会话的位置信息
//指定页面的完整路径,并不是url,而是页面在layout中的地址表达。
const [pathname, setPathname] = useState('/about');

//是否需要页面容器的状态变量。当单页面是是需要的。
//页面容器自带面包路径功能,单页面是不需要,只有sider时需要。
const [isPageContainer, setIsPageContainer] = useState(false);

//------------------若直接打开url,则状态保持--------------------------------------
const location = useLocation()
const path = location.pathname
useEffect(() => {
//const pathnamenew = path.slice(10,path.length) //原始路径中带有"/dashborad"字串,取消
const pathnamenew = path
if (pathnamenew.length == 0) { //当直接打开"http://ip"时
setPathname('/about')
setIsPageContainer(false)
} else { //当直接打开"http://ip/xxx"时
//由于menu2有sider菜单,需面包宵。因此需识别面包宵状态。
if (pathnamenew.slice(1,6) == "menu2") {
setIsPageContainer(true)
} else {
setIsPageContainer(false)
}
setPathname(pathnamenew)
}
},
[] //触发条件:页面直接url方式加载或关闭时。
);

//------------------导航菜单单击事件--------------------------------------
function onMenuItemRender(item:any, dom:any){
//console.log(item)
//console.log(dom)
return(
<a
onClick={() => {
//console.log(item)
setPathname(item.path || '/home');
switch (item.key) {
case "home":
setIsPageContainer(false)
navigate("/home");
break;
case "about":
setIsPageContainer(false)
navigate("/about");
break;
case "menu1":
setIsPageContainer(false)
navigate("/menu1");
break;
case "menu2":
setIsPageContainer(true)
navigate("/menu2");
break;
case "menu21":
setIsPageContainer(true)
navigate("/menu2/menu21");
break;
case "menu22":
setIsPageContainer(true)
navigate("/menu2/menu22");
break;
case "menu23":
setIsPageContainer(true)
navigate("/menu2/menu23");
break;
case "menu231":
setIsPageContainer(true)
navigate("/menu2/menu23/menu231");
break;
case "menu232":
setIsPageContainer(true)
navigate("/menu2/menu23/menu232");
break;
case "userconf":
setIsPageContainer(false)
navigate("/userconf");
break;
}
}}
>
{dom}
</a>
)

}

//------------------当前用户下接菜单--------------------------------------
//单击用户菜单
function onUserConf(e:any) {
console.log('click', e);
switch (e.key) {
case "userconf":
setIsPageContainer(false)
navigate("/userconf");
break;
case "logout":
setIsPageContainer(false)
navigate("/login");
break;
}
}
//当前用户下拉菜单渲染
const avatarPropsRender = (props:any, dom:any) => {
//console.log(props) //通过console口打印出props有哪些参数
if (props.isMobile) return [];
return (
<Dropdown
menu={{
onClick: onUserConf, //单击事件
items: Useritems, //下拉菜单条目
}}
>
{dom}
</Dropdown>
)
}

//------------------side底部的footer--------------------------------------
const menuFooterRender = (props:any) => {
//console.log(props) //通过console口打印出props有哪些参数
if (props?.collapsed) return undefined;
return (
<div
style={{
textAlign: 'center',
paddingBlockStart: 12,
}}
>
<div>test</div>
<div>by guo-fs.com</div>
</div>
);
}

//脚本文字
const line1Text = <><div style={{'color':'red','display':'inline'}} >test</div></>
const line2Text = <><div style={{'display':'inline'}} >by guo-fs.com</div></>

//------------------渲染--------------------------------------
return (<>
<ProConfigProvider dark={true}>
<ProLayout
//导入外部配置
{...defaultProps}

//title配置
//title="Darry"

//logo
logo={logoPNG} //图片路径: project_root/public/

//title与logo渲染
//在此可指定单击logo或title时的跳转
headerTitleRender={(logo, title, _) => {
const defaultDom = (
<a href="/about">
<img src={logoPNG} className="App-logo" />
{title}
</a>
);
if (typeof window === 'undefined') return defaultDom;
if (document.body.clientWidth < 1400) {
return defaultDom;
}
if (_.isMobile) return defaultDom;
return (
<>
{defaultDom}
</>
);
}}
//单击logo或title时的的事件
/*
onMenuHeaderClick={(e) => {
console.log(e)
navigate("/");
}}
*/

//sider宽度
siderWidth={216}

//菜单布局方式
layout="mix" //layout 的菜单模式,side | top | mix
splitMenus={true} //仅当 layout="mix" 有效
//fixSiderbar={true} //是否固定导航

//当前用户图示
avatarProps={{
src: 'https://gw.alipayobjects.com/zos/antfincdn/efFD%24IOql2/weixintupian_20170331104822.jpg',
size: 'small',
title: 'guofs',
render: avatarPropsRender,
}}

//当前应用会话的位置信息。如果你的应用创建了自定义的 history,则需要显示指定 location 属性,
location={{
//pathname: '/dashborad/menu1', //定义默认显示的菜单或页面。若定义默认页面,则子菜单无法显示
pathname,
}}

//使用 IconFont 的图标配置.
iconfontUrl="//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js"

//自定义菜单列表。
//此功能可以实现动态路由,用来渲染访问路由
//menuDataRender={() =>MenuItemList}
//{...menuList}
//route={{routes:RouterListProLayout}}
route={{routes:RouterListProLayout}}

//menuDataRender={() => loopMenuItem(route.routes)}
//menuDataRender={() => (props.routes)}

//自定义菜单项的 render 方法,单击事件
menuItemRender={onMenuItemRender}


//onPageChange={(location)=>{
// console.log("页面切换事件",location)
//</ProConfigProvider>}}

//内容区样式
/*
contentStyle={{
'border': '1px dashed rgb(255, 0, 55)',
//'height': 'calc(inherit - 200px)'
}}
*/
//layout 的内容模式,Fluid:自适应,Fixed:定宽 1200px
contentWidth="Fluid"

//脚本配置,有bug。
// - 在contentStyle中配置height时,若页面过大,显示内容会超过页脚信息。
// - 当内容过少时,不会沉底
/*
footerRender={() => (
<DefaultFooter
style={{
'border': '1px solid rgb(0, 0, 255)',
//'height': '60px',
//'marginTop': '-80px',
//'padding': '-100px',
//'marginBottom': '5px',
}}

links={[
{ key: 'test', title: 'layout', href: 'www.alipay.com' },
{ key: 'test2', title: 'layout2', href: 'www.alipay.com' },
]}
copyright="这是一条测试文案"
/>
)}
*/

//side底部的footer
menuFooterRender={menuFooterRender}
>
{
//菜单为sider时,需要PageContainer,它自带了面包路径。否则不需要。
//菜单为sider时,页面底部不配置页脚。在sider底部配置页脚。
isPageContainer ?
(
<PageContainer
style={{
//'border': '1px dashed rgb(255, 0, 55)',
}}
>
<Outlet />
</PageContainer>

)
: (
//采用flex方式,将页脚显示在底部。
<div style={{
//'border': '1px solid rgb(255, 0, 55)',
'display': 'flex',
'flexDirection':'column',
'justifyContent':'space-between',
'height':'calc(100vh - 60px)', //'height':'calc(100vh - 60px)'
'marginTop': '-40px',
'marginBottom': '-80px',
}}>
{/* 嵌套路由显示区 */}
<Outlet />
{/* 页脚 */}
<div style={{
//'border': '1px solid rgb(255, 0, 55)',
'display':'flex',
//'marginBottom': '-40px',
'height': '60px',
'width':'100%',
}}>
<div style={{
//'border': '1px solid rgb(255, 255, 55)',
'margin':'auto',
//'marginBottom': '5px',
'textAlign':'center',
'fontSize':'16px'
}}>
{line1Text}<br/>{line2Text}
</div>
</div>
</div>
)
}

</ProLayout>
</ProConfigProvider>
</>)
}

export default BasicLayout

页面代码

普通页面,如下

import React from 'react';
export default () => {
return <>
<h2>这是 about 页面</h2>
</>
}

菜单页面中若有子菜单,需路由嵌套,如下

import React from 'react';
import { Outlet } from "umi";

export default () => {
return <>
<Outlet />
</>
}