React Router、And、Redux動態選單和動態路由

Kang_kin發表於2024-05-05

前言

動態選單和動態路由的邏輯

在登入完成之後,用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: string
   children?: 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: string
   children?: 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/>
   }
}

遇到的問題

  1. 白屏問題
  • 在路由檔案使用懶載入必須使用Suspense標籤進行包裹,否則在登入進來會白屏。如果不使用標籤包裹則引用元件
  • 在路由樹中必須要監聽一下路由列表是否有動態載入過,否則重新整理頁面後就會白屏
  1. 修改路由
  • 修改路由列表前,要判斷是否存在動態新增的路由,避免重複修改。也避免清空路由列表

完整程式碼

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.pathname

   return (
       <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">
           <Form
               name="normal_login"
               className="login-form"
               initialValues={{remember: true}}
               onFinish={onFinish}
               validateTrigger={'onBlur'}
           >
               <div className="in">
                   <span>登入</span>
               </div>
               <Form.Item
                   name="mobile"
                   rules={[{required: true, message: '請輸入賬號!'}]}
               >
                   <Input style={{width: '400px'}} size={"large"} prefix={<UserOutlined/>} placeholder="賬號"/>
               </Form.Item>
               <Form.Item
                   name="code"
                   rules={[{required: true, message: '請輸入密碼!'}]}
               >
                   <Input
                       style={{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: string
   children?: Menu[]
}

interface Router {
   path: string
   children?: 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.actions

const useRouterReducer = useStore.reducer

const 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; // 返回更新後的選單項
   });
}

執行截圖

相關文章