日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學(xué)無(wú)先后,達(dá)者為師

網(wǎng)站首頁(yè) 編程語(yǔ)言 正文

react最流行的生態(tài)替代antdpro搭建輕量級(jí)后臺(tái)管理_React

作者:daisY ? 更新時(shí)間: 2022-10-06 編程語(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,但是更加將大。樣式使用 tailwindcssstyled-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.jsusers.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

欄目分類(lèi)
最近更新