react@18 + vite@4 + react-router@6 + zustand + antd@5 + axios + swr 保姆级搭建后台管理系统

7,230 阅读5分钟

项目地址github.com

项目目录结构一览:

├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
│   └── favicon.svg
├── src
│   ├── App.tsx
│   ├── api
│   │   ├── hotNewsApi
│   │   ├── testApi
│   │   └── userApi
│   ├── assets
│   ├── components
│   │   ├── ErrorBoundary
│   │   ├── HeaderRight
│   │   └── Layout
│   ├── hooks
│   │   ├── useDebounce.ts
│   │   ├── useDebounceFn.ts
│   │   └── useTitle.ts
│   ├── main.tsx
│   ├── pages
│   │   ├── About
│   │   ├── Home
│   │   ├── HotNews
│   │   └── Login
│   ├── router
│   │   ├── index.tsx
│   │   └── lazyLoad.tsx
│   ├── stores
│   │   ├── counter.ts
│   │   ├── global.ts
│   │   ├── index.ts
│   │   └── userInfo.ts
│   ├── styles
│   │   └── index.scss
│   ├── utils
│   │   └── request.ts
│   └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

创建vite项目

Vite v4

pnpm create vite

选择 React + Typescript

  1. 在 vite.config.ts 中简单的配置下路径别名,注意引入 path 时报错,需要安装下 @types/node:pnpm add @types/node -D
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '~': path.resolve(__dirname, 'src'),
    },
  },
})
  1. 修改 tsconfig.json,解决 ts 使用别名报错,且会有代码提示
// tsconfig.json
{
  "compilerOptions": {
    // ...
    "baseUrl": "./",
    "paths": {
      "~/*": ["src/*"]
    }
  }
}

添加路由

React Router v6

pnpm add react-router-dom@latest
  1. 在 src 目录下新建 pages 和 router 文件夹
  2. 在 pages 下新增页面级组件 Home/index.tsx, About/index.tsx 添加内容如下:
// src/pages/Home/index.tsx
const Home = () => {
  return <div>Home Page</div>
}

export default Home
// src/pages/About/index.tsx
const About = () => {
  return <div>About Page</div>
}

export default About
  1. 在 router 目录下创建 index.tsx 文件,内容如下:
// src/router/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom'
import type { RouteObject } from 'react-router-dom'
import Home from '~/pages/Home'
import About from '~/pages/About'

const routes: RouteObject[] = [
  {
    path: '/',
    children: [
      {
        index: true,
        element: <Navigate to="/home" replace />,
      },
      {
        path: 'home',
        element: <Home />,
      },
      {
        path: 'about',
        element: <About />,
      },
    ],
  },
]

export default createBrowserRouter(routes, {
  basename: '/',
})
  1. 修改 src/App.tsx 内容:
// src/App.tsx
import { RouterProvider } from 'react-router-dom'
import router from './router'

const App = () => {
  return <RouterProvider router={router} />
}

启动一下项目,在地址栏手动输了不同的路由 /home,/about,能渲染对应的组件说明 router 就配置好了

  1. 改进一下组件懒加载

router 目录下新建 lazyLoad.tsx 文件:

// router/lazyLoad.tsx
import { Suspense } from 'react'

const lazyLoad = (Component: React.LazyExoticComponent<() => JSX.Element>) => {
  return (
    <Suspense>
      <Component />
    </Suspense>
  )
}

export default lazyLoad

修改 router/index.tsx:

// router/index.ts
import { lazy } from 'react'
import { createBrowserRouter, Navigate } from 'react-router-dom'
import type { RouteObject } from 'react-router-dom'
import lazyLoad from './lazyLoad'

const Home = lazy(() => import('~/pages/Home'))
const About = lazy(() => import('~/pages/About'))

const routes: RouteObject[] = [
  {
    path: '/',
    children: [
      {
        index: true,
        element: <Navigate to="/home" replace />,
      },
      {
        path: 'home',
        element: lazyLoad(Home),
      },
      {
        path: 'about',
        element: lazyLoad(About),
      },
    ],
  },
]

export default createBrowserRouter(routes, {
  basename: '/',
})

添加 zustand

Zustand 状态管理库

pnpm add zustand
  1. 在 src 目录下新建 stores 文件夹,添加 counter.ts 文件:
import { create } from 'zustand'

interface CounterState {
  counter: number
  increase: (by: number) => void
}

const useCounterStore = create<CounterState>()((set) => ({
  counter: 0,
  increase: (by) => set((state) => ({ counter: state.counter + by })),
}))

export default useCounterStore
  1. 在组件中使用(拿 Home 组件举例):
import useCounterStore from '~/stores/counter'

const Home = () => {
  const counter = useCounterStore((state) => state.counter)
  const increase = useCounterStore((state) => state.increase)

  return (
    <div>
      <div>Home Page</div>
      <button onClick={() => increase(1)}> counter: {counter} </button>
    </div>
  )
}

export default Home

这时你可以在 home 页面上点击 button 便可看到 counter 的变化。

添加 Antd

Ant Design

pnpm add antd
  1. 修改 App.tsx
// App.tsx
import { RouterProvider } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import { useGlobalStore } from '~/stores'
import router from './router'
import dayjs from 'dayjs'
import zhCN from 'antd/locale/zh_CN'
import 'dayjs/locale/zh-cn'
import 'antd/dist/reset.css'

dayjs.locale('zh-cn')

const App = () => {
  const { primaryColor } = useGlobalStore()

  return (
    <ConfigProvider
      locale={zhCN}
      theme={{
        token: {
          colorPrimary: primaryColor,
        },
      }}
    >
      <RouterProvider router={router} />
    </ConfigProvider>
  )
}

export default App

这里更改antd的语言配置为中文,用到了 dayjs 需要安装一下:pnpm add dayjs

  1. 添加了全局的主题色,需要在 stores 下新建 global.ts 文件,在里面维护了全局主题色 primaryColor,添加持久化缓存
// src/stores/global.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface GlobalState {
  primaryColor: string
  setColor: (color: string) => void
}

const useGlobalStore = create<GlobalState>()(
  persist(
    (set) => ({
      primaryColor: '#00b96b',
      setColor: (color) => set(() => ({ primaryColor: color })),
    }),
    {
      name: 'primaryColor',
      // partialize 过滤属性,存储哪些字段到localStorage
      partialize: (state) =>
        Object.fromEntries(Object.entries(state).filter(([key]) => ['primaryColor'].includes(key))),
    }
  )
)

export default useGlobalStore
  1. 为了方便倒出管理,在 stores 下新建 index.ts,统一倒出:
export { default as useCounterStore } from './counter'
export { default as useGlobalStore } from './global'
  1. 在 src 目录下新建 components/Layout/index.tsx
// src/components/Layout/index.tsx
import { useState } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { Button, Layout, Menu } from 'antd'
import type { MenuProps } from 'antd'

const { Header, Sider, Content } = Layout

const items: MenuProps['items'] = [
  {
    label: 'home',
    path: '/home',
  },
  {
    label: 'about',
    path: '/about',
  },
].map((nav) => ({
  key: nav.path,
  icon: null,
  label: nav.label,
}))

const BasicLayout = () => {
  const navigate = useNavigate()
  const { pathname } = useLocation()

  const [collapsed, setCollapsed] = useState(false)

  const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
    navigate(key)
  }

  return (
    <Layout style={{ height: '100vh' }}>
      <Sider trigger={null} collapsible collapsed={collapsed} theme="light">
        <div
          style={{
            height: 32,
            margin: 16,
            background: 'rgba(0, 0, 0, 0.2)',
            zIndex: 200,
          }}
        />
        <Menu
          mode="inline"
          defaultSelectedKeys={[pathname]}
          items={items}
          onClick={handleMenuClick}
        />
      </Sider>
      <Layout style={{ display: 'flex', flexDirection: 'column' }}>
        <Header style={{ background: '#fff', padding: 0 }}>
          <Button type="text" onClick={() => setCollapsed(!collapsed)}>
            collapsed
          </Button>
        </Header>
        <Content style={{ padding: '16px', flex: 1, overflowY: 'auto' }}>
          <Outlet />
        </Content>
      </Layout>
    </Layout>
  )
}

export default BasicLayout
  1. 修改 router/index.tsx
// src/router/index.tsx
import Layout from '~/components/Layout'

{
    path: '/',
    element: <Layout />,
    children: [
        // ...
    ]
}
  1. 重置一下浏览器的一些默认样式,新建 src/styles/index.scss

安装 sass:pnpm add sass -D

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;

  background: #efefef;
}

ul,
ol {
  list-style: none;
}

img,
svg {
  display: inline-block;
}

a:focus,
a:active,
div:focus {
  outline: none;
}

a,
a:focus,
a:hover {
  cursor: pointer;
  color: inherit;
  text-decoration: none;
}
  1. 在 main.tsx 中引入一下:import '~/styles/index.scss',大功告成

现在项目呈现效果如下,点击切换和刷新都👌

image.png

后续就自己加业务组件了

封装 Axios

Axios

pnpm add axios

在 src 中添加 utils/request.ts 封装一下 axios

// src/utils/request.ts
import axios from 'axios'
import type {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  CreateAxiosDefaults,
  InternalAxiosRequestConfig,
} from 'axios'
import { useUserInfoStore } from '~/stores'

class Request {
  private instance: AxiosInstance
  // 存放取消请求控制器Map
  private abortControllerMap: Map<string, AbortController>

  constructor(config: CreateAxiosDefaults) {
    this.instance = axios.create(config)

    this.abortControllerMap = new Map()

    // 请求拦截器
    this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
      if (config.url !== '/login') {
        const token = useUserInfoStore.getState().userInfo?.token
        if (token) config.headers!['x-token'] = token
      }

      const controller = new AbortController()
      const url = config.url || ''
      config.signal = controller.signal
      this.abortControllerMap.set(url, controller)

      return config
    }, Promise.reject)

    // 响应拦截器
    this.instance.interceptors.response.use(
      (response: AxiosResponse) => {
        const url = response.config.url || ''
        this.abortControllerMap.delete(url)

        if (response.data.code !== 1000) {
          return Promise.reject(response.data)
        }

        return response.data
      },
      (err) => {
        if (err.response?.status === 401) {
          // 登录态失效,清空userInfo,跳转登录页
          useUserInfoStore.setState({ userInfo: null })
          window.location.href = `/login?redirect=${window.location.pathname}`
        }

        return Promise.reject(err)
      }
    )
  }

  // 取消全部请求
  cancelAllRequest() {
    for (const [, controller] of this.abortControllerMap) {
      controller.abort()
    }
    this.abortControllerMap.clear()
  }

  // 取消指定的请求
  cancelRequest(url: string | string[]) {
    const urlList = Array.isArray(url) ? url : [url]
    for (const _url of urlList) {
      this.abortControllerMap.get(_url)?.abort()
      this.abortControllerMap.delete(_url)
    }
  }

  request<T>(config: AxiosRequestConfig): Promise<T> {
    return this.instance.request(config)
  }

  get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return this.instance.get(url, config)
  }

  post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return this.instance.post(url, data, config)
  }
}

export const httpClient = new Request({
  timeout: 20 * 1000,
  baseURL: import.meta.env.VITE_API_URL,
})
// src/api/testApi/index.ts
import { httpClient } from '~/utils/request'

export const testApi = {
  getData: () => {
    return httpClient.get('/test')
  },
}
import { testApi } from '~/api/testApi'

useEffect(() => {
    testApi.getData().then(res=>{
        console.log(res)
    })
}, [])

添加.env配置文件

在项目目录下新建 .env.development 和 .env.production

// .env.development
VITE_APP_ENV = 'development'
VITE_BASE_URL = '/'
VITE_API_URL = '/'
// .env.production
VITE_APP_ENV = 'development'
VITE_BASE_URL = '/'
VITE_API_URL = '/'

这样在可以替换 router/index.tsx 中的 basename 和 utils/request.ts 中的 baseURL

// ...
const router = createBrowserRouter(routes, {
  basename: import.meta.env.VITE_BASE_URL,
})
export const httpClient = new Request({
  timeout: 20 * 1000,
  baseURL: import.meta.env.VITE_API_URL,
})

项目地址github.com

添加 SWR

SWR (用于数据请求的 React Hooks 库)

同类型的还有 React Query

  1. 新建 /api/hotNewsApi/index.ts,内容如下:
import useSWR from 'swr'
import { httpClient } from '~/utils/request'
import { NewItem } from './types'

export const useFetchHotNews = (type: string) => {
  const { data, isValidating } = useSWR(`/ggapi/new?type=${type}`, (url) =>
    httpClient.get<{ list: NewItem[] }>(url, {
      successCode: 200,
    })
  )

  return {
    hotNews: data?.data.list,
    isValidating,
  }
}

这里我使用了一些免费的api来演示: 狗哥API - 永久免费 (ggapi.cn)

这里成功的code我改成了可配置的,相应在封装axios的那里也做了相应的修改,具体可看项目中utils/request.ts

  1. 做一下代理配置, 添加如下代码:
// vite.config.ts

export default defineConfig({
  // ...
  server: {
    proxy: {
      '/new': {
        target: 'http://www.ggapi.cn/api',
        changeOrigin: true,
      },
    },
  },
})
  1. 新建页面组件 /pages/HotNews/index.tsx,内容如下:
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Button, List, Skeleton, Space } from 'antd'
import { useFetchHotNews } from '~/api/hotNewsApi'

const NAVS = [
  { title: '知乎', id: 'zhihu' },
  { title: '微博', id: 'weibo' },
  { title: '微信', id: 'weixin' },
  { title: '百度', id: 'baidu' },
  { title: '抖音', id: 'douyin' },
  { title: 'B站', id: 'bilibili' },
  { title: '头条', id: 'toutiao' },
]

const HotNews = () => {
  const [active, setActive] = useState(NAVS[0].id)

  const { hotNews, isValidating } = useFetchHotNews(active)

  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
      <Space wrap>
        {NAVS.map((nav) => (
          <Button
            type={active === nav.id ? 'primary' : 'default'}
            key={nav.id}
            onClick={() => setActive(nav.id)}
          >
            {nav.title}
          </Button>
        ))}
      </Space>

      <List
        itemLayout="horizontal"
        dataSource={hotNews || Array(10).fill('')}
        bordered
        style={{ marginTop: '16px', flex: 1, overflow: 'auto' }}
        renderItem={(item) => (
          <List.Item key={item.id}>
            <Skeleton avatar={false} title={false} loading={isValidating} active>
              <List.Item.Meta
                title={
                  <Link to={item.link} target="_blank">
                    {item.title}
                  </Link>
                }
              />
              <div>{item.other}</div>
            </Skeleton>
          </List.Item>
        )}
      />
    </div>
  )
}

export default HotNews

这里简单使用封装好的 useFetchHotNews 一行代码就可以实现很多步骤了:比如定义loading、data,在 useEffect 中发送请求,再设置loading、data

  1. 配置相应的路由和左侧的导航菜单,页面呈现如下:

image.png

使用 swr/react-query 之类的请求库可以大量简化项目中数据请求的逻辑,也内置缓存和重复请求去除,还有很多特性:请看这里