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";
...