前言
動態選單和動態路由的邏輯
在登入完成之後,用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/> } }
遇到的問題
- 白屏問題
- 在路由檔案使用懶載入必須使用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.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; // 返回更新後的選單項 }); }