项目目录结构一览:
├── 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项目
pnpm create vite
选择 React + Typescript
- 在 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'),
},
},
})
- 修改 tsconfig.json,解决 ts 使用别名报错,且会有代码提示
// tsconfig.json
{
"compilerOptions": {
// ...
"baseUrl": "./",
"paths": {
"~/*": ["src/*"]
}
}
}
添加路由
pnpm add react-router-dom@latest
- 在 src 目录下新建 pages 和 router 文件夹
- 在 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
- 在 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: '/',
})
- 修改 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 就配置好了
- 改进一下组件懒加载
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
- 在 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
- 在组件中使用(拿 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
pnpm add antd
- 修改 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
- 添加了全局的主题色,需要在 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
- 为了方便倒出管理,在 stores 下新建 index.ts,统一倒出:
export { default as useCounterStore } from './counter'
export { default as useGlobalStore } from './global'
- 在 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
- 修改 router/index.tsx
// src/router/index.tsx
import Layout from '~/components/Layout'
{
path: '/',
element: <Layout />,
children: [
// ...
]
}
- 重置一下浏览器的一些默认样式,新建 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;
}
- 在 main.tsx 中引入一下:import '~/styles/index.scss',大功告成
现在项目呈现效果如下,点击切换和刷新都👌
后续就自己加业务组件了
封装 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,
})
添加 SWR
同类型的还有 React Query
- 新建 /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
- 做一下代理配置, 添加如下代码:
// vite.config.ts
export default defineConfig({
// ...
server: {
proxy: {
'/new': {
target: 'http://www.ggapi.cn/api',
changeOrigin: true,
},
},
},
})
- 新建页面组件 /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
- 配置相应的路由和左侧的导航菜单,页面呈现如下:
使用 swr/react-query 之类的请求库可以大量简化项目中数据请求的逻辑,也内置缓存和重复请求去除,还有很多特性:请看这里