react最流行的生態替代antdpro搭建輕量級後臺管理

前言

你是否經歷過公司的產品和 ui 要求左側菜單欄要改成設計圖上的樣子? 苦惱 antd-pro 強綁定的 pro-layout 菜單欄不能自定義?你可以使用 umi,但是就要根據它的約定來開發,捆綁全傢桶等等。手把手教你搭一個輕量級的後臺模版,包括路由的權限、動態菜單等等。

為方便使用 antd 組件庫,你可以改成任意你喜歡的。數據請求的管理使用 react-query,類似 useRequest,但是更加將大。樣式使用 tailwindcssstyled-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.jsusers.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其它相關文章!

推薦閱讀: