手把手教你使用 Vite + React 搭建后台管理系统

12,278 阅读7分钟

Untitled.gif

2024-08-11更新

这是个手把手教你使用vite搭建的React项目。

没有过多的配置和封装。全是基础但是必需的功能配置,比如代码规范,登录、请求、路由、主题切换、进度条、动画、错误边界处理等。

使用到全是最新的,受欢迎的技术栈:

  • 🌵 vite - 前端构建工具,提升前端开发体验.
  • ✈ react - 不用介绍了吧
  • 🎉 react-router-dom - 路由管理方案
  • 🎨 antd - 开箱即用的高质量 React UI 组件库
  • 💅 tailwindcss - 实用程序优先的 CSS 框架
  • 📑 @tanstack/react-query - Web 应用程序缺少的数据获取库
  • 🐻 zustand - React 中状态管理的必需品
  • 🎇 echarts - 图表库

创建项目

npm create vite@latest

选择 React + Typescript + SWC

配置项目规范

1. 添加 eslint + prettier

npm install --save-dev eslint-plugin-prettier eslint-config-prettier
npm install --save-dev --save-exact prettier

修改 eslint.config.js

import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";

export default tseslint.config({
  extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintPluginPrettierRecommended],
  // 其他配置...
 })

这样就启用了 prettier/prettier 规则,也启用 eslint-config-prettier 配置(关闭 Prettier 和 ESLint 规则冲突)

在根目录下添加 .prettierrc 配置文件

{
  "arrowParens": "always",
  "bracketSameLine": false,
  "bracketSpacing": true,
  "semi": true,
  "experimentalTernaries": false,
  "singleQuote": false,
  "jsxSingleQuote": false,
  "quoteProps": "as-needed",
  "trailingComma": "all",
  "singleAttributePerLine": false,
  "htmlWhitespaceSensitivity": "css",
  "proseWrap": "preserve",
  "insertPragma": false,
  "printWidth": 110,
  "requirePragma": false,
  "tabWidth": 2,
  "useTabs": false,
  "embeddedLanguageFormatting": "auto"
}

配置 vscode 自动保存格式化:根目录下新建 .vscode/settings.json

{
  "editor.codeActionsOnSave": {
    "source.fixAll": "explicit"
  },
  "editor.formatOnSave": true,
}

2. 添加 husky + lint-staged

初始化git

git init

安装 husky + lint-staged

npm install --save-dev husky lint-staged
npx husky init

在 package.josn 文件添加 lint-staged 配置

{
  // ...
  "scripts": {
    // ...
+   "lint:fix": "eslint . --fix"
  },
+ "lint-staged": {
+   "src/**/*.{ts,tsx,js}": [
+     "npm run lint:fix"
+   ]
+ }
}

修改 .husky/pre-commit 文件

#!/usr/bin/env sh
npx --no-install lint-staged

3. 配置别名

npm install --save-dev @types/node

修改 vite.config.ts 文件

import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
+ resolve: {
+   alias: {
+     "@": path.resolve(__dirname, "src"),
+   },
+ },
});

修改 tsconfig.app.json 文件

// tsconfig.json
{
  "compilerOptions": {
    // ...
+   "baseUrl": "./",
+   "paths": {
+     "@/*": ["src/*"]
+   }
  }
}

4.配置环境变量

创建 .env 文件,写入如下内容:

VITE_APP_BASE_URL="/"
VITE_APP_API_URL="//127.0.0.1:5050/api"

修改 vite-env.d.ts

/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_APP_BASE_URL: string;
  readonly VITE_APP_API_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

5. 管理 import 顺序

npm install --save-dev eslint-plugin-simple-import-sort

更新 eslint.config.js 文件

{
  plugins: {
    "simple-import-sort": simpleImportSort,
  },
  rules: {
    "simple-import-sort/exports": "error",
    "simple-import-sort/imports": [
      "error",
      {
        groups: [
          [
            "^(node:|vite)",
            "^react",
            "^@?\\w",
            "^@/components",
            "^\\.\\.(?!/?$)",
            "^\\.\\./?$",
            "^\\./(?=.*/)(?!/?$)",
            "^\\.(?!/?$)",
            "^\\./?$",
            "^@(utils|store|hooks|api|router)",
          ],
          ["antd/locale/zh_CN", "dayjs/locale/zh-cn"],
          ["^.+\\.s?css$"],
        ],
      },
    ],
  },
},

添加 tailwindcss

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p --ts

修改 tailwind.config.ts

export default {
  darkMode: "class",
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  // ...
}

修改 index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

更新 .vscode/settings.json,忽略 tailwind 带来的未知 css 规则的警告

{
  "css.lint.unknownAtRules": "ignore"
}

添加路由 react-router v6

npm install react-router-dom@6

1.新增文件 src/layouts/index.tsx

import { Outlet } from "react-router-dom";

export default function MainLayout() {
  return (
    <>
      <nav className="text-white h-14 bg-zinc-800 flex items-center px-4">Main Layout</nav>
      <Outlet /> {/* Outlet是子路由的占位符 */}
    </>
  );
}

2.新增文件 src/router/index.ts

import { createBrowserRouter, Navigate, type RouteObject } from "react-router-dom";

const routes: RouteObject[] = [
  {
    path: "/",
    lazy: async () => ({
      Component: (await import("@/layouts")).default,
    }),
    children: [
      {
        index: true,
        element: <Navigate replace to="/landing" />,
      },
      {
        path: "landing",
        lazy: async () => ({
          Component: (await import("@/pages/landing")).default,
        }),
      },
    ],
  },
];

export const router = createBrowserRouter(routes, {
  basename: import.meta.env.VITE_APP_BASE_URL,
});

3.新增文件 src/pages/landing/index.tsx

export default function LandingPage() {
  return <div>Landing Page</div>;
}

4.修改 src/App.tsx 内容:

import { RouterProvider } from "react-router-dom";
import { router } from "@/router";

export default function App() {
  return <RouterProvider router={router} fallbackElement={<div>Loading...</div>} />;
}

启动项目,就能看到 LandingPage 页面

添加 Antd + dayjs + zustand

npm install antd dayjs zustand

修改 antd 的语言配置用到 dayjs

用全局状态管理管理主题色,全局状态管理用的 zustand

1.配置antd

修改 App.tsx

import { RouterProvider } from "react-router-dom";
import { App as AntdApp, ConfigProvider } from "antd";
import { useSettingsStore } from "./stores/settings";
import { router } from "./router";

import zhCN from "antd/locale/zh_CN";
import "dayjs/locale/zh-cn";

export default function App() {
  const colorPrimary = useSettingsStore((state) => state.colorPrimary);
  return (
    <ConfigProvider
      locale={zhCN}
      theme={{
        cssVar: true, // 开启 css 变量
        hashed: false, // 如果你的应用中只存在一个版本的 antd你可以设置为 false 来进一步减小样式体积token: {
          colorPrimary,
        },
      }}
      componentSize="large"
    >
      <AntdApp>
        <RouterProvider router={router} fallbackElement={<div>Loading...</div>} />
      </AntdApp>
    </ConfigProvider>
  );
}

2.新建项目设置 settings store

新建 src/stores/settings.ts 文件,存放全局设置

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface SettingsState {
  colorPrimary: string;
  setColorPrimary: (value: string) => void;
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      colorPrimary: "#1DA57A",
      setColorPrimary: (colorPrimary) => set({ colorPrimary }),
    }),
    {
      name: "app-settings",
    },
  ),
);

3.添加自定义主题色

修改 pages/landing/index.tsx

import { Button, ColorPicker, DatePicker, Flex, Typography } from "antd";
import { useSettingsStore } from "@/stores/settings";

const { RangePicker } = DatePicker;

export default function LandingPage() {
  const colorPrimary = useSettingsStore((state) => state.colorPrimary);
  const setColorPrimary = useSettingsStore((state) => state.setColorPrimary);

  return (
    <div>
      <Typography.Title level={3}>Landing Page</Typography.Title>
      <Flex gap={16}>
        <Button type="primary">primary</Button>
        <Button>default</Button>
        <RangePicker />
        <ColorPicker
          showText
          value={colorPrimary}
          onChange={(color) => {
            setColorPrimary(color.toHex());
          }}
        />
      </Flex>
    </div>
  );
}

4.添加 antd message等静态方法

新建 src/components/static-antd/index.tsx

import { App } from 'antd';
import type { MessageInstance } from 'antd/es/message/interface';
import type { ModalStaticFunctions } from 'antd/es/modal/confirm';
import type { NotificationInstance } from 'antd/es/notification/interface';

let message: MessageInstance;
let notification: NotificationInstance;
let modal: Omit<ModalStaticFunctions, 'warn'>;

export default function StaticAntd() {
  const staticFunction = App.useApp();
  message = staticFunction.message;
  modal = staticFunction.modal;
  notification = staticFunction.notification;
  return null;
}

// eslint-disable-next-line
export { message, modal, notification };

4.布局

安装 antd icons

npm install @ant-design/icons --save

新建 src/layouts/sider/index.tsx

import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { HomeOutlined, UserOutlined, VideoCameraOutlined } from "@ant-design/icons";
import { Layout, Menu, type MenuProps } from "antd";
import { useTheme } from "@/components/theme-provider";
import { useSettingsStore } from "@/stores/settings";

import ReactIcon from "@/assets/react.svg?react";

// 递归函数,找到匹配的菜单项
const findSelectedKeys = (items: MenuProps["items"], pathname: string, path: string[] = []) => {
  const selectedKeys: string[] = [];
  let openKeys: string[] = [];

  const travel = (items: MenuProps["items"], pathname: string, path: string[]) => {
    for (const item of items!) {
      if (item!.key === pathname) {
        selectedKeys.push(item!.key);
        openKeys = [...path];
        return;
      }
      if ((item as any).children) {
        path.push(item!.key as string);
        travel((item as any).children, pathname, path);
        path.pop();
      }
    }
  };

  travel(items, pathname, path);
  return { selectedKeys, openKeys };
};

const items: MenuProps["items"] = [
  {
    icon: <HomeOutlined />,
    label: <Link to="/landing">首页</Link>,
    key: "/landing",
  },
  {
    icon: <UserOutlined />,
    label: <Link to="/user-management">用户管理</Link>,
    key: "/user-management",
  },
  {
    icon: <VideoCameraOutlined />,
    label: "一级菜单",
    key: "/nav",
    children: [
      {
        key: "/nav/sub-1",
        label: <Link to="/nav/sub-1">二级菜单-1</Link>,
      },
      {
        key: "/nav/sub-2",
        label: <Link to="/nav/sub-2">二级菜单-2</Link>,
      },
    ],
  },
];

export default function Sider() {
  const location = useLocation();

  const firstRenderRef = useRef(true);

  const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
  const [openKeys, setOpenKeys] = useState<string[]>([]);

  const collapsed = useSettingsStore((state) => state.collapsed);

  const { isDark } = useTheme();

  useEffect(() => {
    if (location.pathname === "/") return;

    // 首次渲染时,设置默认值
    if (firstRenderRef.current) {
      const { selectedKeys, openKeys } = findSelectedKeys(items, location.pathname);
      setSelectedKeys(selectedKeys);
      setOpenKeys(openKeys);
    }
    // 将首次渲染标记设置为false
    firstRenderRef.current = false;
  }, [location.pathname]);

  return (
    <Layout.Sider
      trigger={null}
      collapsible
      collapsed={collapsed}
      className="h-screen overflow-auto fixed top-0 left-0 bottom-0 dark:text-white"
    >
      <Link
        className="font-bold text-2xl hover:text-current h-[var(--layout-header-height)] flex justify-center items-center gap-2"
        to="/"
      >
        <ReactIcon className="size-6" />
        {collapsed ? null : "React Admin"}
      </Link>
      <Menu
        theme={isDark ? "dark" : "light"}
        mode="inline"
        items={items}
        selectedKeys={selectedKeys}
        onSelect={({ selectedKeys }) => setSelectedKeys(selectedKeys)}
        openKeys={openKeys}
        onOpenChange={(openKeys) => setOpenKeys(openKeys)}
        className="!border-e-0"
      />
    </Layout.Sider>
  );
}

更新 src/layouts/index.tsx

import { Outlet, useNavigate } from "react-router-dom";
import { LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { Button, Flex, Layout } from "antd";
import ThemeSwitch from "@/components/theme-switch";
import Sider from "./sider";
import { useSettingsStore } from "@/stores/settings";

export default function MainLayout() {
  const navigate = useNavigate();

  const collapsed = useSettingsStore((state) => state.collapsed);
  const setCollapsed = useSettingsStore((state) => state.setCollapsed);

  return (
    <Layout hasSider className="w-screen overflow-hidden">
      <Sider />
      <Layout>
        <Layout.Header className="flex items-center justify-between dark:text-white sticky top-0 z-[999] border-b border-b-gray-200 dark:border-b-gray-700">
          <Button
            type="text"
            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
            onClick={() => setCollapsed(!collapsed)}
            className="text-inherit hover:text-inherit"
          />
          <Flex gap={16}>
            <ThemeSwitch />
            <div
              className="flex items-center justify-center gap-1 cursor-pointer"
              onClick={() => {
                navigate("/login");
              }}
            >
              <LogoutOutlined className="text-xl" /> 退出登录
            </div>
          </Flex>
        </Layout.Header>
        <Layout.Content className="min-h-[calc(100vh-var(--layout-header-height))] p-4">
          <Outlet /> {/* Outlet是子路由的占位符 */}
        </Layout.Content>
      </Layout>
    </Layout>
  );
}

更新 src/stores/settings.ts 添加侧边栏的展开的状态

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface SettingsState {
  // ...
  collaspsed: boolean;
  setCollapsed: (value: boolean) => void;
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      // ...
      collaspsed: false,
      SettingsState: (collaspsed) => set({ collaspsed }),
    }),
    {
      name: "app-settings",
      partialize: (state) =>
        Object.fromEntries(Object.entries(state).filter(([key]) => ["colorPrimary", "collaspsed"].includes(key))),
    },
  ),
);

5.添加路由切换页面淡入淡出动画

安装 framer-motion

npm install framer-motion

封装PageAnimate组件:

// src/components/page-animate/index.tsx
import type { PropsWithChildren } from "react";
import { AnimatePresence, motion } from "framer-motion";

export function PageAnimate({ children }: PropsWithChildren) {
  return (
    <AnimatePresence mode="wait">
      <motion.div
        initial={{ opacity: 0, x: 20 }}
        animate={{ opacity: 1, x: 0 }}
        exit={{ opacity: 0, x: 20 }}
        transition={{ duration: 0.4 }}
        className="h-full flex flex-col"
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

将 layouts/index.tsx 中的 content 拆分出来

// layouts/content/index.tsx
import { Outlet, useMatches } from "react-router-dom";
import { Layout } from "antd";
import { PageAnimate } from "@/components/page-animate";

export default function Content() {
  const matches = useMatches();
  const currRouter = matches.at(-1);
  return (
    <Layout.Content className="min-h-[calc(100vh-var(--layout-header-height))] p-4">
      <PageAnimate key={currRouter!.pathname}>
        <Outlet /> {/* Outlet是子路由的占位符 */}
      </PageAnimate>
    </Layout.Content>
  );
}

添加 @tanstack/react-query

用于处理 React 中获取、缓存和更新异步数据

  • 自动缓存+重新获取+窗口重新聚焦+轮询/实时
  • 并行+依赖查询
  • 突变+反应式查询重取
  • 多层缓存+自动垃圾回收
  • 分页+基于游标的查询
  • 无限滚动查询/滚动恢复
  • 请求取消
  • 支持 React Suspense
  • 支持预查询
npm add @tanstack/react-query @tanstack/react-query-devtools

修改 main.tsx

// ...
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false, // 窗口聚焦时重新获取数据
      refetchOnReconnect: false, // 网络重新连接时重新获取数据
      retry: false, // 失败时重试
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </StrictMode>,
);

Echarts

npm add echarts

封装echarts组件

// src/components/react-echarts/library.ts

import { BarChart, LineChart, PieChart } from "echarts/charts";
import {
  GridComponent,
  LegendComponent,
  TitleComponent,
  ToolboxComponent,
  TooltipComponent,
} from "echarts/components";
import * as echarts from "echarts/core";

echarts.use([
  BarChart,
  LineChart,
  PieChart,
  GridComponent,
  LegendComponent,
  TitleComponent,
  ToolboxComponent,
  TooltipComponent,
]);

export default echarts;

// src/components/react-echarts/library.ts

import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { CanvasRenderer, SVGRenderer } from "echarts/renderers";
import echarts from "./library";
import { cn, debounce } from "@/utils";

interface ReactEchartsProps {
  theme?: string;
  renderer?: "canvas" | "svg";
  option: any;
  className?: string;
  style?: React.CSSProperties;
}

interface RefProps {
  getEChartInstance: () => echarts.ECharts | null;
}

export const ReactEcharts = forwardRef<RefProps, ReactEchartsProps>(function (
  { theme = "light", option, renderer, className, style },
  ref,
) {
  const eleRef = useRef(null);
  const chartInstance = useRef<echarts.ECharts | null>(null);

  echarts.use(renderer === "svg" ? SVGRenderer : CanvasRenderer);

  useImperativeHandle(ref, () => ({
    getEChartInstance: () => chartInstance.current,
  }));

  useEffect(() => {
    if (eleRef.current) {
      chartInstance.current = echarts.init(eleRef.current, theme, { renderer });
      chartInstance.current.setOption(option);

      const onResize = debounce(() => {
        chartInstance.current?.resize();
      }, 500);

      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(eleRef.current);

      return () => {
        resizeObserver.disconnect();
        chartInstance.current?.dispose();
      };
    }
  }, [option, theme, renderer]);

  return <div ref={eleRef} className={cn("w-full h-full", className)} style={style} />;
});

ReactEcharts.displayName = "ReactEcharts";

...

项目仓库github