react最流行的生態替代antdpro搭建輕量級後臺管理
前言
你是否經歷過公司的產品和 ui 要求左側菜單欄要改成設計圖上的樣子? 苦惱 antd-pro 強綁定的 pro-layout 菜單欄不能自定義?你可以使用 umi,但是就要根據它的約定來開發,捆綁全傢桶等等。手把手教你搭一個輕量級的後臺模版,包括路由的權限、動態菜單等等。
為方便使用 antd 組件庫,你可以改成任意你喜歡的。數據請求的管理使用 react-query
,類似 useRequest
,但是更加將大。樣式使用 tailwindcss
加 styled-components
,因為 antd v5 將使用 css in js。路由的權限和菜單管理使用 react-router-auth-plus
。。。
倉庫地址
項目初始化
vite
# npm 7+ npm create vite spirit-admin -- --template react-ts
antd
tailwindcss
styled-components
react-query
axios
react-router
react-router-auth-plus (權限路由、動態菜單解決方案) 倉庫地址 文章地址
等等…
數據請求 + mock
配置 axios
設置攔截器,並在 main.ts 入口文件中引入這個文件,使其在全局生效
// src/axios.ts import axios, { AxiosError } from "axios"; import { history } from "./main"; // 設置 response 攔截器,狀態碼為 401 清除 token,並返回 login 頁面。 axios.interceptors.response.use( function (response) { return response; }, function (error: AxiosError) { if (error.response?.status === 401) { localStorage.removeItem("token"); // 在 react 組件外使用路由方法, 使用方式會在之後路由配置時講到 history.push("/login"); } return Promise.reject(error); } ); // 設置 request 攔截器,請求中的 headers 帶上 token axios.interceptors.request.use(function (request) { request.headers = { authorization: localStorage.getItem("token") || "", }; return request; });
配置 react-query
在 App 外層包裹 QueryClientProvider,設置默認選項,窗口重新聚焦時和失敗時不重新請求。
// 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> );
我們隻有兩個請求,登錄和獲取當前用戶,src 下新建 hooks 文件夾,再分別建 query、mutation 文件夾,query 是請求數據用的,mutation 是發起數據操作的請求用的。具體可以看 react-query 文檔
獲取當前用戶接口
// src/hooks/query/useCurrentUserQuery.ts import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { queryClient } from "../../main"; // useQuery 需要唯一的 key,react-query v4 是數組格式 const currentUserQueryKey = ["currentUser"]; // 查詢當前用戶,如果 localStorage 裡沒有 token,則不請求 export const useCurrentUserQuery = () => useQuery(currentUserQueryKey, () => axios.get("/api/me"), { enabled: !!localStorage.getItem("token"), }); // 可以在其它頁面獲取 useCurrentUserQuery 的數據 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
數據請求使用 react-query + axios, 因為隻有兩個請求,/login
(登錄) 和 /me
(當前用戶),直接使用 express 本地 mock 一下數據。新建 mock 文件夾,分別建立 index.js
和 users.js
// users.js 存放兩種類型的用戶 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: "用戶名或密碼錯誤" }); } }, 2000); }); // 當前用戶接口,請求時需在 headers 中帶上 authorization,若不正確返回 401 狀態碼。根據用戶類型返回權限和用戶名 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: "請登錄" }); } 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()); // 接口前綴統一加上 /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,用來熱更新 mock 文件的。npm run mock
啟動 express 服務。
"scripts": { ... "mock": "nodemon mock/index.js" }
現在在項目中還不能使用,需要在 vite 中配置 proxy 代理
// vite.config.ts export default defineConfig({ plugins: [react()], server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true, }, }, }, });
路由權限配置
路由和權限這塊使用的方案是 react-router-auth-plus,具體介紹見上篇
路由文件
新建一個 router.tsx,引入頁面文件,配置項目所用到的所有路由,配置上權限。這裡我們擴展一下 AuthRouterObject
類型,自定義一些參數,例如左側菜單的 icon、name 等。設置上 /account/center
和 /application
路由需要對應的權限。
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[]; } // 隻需在需要權限的路由配置 auth 即可 export const routers: MetaRouterObject[] = [ { path: "/", element: <Navigate to="/home" replace /> }, { path: "/login", element: <Login /> }, { element: <BasicLayout />, children: [ { path: "/home", element: <Home />, name: "主頁", icon: <HomeOutlined />, }, { path: "/account", name: "個人", icon: <UserOutlined />, children: [ { path: "/account", element: <Navigate to="/account/center" replace />, }, { path: "/account/center", name: "個人中心", element: <Center />, }, { path: "/account/setting", name: "個人設置", element: <Setting />, // 權限 auth: ["setting"], }, ], }, { path: "/application", element: <Application />, // 權限 auth: ["application"], name: "應用", icon: <AppstoreOutlined />, }, ], }, { path: "*", element: <NotFound /> }, ];
main.tsx
使用 HistoryRouter,在組件外可以路由跳轉,這樣就可以在 axios 攔截器中引入 history 跳轉路由瞭。
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(); // 獲取當前用戶,localStorage 裡沒 token 時不請求 const { data, isFetching } = useCurrentUserQuery(); // 第一次進入程序,不是 login 頁面且沒有 token,跳轉到 login 頁面 useEffect(() => { if (!localStorage.getItem("token") && location.pathname !== "/login") { navigate("/login"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 第一次進入程序,若是 login 頁面,且 token 沒過期(code 為 0),自動登錄進入 home 頁面。使用 useLayoutEffect 可以避免看到先閃一下 login 頁面,再跳到 home 頁面。 useLayoutEffect(() => { if (location.pathname === "/login" && data?.data.code === 0) { navigate("/home"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.data.code]); return useAuthRouters({ // 傳入當前用戶的權限 auth: data?.data.data.auth || [], // 若正在獲取當前用戶,展示 loading render: (element) => isFetching ? ( <div className="flex justify-center items-center h-full"> <Spin size="large" /> </div> ) : ( element ), // 若進入沒權限的頁面,顯示 403 頁面 noAuthElement: () => <NotAuth />, routers, }); } export default App;
頁面編寫
login 頁面
html 省略,antd Form 表單賬號密碼輸入框和一個登錄按鈕
// 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); // 請求當前用戶 await queryClient.refetchQueries(currentUserQueryKey); navigate("/home") message.success("登錄成功"); } else { message.error(data.message); } }; return ... };
BasicLayout
BasicLayout 這裡簡寫一下,具體可以看源碼。BasicLayout 會接收到 routers,在 routers.tsx
配置的 children 會自動傳入 routers,不需要像這樣手動傳入<BasicLayout routers={[]} />
。Outlet
相當於 children
,是 react-router v6
新增的。
將 routers 傳入到 Outlet
的 context 中。之後就可以在頁面中用 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 }) => { // 樣式省略簡寫 return ( <Layout> <Header> ...頂部 </Header> <Layout hasSider> <Slider> ...左側菜單 </Slider> <Layout> <Layout.Content> <Outlet context={{ routers }} /> </Layout.Content> </Layout> </Layout> </Layout> ); };
動態菜單欄
把左側菜單欄單獨拆分成一個組件,在 BasicLayout 中引入,傳入 routers 參數。
// 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"; // 轉化成 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 先過濾掉沒有權限的路由。再通過 getMenuItems 獲得 antd Menu組件需要的格式 const menuItems = getMenuItems(useAuthMenus(routers)); // 默認打開的下拉菜單 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} {/* 選中菜單回調,導航到其路由 */} onSelect={({ key }) => navigate(key)} /> ); }; export default SlideMenu;
封裝頁面通用面包屑
封裝一個在 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 方法匹配路由數組 const match = matchRoutes(routers, location); // 處理一下生成面包屑數組 const breadcrumbs = (match || []).reduce((total: MetaRouterObject[], current) => { if ((current.route as MetaRouterObject).name) { total.push(current.route); } return total; }, []); // 最後一個面包屑不能點擊,前面的都能點擊跳轉 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;
這樣就能在頁面中引入這個組件使用瞭,如果你想在每個頁面中都使用,可以寫在 BasicLayout 的 Content 中,並在 routers 配置中加一個 hideBreadcrumb
選項,通過配置來控制是否在當前路由頁面顯示面包屑。
function Home() { return ( <div> <PageBreadcrumb /> </div> ); }
總結
react 的生態是越來越多樣化瞭,學的東西也越來越多(太卷瞭)。總的來說,上面所使用的一些庫,或多或少都要有所瞭解。應該都要鍛煉自己有具備能搭建一個簡易版的後臺管理模版的能力 github 地址
以上就是react最流行的生態替代antdpro搭建輕量級後臺管理的詳細內容,更多關於react生態輕量級後臺管理的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- React-Router(V6)的權限控制實現示例
- react-router v6實現動態路由實例
- Vue實現用戶登錄及token驗證
- 手把手教你從零開始react+antd搭建項目
- 使用React Router v6 添加身份驗證的方法