網(wǎng)站首頁(yè) 編程語(yǔ)言 正文
前言
你是否經(jīng)歷過(guò)公司的產(chǎn)品和 ui 要求左側(cè)菜單欄要改成設(shè)計(jì)圖上的樣子? 苦惱 antd-pro 強(qiáng)綁定的 pro-layout 菜單欄不能自定義?你可以使用 umi,但是就要根據(jù)它的約定來(lái)開(kāi)發(fā),捆綁全家桶等等。手把手教你搭一個(gè)輕量級(jí)的后臺(tái)模版,包括路由的權(quán)限、動(dòng)態(tài)菜單等等。
為方便使用 antd 組件庫(kù),你可以改成任意你喜歡的。數(shù)據(jù)請(qǐng)求的管理使用 react-query
,類(lèi)似 useRequest
,但是更加將大。樣式使用 tailwindcss
加 styled-components
,因?yàn)?antd v5 將使用 css in js。路由的權(quán)限和菜單管理使用 react-router-auth-plus
。。。
倉(cāng)庫(kù)地址
項(xiàng)目初始化
vite
# npm 7+ npm create vite spirit-admin -- --template react-ts
antd
tailwindcss
styled-components
react-query
axios
react-router
react-router-auth-plus (權(quán)限路由、動(dòng)態(tài)菜單解決方案) 倉(cāng)庫(kù)地址 文章地址
等等...
數(shù)據(jù)請(qǐng)求 + mock
配置 axios
設(shè)置攔截器,并在 main.ts 入口文件中引入這個(gè)文件,使其在全局生效
// src/axios.ts import axios, { AxiosError } from "axios"; import { history } from "./main"; // 設(shè)置 response 攔截器,狀態(tài)碼為 401 清除 token,并返回 login 頁(yè)面。 axios.interceptors.response.use( function (response) { return response; }, function (error: AxiosError) { if (error.response?.status === 401) { localStorage.removeItem("token"); // 在 react 組件外使用路由方法, 使用方式會(huì)在之后路由配置時(shí)講到 history.push("/login"); } return Promise.reject(error); } ); // 設(shè)置 request 攔截器,請(qǐng)求中的 headers 帶上 token axios.interceptors.request.use(function (request) { request.headers = { authorization: localStorage.getItem("token") || "", }; return request; });
配置 react-query
在 App 外層包裹 QueryClientProvider,設(shè)置默認(rèn)選項(xiàng),窗口重新聚焦時(shí)和失敗時(shí)不重新請(qǐng)求。
// App.tsx import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false, }, }, }); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <React.StrictMode> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </React.StrictMode> );
我們只有兩個(gè)請(qǐng)求,登錄和獲取當(dāng)前用戶,src 下新建 hooks 文件夾,再分別建 query、mutation 文件夾,query 是請(qǐng)求數(shù)據(jù)用的,mutation 是發(fā)起數(shù)據(jù)操作的請(qǐng)求用的。具體可以看 react-query 文檔
獲取當(dāng)前用戶接口
// src/hooks/query/useCurrentUserQuery.ts import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { queryClient } from "../../main"; // useQuery 需要唯一的 key,react-query v4 是數(shù)組格式 const currentUserQueryKey = ["currentUser"]; // 查詢當(dāng)前用戶,如果 localStorage 里沒(méi)有 token,則不請(qǐng)求 export const useCurrentUserQuery = () => useQuery(currentUserQueryKey, () => axios.get("/api/me"), { enabled: !!localStorage.getItem("token"), }); // 可以在其它頁(yè)面獲取 useCurrentUserQuery 的數(shù)據(jù) export const getCurrentUser = () => { const data: any = queryClient.getQueryData(currentUserQueryKey); return { username: data?.data.data.username, }; };
登錄接口
// src/hooks/mutation/useLoginMutation.ts import { useMutation } from "@tanstack/react-query"; import axios from "axios"; export const useLoginMutation = () => useMutation((data) => axios.post("/api/login", data));
mock
數(shù)據(jù)請(qǐng)求使用 react-query + axios, 因?yàn)橹挥袃蓚€(gè)請(qǐng)求,/login
(登錄) 和 /me
(當(dāng)前用戶),直接使用 express 本地 mock 一下數(shù)據(jù)。新建 mock 文件夾,分別建立 index.js
和 users.js
// users.js 存放兩種類(lèi)型的用戶 export const users = [ { username: "admin", password: "admin" }, { username: "employee", password: "employee" }, ];
// index.js 主文件 import express from "express"; import { users } from "./users.js"; const app = express(); const port = 3000; const router = express.Router(); // 登錄接口,若成功返回 token,這里模擬 token 只有兩種情況 router.post("/login", (req, res) => { setTimeout(() => { const username = req.body.username; const password = req.body.password; const user = users.find((user) => user.username === username); if (user && password === user.password) { res.status(200).json({ code: 0, token: user.username === "admin" ? "admin-token" : "employee-token", }); } else { res.status(200).json({ code: -1, message: "用戶名或密碼錯(cuò)誤" }); } }, 2000); }); // 當(dāng)前用戶接口,請(qǐng)求時(shí)需在 headers 中帶上 authorization,若不正確返回 401 狀態(tài)碼。根據(jù)用戶類(lèi)型返回權(quán)限和用戶名 router.get("/me", (req, res) => { setTimeout(() => { const token = req.headers.authorization; if (!["admin-token", "employee-token"].includes(token)) { res.status(401).json({ code: -1, message: "請(qǐng)登錄" }); } else { const auth = token === "admin-token" ? ["application", "setting"] : []; const username = token === "admin-token" ? "admin" : "employee"; res.status(200).json({ code: 0, data: { auth, username } }); } }, 2000); }); app.use(express.json()); // 接口前綴統(tǒng)一加上 /api app.use("/api", router); // 禁用 304 緩存 app.disable("etag"); app.listen(port, () => { console.log(`Example app listening on port ${port}`); });
在 package.json
中的 scripts
添加一條 mock 命令,需安裝 nodemon,用來(lái)熱更新 mock 文件的。npm run mock
啟動(dòng) express 服務(wù)。
"scripts": { ... "mock": "nodemon mock/index.js" }
現(xiàn)在在項(xiàng)目中還不能使用,需要在 vite 中配置 proxy 代理
// vite.config.ts export default defineConfig({ plugins: [react()], server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true, }, }, }, });
路由權(quán)限配置
路由和權(quán)限這塊使用的方案是 react-router-auth-plus,具體介紹見(jiàn)上篇
路由文件
新建一個(gè) router.tsx,引入頁(yè)面文件,配置項(xiàng)目所用到的所有路由,配置上權(quán)限。這里我們擴(kuò)展一下 AuthRouterObject
類(lèi)型,自定義一些參數(shù),例如左側(cè)菜單的 icon、name 等。設(shè)置上 /account/center
和 /application
路由需要對(duì)應(yīng)的權(quán)限。
import { AppstoreOutlined, HomeOutlined, UserOutlined, } from "@ant-design/icons"; import React from "react"; import { AuthRouterObject } from "react-router-auth-plus"; import { Navigate } from "react-router-dom"; import BasicLayout from "./layouts/BasicLayout"; import Application from "./pages/application"; import Home from "./pages/home"; import Login from "./pages/login"; import NotFound from "./pages/404"; import Setting from "./pages/account/setting"; import Center from "./pages/account/center"; export interface MetaRouterObject extends AuthRouterObject { name?: string; icon?: React.ReactNode; hideInMenu?: boolean; hideChildrenInMenu?: boolean; children?: MetaRouterObject[]; } // 只需在需要權(quán)限的路由配置 auth 即可 export const routers: MetaRouterObject[] = [ { path: "/", element: <Navigate to="/home" replace /> }, { path: "/login", element: <Login /> }, { element: <BasicLayout />, children: [ { path: "/home", element: <Home />, name: "主頁(yè)", icon: <HomeOutlined />, }, { path: "/account", name: "個(gè)人", icon: <UserOutlined />, children: [ { path: "/account", element: <Navigate to="/account/center" replace />, }, { path: "/account/center", name: "個(gè)人中心", element: <Center />, }, { path: "/account/setting", name: "個(gè)人設(shè)置", element: <Setting />, // 權(quán)限 auth: ["setting"], }, ], }, { path: "/application", element: <Application />, // 權(quán)限 auth: ["application"], name: "應(yīng)用", icon: <AppstoreOutlined />, }, ], }, { path: "*", element: <NotFound /> }, ];
main.tsx
使用 HistoryRouter,在組件外可以路由跳轉(zhuǎn),這樣就可以在 axios 攔截器中引入 history 跳轉(zhuǎn)路由了。
import { createBrowserHistory } from "history"; import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"; export const history = createBrowserHistory({ window }); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <React.StrictMode> <QueryClientProvider client={queryClient}> <HistoryRouter history={history}> <App /> </HistoryRouter> </QueryClientProvider> </React.StrictMode> );
App.tsx
import { useAuthRouters } from "react-router-auth-plus"; import { routers } from "./router"; import NotAuth from "./pages/403"; import { Spin } from "antd"; import { useEffect, useLayoutEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useCurrentUserQuery } from "./hooks/query"; function App() { const navigate = useNavigate(); const location = useLocation(); // 獲取當(dāng)前用戶,localStorage 里沒(méi) token 時(shí)不請(qǐng)求 const { data, isFetching } = useCurrentUserQuery(); // 第一次進(jìn)入程序,不是 login 頁(yè)面且沒(méi)有 token,跳轉(zhuǎn)到 login 頁(yè)面 useEffect(() => { if (!localStorage.getItem("token") && location.pathname !== "/login") { navigate("/login"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 第一次進(jìn)入程序,若是 login 頁(yè)面,且 token 沒(méi)過(guò)期(code 為 0),自動(dòng)登錄進(jìn)入 home 頁(yè)面。使用 useLayoutEffect 可以避免看到先閃一下 login 頁(yè)面,再跳到 home 頁(yè)面。 useLayoutEffect(() => { if (location.pathname === "/login" && data?.data.code === 0) { navigate("/home"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.data.code]); return useAuthRouters({ // 傳入當(dāng)前用戶的權(quán)限 auth: data?.data.data.auth || [], // 若正在獲取當(dāng)前用戶,展示 loading render: (element) => isFetching ? ( <div className="flex justify-center items-center h-full"> <Spin size="large" /> </div> ) : ( element ), // 若進(jìn)入沒(méi)權(quán)限的頁(yè)面,顯示 403 頁(yè)面 noAuthElement: () => <NotAuth />, routers, }); } export default App;
頁(yè)面編寫(xiě)
login 頁(yè)面
html 省略,antd Form 表單賬號(hào)密碼輸入框和一個(gè)登錄按鈕
// src/pages/login/index.tsx const Login: FC = () => { const navigate = useNavigate(); const { mutateAsync: login, isLoading } = useLoginMutation(); // Form 提交 const handleFinish = async (values: any) => { const { data } = await login(values); if (data.code === 0) { localStorage.setItem("token", data.token); // 請(qǐng)求當(dāng)前用戶 await queryClient.refetchQueries(currentUserQueryKey); navigate("/home") message.success("登錄成功"); } else { message.error(data.message); } }; return ... };
BasicLayout
BasicLayout 這里簡(jiǎn)寫(xiě)一下,具體可以看源碼。BasicLayout 會(huì)接收到 routers,在 routers.tsx
配置的 children 會(huì)自動(dòng)傳入 routers,不需要像這樣手動(dòng)傳入<BasicLayout routers={[]} />
。Outlet
相當(dāng)于 children
,是 react-router v6
新增的。
將 routers 傳入到 Outlet
的 context 中。之后就可以在頁(yè)面中用 useOutletContext
獲取到 routers 了。
// src/layouts import { Layout } from "antd"; import { Outlet } from "react-router-dom"; import styled from "styled-components"; // 使用 styled-components 覆蓋樣式 const Header = styled(Layout.Header)` height: 48px; line-height: 48px; padding: 0 16px; `; // 同上 const Slider = styled(Layout.Sider)` .ant-layout-sider-children { display: flex; flex-direction: column; } `; interface BasicLayoutProps { routers?: MetaRouterObject[]; } const BasicLayout: FC<BasicLayoutProps> = ({ routers }) => { // 樣式省略簡(jiǎn)寫(xiě) return ( <Layout> <Header> ...頂部 </Header> <Layout hasSider> <Slider> ...左側(cè)菜單 </Slider> <Layout> <Layout.Content> <Outlet context={{ routers }} /> </Layout.Content> </Layout> </Layout> </Layout> ); };
動(dòng)態(tài)菜單欄
把左側(cè)菜單欄單獨(dú)拆分成一個(gè)組件,在 BasicLayout 中引入,傳入 routers 參數(shù)。
// src/layouts/BasicLayout/components/SliderMenu.tsx import { Menu } from "antd"; import { FC, useEffect, useState } from "react"; import { useAuthMenus } from "react-router-auth-plus"; import { useNavigate } from "react-router-dom"; import { useLocation } from "react-router-dom"; import { MetaRouterObject } from "../../../router"; import { ItemType } from "antd/lib/menu/hooks/useItems"; // 轉(zhuǎn)化成 antd Menu 組件需要的格式。只有配置了 name 和不隱藏的才展示 const getMenuItems = (routers: MetaRouterObject[]): ItemType[] => { const menuItems = routers.reduce((total: ItemType[], router) => { if (router.name && !router.hideInMenu) { total?.push({ key: router.path as string, icon: router.icon, label: router.name, children: router.children && router.children.length > 0 && !router.hideChildrenInMenu ? getMenuItems(router.children) : undefined, }); } return total; }, []); return menuItems; }; interface SlideMenuProps { routers: MetaRouterObject[]; } const SlideMenu: FC<SlideMenuProps> = ({ routers }) => { const location = useLocation(); const navigate = useNavigate(); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); // useAuthMenus 先過(guò)濾掉沒(méi)有權(quán)限的路由。再通過(guò) getMenuItems 獲得 antd Menu組件需要的格式 const menuItems = getMenuItems(useAuthMenus(routers)); // 默認(rèn)打開(kāi)的下拉菜單 const defaultOpenKey = menuItems.find((i) => location.pathname.startsWith(i?.key as string) )?.key as string; // 選中菜單 useEffect(() => { setSelectedKeys([location.pathname]); }, [location.pathname]); return ( <Menu style={{ borderRightColor: "white" }} className="h-full" mode="inline" selectedKeys={selectedKeys} defaultOpenKeys={defaultOpenKey ? [defaultOpenKey] : []} items={menuItems} {/* 選中菜單回調(diào),導(dǎo)航到其路由 */} onSelect={({ key }) => navigate(key)} /> ); }; export default SlideMenu;
封裝頁(yè)面通用面包屑
封裝一個(gè)在 BasicLayout 下全局通用的面包屑。
// src/components/PageBreadcrumb.tsx import { Breadcrumb } from "antd"; import { FC } from "react"; import { Link, matchRoutes, useLocation, useOutletContext, } from "react-router-dom"; import { MetaRouterObject } from "../router"; const PageBreadcrumb: FC = () => { const location = useLocation(); // 獲取在 BasicLayout 中傳入的 routers const { routers } = useOutletContext<{ routers: MetaRouterObject[] }>(); // 使用 react-router 的 matchRoutes 方法匹配路由數(shù)組 const match = matchRoutes(routers, location); // 處理一下生成面包屑數(shù)組 const breadcrumbs = (match || []).reduce((total: MetaRouterObject[], current) => { if ((current.route as MetaRouterObject).name) { total.push(current.route); } return total; }, []); // 最后一個(gè)面包屑不能點(diǎn)擊,前面的都能點(diǎn)擊跳轉(zhuǎn) return ( <Breadcrumb> {breadcrumbs.map((i, index) => ( <Breadcrumb.Item key={i.path}> {index === breadcrumbs.length - 1 ? ( i.name ) : ( <Link to={i.path as string}>{i.name}</Link> )} </Breadcrumb.Item> ))} </Breadcrumb> ); }; export default PageBreadcrumb;
這樣就能在頁(yè)面中引入這個(gè)組件使用了,如果你想在每個(gè)頁(yè)面中都使用,可以寫(xiě)在 BasicLayout 的 Content 中,并在 routers 配置中加一個(gè) hideBreadcrumb
選項(xiàng),通過(guò)配置來(lái)控制是否在當(dāng)前路由頁(yè)面顯示面包屑。
function Home() { return ( <div> <PageBreadcrumb /> </div> ); }
總結(jié)
react 的生態(tài)是越來(lái)越多樣化了,學(xué)的東西也越來(lái)越多(太卷了)。總的來(lái)說(shuō),上面所使用的一些庫(kù),或多或少都要有所了解。應(yīng)該都要鍛煉自己有具備能搭建一個(gè)簡(jiǎn)易版的后臺(tái)管理模版的能力?github 地址
原文鏈接:https://juejin.cn/post/7124958026196320286
相關(guān)推薦
- 2023-01-13 Android?Parcleable接口的調(diào)用源碼層分析_Android
- 2021-12-06 C/C++?Qt?數(shù)據(jù)庫(kù)與ComBox實(shí)現(xiàn)多級(jí)聯(lián)動(dòng)示例代碼_C 語(yǔ)言
- 2022-10-31 Python?NumPy隨機(jī)抽模塊介紹及方法_python
- 2022-01-31 RuntimeError: scatter(): Expected dtype int64 for
- 2022-06-12 Python批量裁剪圖形外圍空白區(qū)域_python
- 2022-10-22 React拖拽調(diào)整大小的組件_React
- 2022-11-17 Python?隊(duì)列Queue和PriorityQueue解析_python
- 2022-06-06 Postgresql split_part()函數(shù),根據(jù)符號(hào)切割字符串
- 最近更新
-
- window11 系統(tǒng)安裝 yarn
- 超詳細(xì)win安裝深度學(xué)習(xí)環(huán)境2025年最新版(
- Linux 中運(yùn)行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲(chǔ)小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎(chǔ)操作-- 運(yùn)算符,流程控制 Flo
- 1. Int 和Integer 的區(qū)別,Jav
- spring @retryable不生效的一種
- Spring Security之認(rèn)證信息的處理
- Spring Security之認(rèn)證過(guò)濾器
- Spring Security概述快速入門(mén)
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權(quán)
- redisson分布式鎖中waittime的設(shè)
- maven:解決release錯(cuò)誤:Artif
- restTemplate使用總結(jié)
- Spring Security之安全異常處理
- MybatisPlus優(yōu)雅實(shí)現(xiàn)加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務(wù)發(fā)現(xiàn)-Nac
- Spring Security之基于HttpR
- Redis 底層數(shù)據(jù)結(jié)構(gòu)-簡(jiǎn)單動(dòng)態(tài)字符串(SD
- arthas操作spring被代理目標(biāo)對(duì)象命令
- Spring中的單例模式應(yīng)用詳解
- 聊聊消息隊(duì)列,發(fā)送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠(yuǎn)程分支