前言
动态菜单和动态路由的逻辑
在登录完成之后,用useEffect监听dispatch把菜单和路由的数据初始化,渲染菜单,redux将路由的静态资源修改。
数据结构
后端数据符合前端需要的数据结构即可,mock后端接口返回数据
import Mock from 'mockjs';Mock.mock('/api', 'get', {code: 200,data:{menuLists: [{id: '@id',name: 'Layout',path: '/home/homes',icon: 'ShopOutlined',label: '首页',},{id: '@id',name: 'Publish',path: '/publish',label: '文章管理',icon: 'DesktopOutlined',children: [{id: '@id',name: 'Article',path: '/publish/article',label: '创建文章',icon: 'SignatureOutlined',children: []},{id: '@id',name: 'Mark',path: '/publish/mark',label: '标注文章',icon: 'FormOutlined',children: [{id: '@id',name: 'High',icon: 'AreaChartOutlined',path: '/publish/mark/high',label: '高亮文章',children: []}]}]},],routerLists:[{id: '@id',name: 'Layout',path: '/publish',children: [{id: '@id',name: 'Article',path: '/publish/article',children: []},{id: '@id',name: 'Mark',path: '/publish/mark',children: [{id: '@id',name: 'High',path: '/publish/mark/high',children: []}]}]},]}})
动态菜单
数据结构
antd的menu组件中有
items
属性用于显示菜单内容。需要按照数据结构才能显示interface Menu {label: string,icon: string,key: stringchildren?: Menu[] }
这里如果菜单不是多级路由把Children属性去掉,否则路由会成为一个父级路由
初始化菜单
const addMenuList = (datas) => {const menus: Menu[] = [];datas.forEach(items => {let menu: Menu = {icon: items.icon,label: items.label,key: items.path,}if (items.children && items.children.length != 0) {// 递归处理子菜单const child: Menu[] = addMenuList(items.children);// 只有在子菜单非空时,才给父菜单项添加 children 属性if (child.length > 0) {menu.children = child;}}const flag = menus.find(it => it.key === menu.key)if (!flag) {menus.push(menu)}})return menus }
初始化图标
将icon图标进行动态添加,因为存储的为不可序列化对象,控制台会报错但不影响使用,图标不会用来序列化可以忽略
import React from "react"; import * as Icons from '@ant-design/icons' import {Menu} from "@/store/routers/MyRouterStore.tsx";const iconList: any = Icons// 修改方法,避免直接修改原始数据 export function addIconToMenu(menuData: Menu[]) {// 递归处理每个菜单项return menuData.map((item: any) => {// const item = {...item}; // 创建新的对象,避免修改原始数据// 如果菜单项有 icon 属性,则创建对应的 React 元素if (item.icon) {const IconComponent = iconList[item.icon]; // 获取对应的图标组件item.icon = React.createElement(IconComponent); // 创建 React 元素}// 如果菜单项有 children 属性,则递归处理子菜单项if (item.children) {item.children = addIconToMenu(item.children); // 递归处理子菜单项}return item; // 返回更新后的菜单项}); }
渲染菜单公共组件
const location = useLocation() const navigate = useNavigate();// 获取菜单 const items = useSelector(state => state.route.menuList)//跳转路由 const menuPath = (route) => {navigate(route.key) }//也可以递归进行渲染菜单 // const renderMenuItems = (data) => {// return data.map(item => {// if (item.children && item.children.length > 0) {// // 如果有子菜单,递归渲染子菜单// return (// <SubMenu key={item.key} title={item.label} icon={item.icon}>// {renderMenuItems(item.children)}// </SubMenu>// );// } else {// // 没有子菜单的情况// return (// <Menu.Item key={item.key} icon={item.icon}>// {item.label}// </Menu.Item>// );// }// });// };//将要去的路径高亮 const lightHeight = location.pathname return( <Layout style={{minHeight: '100vh'}}><Sider collapsible><div className="demo-logo-vertical"/><Menu theme="dark" selectedKeys={[lightHeight]} mode="inline" onClick={menuPath} items={items}>{/*{renderMenuItems(items)}*/}</Menu></Sider><Layout><Header style={{padding: 0, background: colorBgContainer}}></Header><Content style={{margin: '0 16px'}}><Breadcrumb style={{margin: '16px 0'}}></Breadcrumb><Outlet></Outlet></Content><Footer style={{textAlign: 'center'}}>Ant Design ©{new Date().getFullYear()} Created by Ant UED</Footer></Layout></Layout>)
动态路由
静态路由(准备好公用的静态路由和拼接路由方法)
将后端返回数据的路径拼成页面路由,不为公共样式的可能是子菜单需要拼接
export const load = (name: string, path: string) => {let Page: React.LazyExoticComponent<React.ComponentType<any>>if (name !== 'Layout') {// 将路径字符串按 '/' 分割成数组const parts = path.split('/');// 遍历数组,对每个路径部分进行首字母大写处理const capitalizedParts = parts.map(part => {if (part.length > 0) {// 将单词的首字母大写,再加上剩余部分return part.charAt(0).toUpperCase() + part.slice(1);} else {return part; // 对于空字符串部分保持不变}});// 将处理后的路径部分拼接回字符串,使用 '/' 连接const capitalizedPath = capitalizedParts.join('/');console.log('capitalizedPath', capitalizedPath)Page = lazy(() => import(`../pages${capitalizedPath}`))} else {Page = lazy(() => import(`../pages/${name}`))}return (<Suspense fallback={'等待中'}><AuthRoute><Page></Page></AuthRoute></Suspense>) }
静态路由
const LazyHome=lazy(()=>import(`@/pages/Home`)) export let routerLists = [{path: '/login',element:<Suspense fallback={'等待中'}><Login/></Suspense>},{path: '/',element: <Navigate to={'/home/homes'}/>,},{path: '/home',element: <AuthRoute><Lay/></AuthRoute>,children: [{path: 'homes',element: <Suspense fallback={'等待中'}><LazyHome /></Suspense>,}]}, ]
动态路由初始化(递归方法与菜单递归相似)
interface Router {path: stringchildren?: Router[],element: any }const addRouterList = (routers) => {const routerLis: Router[] = []routers.forEach(items => {let route: Router = {path: items.path,element: load(items.name, items.path),}if (items.children && items.children.length != 0) {// 递归处理子菜单const child: Router[] = addRouterList(items.children);// 只有在子菜单非空时,才给父菜单项添加 children 属性if (child.length > 0) {route.children = child;}}const flag = routerLis.find(it => it.path === route.path)if (!flag) {routerLis.push(route)}})return routerLis }
判断固定静态路由长度避免重复添加路由
const info = (routers) => {if (routerLists.length==3){routers.forEach(item=>{routerLists.push(item)}) } }
在菜单公共组件中监听dispatch的变化,修改动态路由
const dispatch = useDispatch()useEffect(() => {dispatch(getRouterList())}, [dispatch])
将动态路由渲染出来
这里的两个监听,
1
防止第一次渲染吧路由清空,之后当redux的路由变化时修改路由列表,2
浏览器刷新时防止白屏没路由,判断是否只有静态路由function Routes() {const router = useSelector(state => state.route.routerList)const dispatch= useDispatch()//监听路由进行修改useEffect(() => {if (router.length>0){setRouterList(router)}}, [router]);//防止白屏没路由useEffect(() => {if (routerLists.length==3){dispatch(getRouterList())}}, []);console.log('routerLists',routerLists)const element = useRoutes(routerLists)return <>{element}</> }
ReactDOM.createRoot(document.getElementById('root')!).render(<Provider store={store}><BrowserRouter><Routes/></BrowserRouter></Provider> )
用到的工具
判断是否存在Token组件
包裹组件后,当组件不存在token会被强制跳转登录页面
import {getToken} from "@/utils"; import {Navigate} from "react-router-dom";function getToken() {return sessionStorage.getItem("token") }function setToken(token: string) {sessionStorage.setItem("token", token) }function removeToken() {sessionStorage.removeItem("token") }//组件前判断是否有token export default function AuthRoute({children}) {const token= getToken()if (token){return <>{children}</>}else {return <Navigate to={'/login'} replace/>} }
遇到的问题
- 白屏问题
- 在路由文件使用懒加载必须使用Suspense标签进行包裹,否则在登录进来会白屏。如果不使用标签包裹则引用组件
- 在路由树中必须要监听一下路由列表是否有动态加载过,否则刷新页面后就会白屏
- 修改路由
- 修改路由列表前,要判断是否存在动态新增的路由,避免重复修改。也避免清空路由列表
完整代码
src ├─ App.tsx ├─ apis │ ├─ article.ts │ ├─ mock.ts │ └─ user.ts ├─ components │ └─ AuthRoute.tsx ├─ hooks │ └─ useChannel.ts ├─ index.scss ├─ main.tsx ├─ pages │ ├─ Home │ │ ├─ component │ │ │ └─ Barschar.tsx │ │ └─ index.tsx │ ├─ Layout │ │ ├─ index.scss │ │ └─ index.tsx │ ├─ Login │ │ ├─ index.tsx │ │ └─ login.scss │ └─ Publish │ ├─ Article │ │ ├─ index.scss │ │ └─ index.tsx │ ├─ Mark │ │ ├─ High │ │ │ └─ index.tsx │ │ └─ index.tsx │ ├─ index.tsx │ └─ inter.ts ├─ router │ ├─ index.tsx │ └─ routerList.tsx ├─ store │ ├─ index.tsx │ ├─ routers │ │ └─ MyRouterStore.tsx │ └─ users │ └─ index.ts ├─ utils │ ├─ TokenUtil.ts │ ├─ icon.ts │ ├─ index.ts │ └─ request.ts
AuthRoute.tsx
import {getToken} from "@/utils"; import {Navigate} from "react-router-dom";//组件前判断是否有token export default function AuthRoute({children}) {const token= getToken()if (token){return <>{children}</>}else {return <Navigate to={'/login'} replace/>} }
main.tsx
import React from 'react' import ReactDOM from 'react-dom/client' import './index.scss' import {BrowserRouter} from "react-router-dom"; import {Provider} from "react-redux"; import store from "@/store"; import { ConfigProvider } from 'antd'; import zh_CN from 'antd/locale/zh_CN'; import '@/apis/mock.ts' import Routes from "@/router";ReactDOM.createRoot(document.getElementById('root')!).render(<ConfigProvider locale={zh_CN}><Provider store={store}><BrowserRouter><Routes/></BrowserRouter></Provider></ConfigProvider> )
Layout(index.tsx)
import React, {useEffect, useState} from 'react';import {MenuProps, Popover} from 'antd'; import './index.scss' import {Breadcrumb, Layout as Lay, Menu, theme} from 'antd'; import {Outlet, useLocation, useNavigate} from "react-router-dom"; import {useDispatch, useSelector} from "react-redux"; import {fetchUserInfo} from "@/store/users"; import {removeToken} from "@/utils"; import {getRouterList} from "@/store/routers/MyRouterStore.tsx";const {Header, Content, Footer, Sider} = Lay;const Layout: React.FC = () => {const dispatch = useDispatch()const [collapsed, setCollapsed] = useState(false);const location = useLocation()const navigate = useNavigate();const [open, setOpen] = useState(false)const [title, setTitle] = useState([])const {token: {colorBgContainer, borderRadiusLG},} = theme.useToken();const items = useSelector(state => state.route.menuList)//跳转路由const menuPath = (route) => {navigate(route.key)}// const renderMenuItems = (data) => {// return data.map(item => {// if (item.children && item.children.length > 0) {// // 如果有子菜单,递归渲染子菜单// return (// <SubMenu key={item.key} title={item.label} icon={item.icon}>// {renderMenuItems(item.children)}// </SubMenu>// );// } else {// // 没有子菜单的情况// return (// <Menu.Item key={item.key} icon={item.icon}>// {item.label}// </Menu.Item>// );// }// });// };useEffect(() => {dispatch(fetchUserInfo())dispatch(getRouterList())}, [dispatch])//获取个人信息const userName = useSelector(state => state.user.userInfo.name)//将要去的路径const lightHeight = location.pathnamereturn (<Lay style={{minHeight: '100vh'}}><Sider collapsible><div className="demo-logo-vertical"/><Menu theme="dark" selectedKeys={[lightHeight]} mode="inline" onClick={menuPath} items={items}>{/*{renderMenuItems(items)}*/}</Menu></Sider><Lay><Header style={{padding: 0, background: colorBgContainer}}></Header><Content style={{margin: '0 16px'}}><Outlet></Outlet></Content><Footer style={{textAlign: 'center'}}>Ant Design ©{new Date().getFullYear()} Created by Ant UED</Footer></Lay></Lay>); };export default Layout;
Login(index.tsx)
import React from 'react'; import {SafetyOutlined, UserOutlined} from '@ant-design/icons'; import {Button, Form, Input, message} from 'antd'; import './login.scss' import {useDispatch} from "react-redux"; import {fetchLogin} from "@/store/users"; import {useNavigate} from "react-router-dom";const Login: React.FC = () => {const dispatch = useDispatch()const navigate = useNavigate()const onFinish = async (values: any) => {//进行登录await dispatch(fetchLogin(values))// 完成跳转navigate('/')message.success("登录成功")};return (<div className="login"><Formname="normal_login"className="login-form"initialValues={{remember: true}}onFinish={onFinish}validateTrigger={'onBlur'}><div className="in"><span>登录</span></div><Form.Itemname="mobile"rules={[{required: true, message: '请输入账号!'}]}><Input style={{width: '400px'}} size={"large"} prefix={<UserOutlined/>} placeholder="账号"/></Form.Item><Form.Itemname="code"rules={[{required: true, message: '请输入密码!'}]}><Inputstyle={{width: '400px'}}size={"large"}prefix={<SafetyOutlined/>}type="password"placeholder="密码"/></Form.Item><Form.Item><Button size={"large"} type="primary" htmlType="submit" className="login-form-button">登录</Button></Form.Item></Form></div>); };export default Login;
router
index.tsx
import React, {useEffect} from "react" import {useRoutes} from "react-router-dom" import {routerLists, setRouterList} from "@/router/routerList.tsx"; import {useDispatch, useSelector} from "react-redux"; import {getRouterList} from "@/store/routers/MyRouterStore.tsx";function Routes() {const router = useSelector(state => state.route.routerList)const dispatch= useDispatch()useEffect(() => {if (router.length>0){console.log('router',router)setRouterList(router)}}, [router]);useEffect(() => {if (routerLists.length==3){dispatch(getRouterList())}}, []);console.log('routerLists',routerLists)const element = useRoutes(routerLists)return <>{element}</> }export default Routes
routerList.tsx
import {Navigate} from "react-router-dom"; import AuthRoute from "@/components/AuthRoute.tsx"; import React, {lazy, Suspense} from "react"; import Login from "@/pages/Login"; import Layout from "@/pages/Layout";export const load = (name: string, path: string) => {let Page: React.LazyExoticComponent<React.ComponentType<any>>if (name !== 'Layout') {// 将路径字符串按 '/' 分割成数组const parts = path.split('/');// 遍历数组,对每个路径部分进行首字母大写处理const capitalizedParts = parts.map(part => {if (part.length > 0) {// 将单词的首字母大写,再加上剩余部分return part.charAt(0).toUpperCase() + part.slice(1);} else {return part; // 对于空字符串部分保持不变}});// 将处理后的路径部分拼接回字符串,使用 '/' 连接const capitalizedPath = capitalizedParts.join('/');console.log('capitalizedPath', capitalizedPath)Page = lazy(() => import(`../pages${capitalizedPath}`))} else {Page = lazy(() => import(`../pages/${name}`))}return (<Suspense fallback={'等待中'}><AuthRoute><Page></Page></AuthRoute></Suspense>) }const LazyHome=lazy(()=>import(`@/pages/Home`)) export let routerLists = [{path: '/login',element:<Suspense fallback={'等待中'}><Login/></Suspense>},{path: '/',element: <Navigate to={'/home/homes'}/>,},{path: '/home',// lazy:()=>import("@/pages/Home")element: <AuthRoute><Layout/></AuthRoute>,children: [{path: 'homes',element:<Suspense fallback={'等待中'}><LazyHome /></Suspense>}]}, ]export const setRouterList = (data) => {routerLists = data }
store
MyRouterStore.tsx
import {createSlice} from "@reduxjs/toolkit"; import {user} from "@/apis/user.ts";import {addIconToMenu} from "@/utils"; import {load, routerLists} from "@/router/routerList.tsx";export interface Menu {label: string,icon: string,key: stringchildren?: Menu[] }interface Router {path: stringchildren?: Router[],element: any }const useStore = createSlice({name: 'route',initialState: {routerList: [] as Router[], //动态路由数组menuList: [] as Menu[] //菜单数组},reducers: {setRouterList(state, action) {state.routerList = action.payload},setMenuList(state, action) {state.menuList = action.payload}} })const {setRouterList, setMenuList} = useStore.actionsconst useRouterReducer = useStore.reducerconst getRouterList = () => {return async (dispatch) => {const data = await user.routerList()const menus: Menu[] = addMenuList(data.data.menuLists)addIconToMenu(menus)const routers = addRouterList(data.data.routerLists)info(routers)dispatch(setMenuList(menus))dispatch(setRouterList(routerLists))} }const addMenuList = (datas) => {const menus: Menu[] = [];datas.forEach(items => {let menu: Menu = {icon: items.icon,label: items.label,key: items.path,}if (items.children && items.children.length != 0) {// 递归处理子菜单const child: Menu[] = addMenuList(items.children);// 只有在子菜单非空时,才给父菜单项添加 children 属性if (child.length > 0) {menu.children = child;}}const flag = menus.find(it => it.key === menu.key)if (!flag) {menus.push(menu)}})return menus }const addRouterList = (routers) => {const routerLis: Router[] = []routers.forEach(items => {let route: Router = {path: items.path,element: load(items.name, items.path),}if (items.children && items.children.length != 0) {// 递归处理子菜单const child: Router[] = addRouterList(items.children);// 只有在子菜单非空时,才给父菜单项添加 children 属性if (child.length > 0) {route.children = child;}}const flag = routerLis.find(it => it.path === route.path)if (!flag) {routerLis.push(route)}})return routerLis }const info = (routers) => {if (routerLists.length == 3) {routers.forEach(item => {routerLists.push(item)})}}export {setRouterList,setMenuList,getRouterList } export default useRouterReducer
index.ts
import {configureStore} from "@reduxjs/toolkit"; import userReducer from "@/store/users"; import useRouterReducer from "@/store/routers/MyRouterStore.tsx";export default configureStore({reducer: {user: userReducer,route:useRouterReducer}, })
utils
icon.ts
import React from "react"; import * as Icons from '@ant-design/icons' import {Menu} from "@/store/routers/MyRouterStore.tsx";const iconList: any = Icons// 修改方法,避免直接修改原始数据 export function addIconToMenu(menuData: Menu[]) {// 递归处理每个菜单项return menuData.map((item: any) => {// const item = {...item}; // 创建新的对象,避免修改原始数据// 如果菜单项有 icon 属性,则创建对应的 React 元素if (item.icon) {const IconComponent = iconList[item.icon]; // 获取对应的图标组件item.icon = React.createElement(IconComponent); // 创建 React 元素}// 如果菜单项有 children 属性,则递归处理子菜单项if (item.children) {item.children = addIconToMenu(item.children); // 递归处理子菜单项}return item; // 返回更新后的菜单项}); }