我用Antd5.x+React18+Vite3.x+Ts做了一个后台管理模版。

4,715 阅读3分钟

Antd5.x

Antd5 官网

吸引我的点

  • 定制主题,随心所欲

  • 组件丰富,选用自如

开发后台管理整个流程

创建模版

  • pnpm create vite@latest react-admin-vite-antd5 -- --template react-ts
  • npm install
  • npm run dev

配置vite和tsconfig

  • vite-plugin-compression 打包增加gzip文件 提高首屏访问速度

  • 配置 "@" 别名,不用写 ../ 这种形式了

  • proxy 解决开发环境跨域问题

  • build / rollupOptions / 拆分包大小,也可以提升首屏访问速度

  •   import react from "@vitejs/plugin-react";
      import { fileURLToPath } from "url";
      import { defineConfig } from "vite";
      // https://vitejs.dev/config/
      import vitePluginCompression from "vite-plugin-compression";
      
      const baseUrl = "react-admin-vite-antd5";
      
      export default defineConfig(config => {
        console.log(config, "config");
        return {
          plugins: [
            react(),
            vitePluginCompression({
              threshold: 1024 * 10, // 对大于 10kb 的文件进行压缩
              // deleteOriginFile: true,
            }),
          ],
          resolve: {
            alias: {
              // for TypeScript path alias import like : @/x/y/z
              "@": fileURLToPath(new URL("./src", import.meta.url)),
            },
          },
          server: {
            open: true,
            port: 5793,
            proxy: {
              "/api": {
                target: "http://localhost:8080",
                secure: false,
                rewrite: path => path.replace(/^\/api/, ""),
              },
            },
          },
          base: config.mode === "development" ? "/" : `/${baseUrl}/`,
          build: {
            outDir: baseUrl,
            rollupOptions: {
              output: {
                chunkFileNames: "js/[name]-[hash].js", // 引入文件名的名称
                entryFileNames: "js/[name]-[hash].js", // 包的入口文件名称
                assetFileNames: "[ext]/[name]-[hash].[ext]", // 资源文件像 字体,图片等
                manualChunks(id) {
                  if (id.includes("node_modules")) {
                    return id
                      .toString()
                      .split("node_modules/")[1]
                      .split("/")[0]
                      .toString();
                  }
                },
              },
            },
          },
        };
      });
    
  • ts类型规则配置

  •   {
        "compilerOptions": {
          "target": "esnext",
          "module": "esnext",
          "lib": ["dom", "dom.iterable", "esnext", "scripthost"],
          "allowJs": false,
          "allowSyntheticDefaultImports": true,
          "esModuleInterop": true,
          "forceConsistentCasingInFileNames": true,
          "isolatedModules": true,
          "jsx": "react-jsx",
          "moduleResolution": "Node",
          "noEmit": true,
          "noFallthroughCasesInSwitch": true,
          "resolveJsonModule": true,
          "skipLibCheck": true,
          "strict": true,
          "sourceMap": true,
          "types": ["vite/client", "node"],
          "baseUrl": ".",
          "paths": {
            "@/*": ["src/*"]
          }
        },
        "include": ["src", "vite.config.ts"]
      }
    

添加d.ts解决大部分引入文件报错的问题

declare module "*.css";
declare module "*.scss";
declare module "*.sass";
declare module "*.svg";
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.tiff";

添加需要用到的包

  • antd、lodash、sass、redux、react-redux、@reduxjs/toolkit

文件主入口

import "./main.css";

import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";

import App from "./app";
import store from "./store";

const container = document.getElementById("root");
const root = createRoot(container as HTMLDivElement);
root.render(
  <Provider store={store}>
    <BrowserRouter
      // 生产环境配置二级路径
      basename={"/" + import.meta.env.BASE_URL.replaceAll("/", "")}
    >
      <App />
    </BrowserRouter>
  </Provider>
);

添加状态管理

import { configureStore } from "@reduxjs/toolkit";

import common from "./reducers/common";
import user from "./reducers/user";

const store = configureStore({
  reducer: {
    user,
    common,
  },
});

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

export default store;





import { createSlice } from "@reduxjs/toolkit";

import { TOKEN } from "@/common/utils/contans";
import { getStorage } from "@/common/utils/storage";
import { MenuItem } from "@/components/Layout/layout";

export interface IUserInitialState {
  role: string[];
  token: string;
  menu: MenuItem[];
  [key: string]: any;
}

export interface Type {
  type: string;
}
// 默认状态
const initialState: IUserInitialState = {
  role: [],
  token: getStorage(TOKEN) ?? "",
  menu: [],
};

export const userSlice = createSlice({
  name: "user",
  initialState: initialState,
  reducers: {
    setUserToken: (state, action) => {
      state.token = action.payload;
    },
    setMenu: (state, action) => {
      state.menu = action.payload;
    },
  },
});

export const { setUserToken, setMenu } = userSlice.actions;

export default userSlice.reducer;

添加登陆界面

主界面

路由

  • 路由、路由懒加载

  •   import { DashboardOutlined } from "@ant-design/icons";
      import { Alert, Button, Result, Spin } from "antd";
      import { lazy, Suspense } from "react";
      import { Link, Navigate } from "react-router-dom";
      
      import { TOKEN } from "@/common/utils/contans";
      import { getStorage } from "@/common/utils/storage";
      import Layout from "@/components/Layout";
      import { MenuItem } from "@/components/Layout/layout";
      import OutletLayoutRouter from "@/components/OutletLayoutRouter";
      import Dashboard from "@/pages/dashboard";
      import ErrorPage from "@/pages/error-page";
      import Login from "@/pages/login";
      
      const Permissions = ({ children }: any) => {
        const token = getStorage(TOKEN);
        return token ? children : <Navigate to="/login" />;
      };
      
      export const baseRouterList = [
        {
          label: "Dashboard",
          key: "dashboard",
          path: "dashboard",
          icon: <DashboardOutlined />,
          filepath: "pages/dashboard/index.tsx",
        },
      ];
      
      export const defaultRoutes: any = [
        {
          path: "/",
          element: <Permissions>{<Layout />}</Permissions>,
          errorElement: <ErrorPage />,
          children: [
            {
              path: "/",
              element: <Navigate to="dashboard" />,
            },
            {
              path: "dashboard",
              element: <Dashboard />,
            },
            {
              path: "/*",
              element: (
                <ErrorPage>
                  <Result
                    status="404"
                    title="404"
                    subTitle="Sorry, the page you visited does not exist."
                    extra={
                      <Link to={"/"}>
                        <Button type="primary">Back Home</Button>
                      </Link>
                    }
                  />
                </ErrorPage>
              ),
            },
          ],
        },
        {
          path: "/login",
          element: <Login />,
        },
      ];
      
      // /**/ 表示二级目录 一般二级目录就够了  不够在加即可
      export const modules = import.meta.glob("../pages/**/*.tsx");
      
      function pathToLazyComponent(Ele: string) {
        const path = modules[`../${Ele}`] as any;
        if (!path)
          return (
            <ErrorPage>
              <Alert
                message={
                  Ele +
                  ":Cannot find the path, please configure the correct folder path"
                }
                type="error"
              />
            </ErrorPage>
          );
        const Components = lazy(path);
        return (
          <Suspense fallback={<Spin size="small" />}>
            <Components />
          </Suspense>
        );
      }
      
      // 递归算法 
      export const filepathToElement = (list: MenuItem[]) =>
        list.map(item => {
          if (item.children) {
            return {
              path: item.path,
              key: item.key,
              children: item.children?.map(c => ({
                key: c.key,
                path: c.path,
                element: pathToLazyComponent(c.filepath),
              })),
              element: <OutletLayoutRouter />,
            };
          } else {
            return {
              key: item.key,
              path: item.path,
              element: pathToLazyComponent(item.filepath),
            };
          }
        });
    
  • 动态路由主要hooks:useRoutes

  •   import "./App.scss";
      
      import { cloneDeep } from "lodash";
      import React, { useEffect } from "react";
      import { useRoutes } from "react-router-dom";
      
      import { AuthContext, signIn, signOut } from "@/common/context";
      import {
        useAppDispatch,
        useAppSelector,
        useLocationListen,
      } from "@/common/hooks";
      import { MenuData } from "@/common/mock";
      import { ADMIN } from "@/common/utils/contans";
      import { Settings } from "@/config/defaultSetting";
      import { setMenu } from "@/store/reducers/user";
      
      import { defaultRoutes, filepathToElement } from "./routes";
      
      function App() {
        const dispatch = useAppDispatch();
        const {
          user: { token, menu },
        } = useAppSelector(state => state);
        const cloneDefaultRoutes = cloneDeep(defaultRoutes);
        cloneDefaultRoutes[0].children = [
          ...filepathToElement(menu),
          ...cloneDefaultRoutes[0].children,
        ];
        // console.log(cloneDefaultRoutes, "cloneDefaultRoutes");
        useLocationListen(r => {
          document.title = `${Settings.title}: ${r.pathname.replace("/", "")}`;
        });
        const element = useRoutes(cloneDefaultRoutes);
        useEffect(() => {
          // console.log(token, "token");
          if ((token as unknown as { username: string })?.username === ADMIN) {
            dispatch(setMenu([...MenuData.admin]));
          } else {
            dispatch(setMenu([...MenuData.user]));
          }
        }, [token]);
      
        return (
          <AuthContext.Provider value={{ signIn, signOut }}>
            {element}
          </AuthContext.Provider>
        );
      }
      
      export default App;
    
  • 模拟数据

  •   import {
        DesktopOutlined,
        TableOutlined,
        UserOutlined,
      } from "@ant-design/icons";
      
      import { MenuItem } from "@/components/Layout/layout";
      
      export const MenuData: {
        user: MenuItem[];
        admin: MenuItem[];
      } = {
        user: [
          {
            label: "User",
            key: "user",
            path: "/user",
            icon: <DesktopOutlined />,
            filepath: "pages/user/index.tsx",
          },
        ],
        admin: [
          {
            label: "User",
            key: "user",
            path: "user",
            icon: <DesktopOutlined />,
            filepath: "pages/user/index.tsx",
          },
          {
            label: "List Page",
            key: "list-page",
            path: "list-page",
            icon: <TableOutlined />,
            filepath: "pages/list-page/index.tsx",
          },
          {
            label: "System Management",
            key: "systemManagement",
            path: "systemManagement",
            icon: <UserOutlined />,
            filepath: "components/OutletLayoutRouter/index.tsx",
            children: [
              {
                label: "User Management",
                key: "userManagement",
                path: "userManagement",
                filepath: "pages/systemManagement/userManagement/index.tsx",
              },
              {
                label: "Role Management",
                key: "roleManagement",
                path: "roleManagement",
                filepath: "pages/systemManagement/roleManagement/index.tsx",
              },
            ],
          },
        ],
      };
    

KeepAlive组件(实验性组件,网上看别人写的,不知道性能如何)

import React, { memo, useEffect, useMemo, useReducer, useRef } from "react";
import { useLocation, useOutlet } from "react-router-dom";

const KeepAlive = (props: { include: any; keys: any }) => {
  const outlet = useOutlet();
  const { include, keys } = props;
  const { pathname } = useLocation();
  const componentList = useRef(new Map());
  // @ts-ignore
  const forceUpdate = useReducer(bool => !bool)[1]; // 强制渲染
  const cacheKey = useMemo(
    () => pathname + "__" + keys[pathname],
    [pathname, keys]
  );
  const activeKey = useRef<string>("");
  useEffect(() => {
    componentList.current.forEach(function (value, key) {
      const _key = key.split("__")[0];
      if (!include.includes(_key) || _key === pathname) {
        // @ts-ignore
        this.delete(key);
      }
    }, componentList.current);
    activeKey.current = cacheKey;
    if (!componentList.current.has(activeKey.current)) {
      componentList.current.set(activeKey.current, outlet);
    }
    forceUpdate();
  }, [cacheKey, include]); // eslint-disable-line
  return (
    <>
      {Array.from(componentList.current).map(([key, component]) => (
        <div key={key}>
          {key === activeKey.current ? (
            <div>{component}</div>
          ) : (
            <div style={{ display: "none" }}>{component}</div>
          )}
        </div>
      ))}
    </>
  );
};

export default memo(KeepAlive);
  • 组件使用

封装主题定制

  • 参考:ant.design/theme-edito…

    <ConfigProvider theme={{ token: { borderRadius: 4, fontSize: 14, colorPrimary: "pink", }, }}

暗黑模式

  • @ant-design/pro-components 提供了 ProConfigProvider 组件
  • 用的是import ProLayout from "@ant-design/pro-layout"; 组件
  •   import {
        GithubFilled,
        InfoCircleFilled,
        LoginOutlined,
        PlusCircleFilled,
        QuestionCircleFilled,
        SearchOutlined,
      } from "@ant-design/icons";
      import {
        ProBreadcrumb,
        ProConfigProvider,
        ProSettings,
      } from "@ant-design/pro-components";
      import ProLayout from "@ant-design/pro-layout";
      import { Input, Switch, Tooltip } from "antd";
      import ErrorBoundary from "antd/es/alert/ErrorBoundary";
      import { useContext, useState } from "react";
      import { Link, useLocation, useNavigate } from "react-router-dom";
      
      import { AuthContext } from "@/common/context";
      import KeepAlive from "@/common/hocs/keepAlive";
      import {
        useAppDispatch,
        useAppSelector,
        useLocationListen,
      } from "@/common/hooks";
      import { treeRouter } from "@/common/utils/common";
      import { Settings } from "@/config/defaultSetting";
      import { baseRouterList } from "@/routes";
      
      export default () => {
        const { user } = useAppSelector(state => state);
        const navigate = useNavigate();
        const location = useLocation();
        const [pathname, setPathname] = useState(location.pathname);
        const dispatch = useAppDispatch();
        const { signOut } = useContext(AuthContext);
        const [dark, setDark] = useState(false);
      
        useLocationListen(listener => {
          // console.log(listener, "listener");
          setPathname(listener.pathname);
        });
      
        const settings: ProSettings | undefined = {
          title: Settings.title.slice(0, 11),
          // fixSiderbar: true,
          layout: "mix",
          // splitMenus: true,
        };
      
        return (
          <ProConfigProvider dark={dark}>
            <div
              id="admin-pro-layout"
              style={{
                height: "100vh",
              }}
            >
              <ProLayout
                fixSiderbar
                siderWidth={245}
                logo={Settings.logo}
                ErrorBoundary={false}
                route={{
                  path: "/",
                  routes: treeRouter([...baseRouterList, ...user.menu]),
                }}
                {...settings}
                location={{
                  pathname,
                }}
                waterMarkProps={{
                  content: Settings.title,
                }}
                appList={[
                  {
                    icon: "https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg",
                    title: "Blog",
                    desc: "杭州市较知名的 UI 设计语言",
                    url: "https://hzdjs.cn",
                  },
                ]}
                avatarProps={{
                  src: "https://joeschmoe.io/api/v1/random",
                  size: "small",
                  title: (
                    <div>
                      {(user.token as unknown as { username: string })?.username}
                    </div>
                  ),
                }}
                headerContentRender={() => <ProBreadcrumb />}
                actionsRender={props => {
                  if (props.isMobile) return [];
                  return [
                    props.layout !== "side" && document.body.clientWidth > 1400 ? (
                      <div
                        key="SearchOutlined"
                        aria-hidden
                        style={{
                          display: "flex",
                          alignItems: "center",
                          marginInlineEnd: 24,
                        }}
                        onMouseDown={e => {
                          e.stopPropagation();
                          e.preventDefault();
                        }}
                      >
                        <Input
                          style={{
                            borderRadius: 4,
                            marginInlineEnd: 12,
                            backgroundColor: "rgba(0,0,0,0.03)",
                          }}
                          prefix={
                            <SearchOutlined
                              style={{
                                color: "rgba(0, 0, 0, 0.15)",
                              }}
                            />
                          }
                          placeholder="搜索方案"
                          bordered={false}
                        />
                        <PlusCircleFilled
                          style={{
                            color: "var(--ant-primary-color)",
                            fontSize: 24,
                          }}
                        />
                      </div>
                    ) : undefined,
                    <InfoCircleFilled key="InfoCircleFilled" />,
                    <QuestionCircleFilled key="QuestionCircleFilled" />,
      
                    <Tooltip placement="bottom" title={"Github"}>
                      <a
                        href="https://github.com/frontend-winter/react-admin-vite-antd5"
                        target="_blank"
                        rel="noreferrer"
                      >
                        <GithubFilled key="GithubFilled" />
                      </a>
                    </Tooltip>,
                    <Tooltip placement="bottom" title={"Sign Out"}>
                      <a>
                        <LoginOutlined
                          onClick={async () => {
                            await signOut(dispatch);
                            navigate("/login");
                          }}
                        />
                      </a>
                    </Tooltip>,
                  ];
                }}
                menuFooterRender={props => {
                  if (props?.collapsed || props?.isMobile) return undefined;
                  return (
                    <div style={{ textAlign: "center" }}>
                      <Switch
                        checkedChildren="🌜"
                        unCheckedChildren="🌞"
                        defaultChecked={false}
                        onChange={v => setDark(v)}
                      />
                    </div>
                  );
                }}
                menuItemRender={(item, dom) => (
                  <Link
                    to={item?.path || "/"}
                    onClick={() => {
                      setPathname(item.path || "/");
                    }}
                  >
                    {dom}
                  </Link>
                )}
                onMenuHeaderClick={() => navigate("/")}
              >
                <ErrorBoundary>
                  <KeepAlive include={[]} keys={[]} />
                </ErrorBoundary>
              </ProLayout>
            </div>
          </ProConfigProvider>
        );
      };
    

结尾

这篇先讲这么多,说的比较模糊,大家可以去看下源码,还有很多可以探讨的问题,欢迎大家留言。

项目地址 github.com/frontend-wi…

线上预览地址hzdjs.cn/react-admin…

至此,一个对新手友好的管理后台项目就构建好了,而且还在不断完善中,未来会补全Node后端服务项目,敬请期待,有问题可以随时留言。。