avatar


8.React [3/4]

本文讨论一个案例:博客的后台管理系统。

开始

基于CRA创建项目,示例代码:

1
npx create-react-app react-jike

src目录下,创建如下文件夹:

  • apis,项目接口函数。
  • assets,项目资源文件,比如,图片等。
  • components,通用组件。
  • pages,页面组件。
  • store,集中状态管理。
  • utils,工具,比如,token、axios的封装等。
  • App.js,根组件。
  • index.scss,全局样式。
  • index.js,项目入口。

安装插件:

  • AntDnpm i antd
  • 路由支持:npm i react-router-dom
  • 别名路径:npm i @craco/craco,该插件需要在项目中添加配置文件,具体可以参考《6.React [2/4]》
  • 新增VSCode提示配置,jsconfig.json,具体可以参考《6.React [2/4]》

新增如下文件:

  • pages/Layout/index.js
    1
    2
    3
    4
    const Layout = () => {
    return <div>this is layout</div>
    }
    export default Layout
  • pages/Login/index.js
    1
    2
    3
    4
    const Login = () => {
    return <div>this is login</div>
    }
    export default Login
  • router/index.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import { createBrowserRouter } from 'react-router-dom'

    import Login from '@/pages/Login'
    import Layout from '@/pages/Layout'

    const router = createBrowserRouter([
    {
    path: '/',
    element: <Layout />,
    },
    {
    path: '/login',
    element: <Login />,
    },
    ])

    export default router
  • index.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    import React from 'react'
    import ReactDOM from 'react-dom/client'
    import './index.scss'
    import router from './router'
    import { RouterProvider } from 'react-router-dom'

    ReactDOM.createRoot(document.getElementById('root')).render(
    <RouterProvider router={router} />
    )

登录

基本结构

登录

pages/Login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'

const Login = () => {
return (
<div className="login">
<Card className="login-container">
<img className="login-logo" src={logo} alt="" />
{/* 登录表单 */}
<Form>
<Form.Item>
<Input size="large" placeholder="请输入手机号" />
</Form.Item>
<Form.Item>
<Input size="large" placeholder="请输入验证码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block>
登录
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

export default Login

pages/Login/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
.login {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: center/cover url('~@/assets/login.png');

.login-logo {
width: 200px;
height: 60px;
display: block;
margin: 0 auto 20px;
}

.login-container {
width: 440px;
height: 360px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 50px rgb(0 0 0 / 10%);
}

.login-checkbox-label {
color: #1890ff;
}
}

表单校验

步骤:

  1. Form组件添加validateTrigger属性,指定校验触发时机的集合。
  2. Form.Item组件添加name属性。
  3. Form.Item组件添加rules属性,用来添加表单校验规则对象。

page/Login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'

const Login = () => {
return (
<div className="login">
<Card className="login-container">
<img className="login-logo" src={logo} alt="" />
{/* 登录表单 */}
<Form validateTrigger='onBlur'>
<Form.Item
name="mobile"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号码格式不对' }
]}>
<Input size="large" placeholder="请输入手机号" />
</Form.Item>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
]}>
<Input size="large" placeholder="请输入验证码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block>
登录
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

export default Login

获取表单数据

步骤:

  1. Form组件添加onFinish属性,该事件会在点击登录按钮时触发。
  2. 创建onFinish函数,通过函数参数values拿到表单值。

pages/Login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 点击登录按钮时触发 参数values即是表单输入数据
const onFinish = formValue => {
console.log(formValue)
}

const Login = () => {
return (
【部分代码略】
{/* 登录表单 */}
<Form validateTrigger='onBlur' onFinish={ onFinish }>
【部分代码略】
</Form>
【部分代码略】
)
}

export default Login

封装request工具模块

在整个项目中会发送很多网络请求,并且:

  • 几乎所有的接口都是一样的接口域名。
  • 几乎所有的接口都需要设置一样的超时时间。
  • 几乎所有的接口都需要做Token权限处理

我们可以做好统一封装,这样方便统一管理和复用。

步骤:

  1. 安装axios到项目。
    1
    npm i axios
  2. 创建utils/request.js文件。
  3. 创建axios实例,配置baseURL(根域名配置)、超时时间、请求拦截器、响应拦截器。
  4. utils/index.js中,统一导出request

utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import axios from 'axios'

const request = axios.create({
baseURL: 'http://geek.itheima.net/v1_0',
timeout: 5000
})

// 添加请求拦截器
request.interceptors.request.use((config)=> {
return config
}, (error)=> {
return Promise.reject(error)
})

// 添加响应拦截器
request.interceptors.response.use((response)=> {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response.data
}, (error)=> {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})

export { request }

utils/index.js

1
2
import { request } from './request'
export { request }

解释说明:再用utils/index.js封装一层,这么做的原因是,我们可能会在uitls中封装多个模块。所以,统一中转工具模块函数,import { request } from '@/utils'

使用Redux管理token

Token作为用户的标识数据,需要在很多个模块中共享,我们可以基于Redux解决状态共享问题。

安装Redux相关工具包:

1
2
npm i react-redux
npm i @reduxjs/toolkit

store/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { createSlice } from '@reduxjs/toolkit'
import { request } from '@/utils'
const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token:''
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
}
}
})

// 解构出actionCreater
const { setToken } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
dispatch(setToken(res.data.token))
}
}

export { fetchLogin }

export default userReducer

store/index.js

1
2
3
4
5
6
7
8
9
10
import { configureStore } from '@reduxjs/toolkit'

import userReducer from './modules/user'

export default configureStore({
reducer: {
// 注册子模块
user: userReducer
}
})

修改index.js,添加Provider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.scss'
import router from './router'
import { RouterProvider } from 'react-router-dom'

// 导入store
import store from './store'
// 导入store提供组件Provider
import { Provider } from 'react-redux'

ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
)

登录逻辑

业务逻辑:

  1. 跳转到首页
  2. 提示用户登录成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

【部分代码略】

import { fetchLogin } from '@/store/modules/user'
import { useDispatch } from 'react-redux'
import { useNavigate } from "react-router-dom"
import { message } from 'antd'

const Login = () => {

const dispatch = useDispatch()
const navigate = useNavigate()
const onFinish = async formValue => {
await dispatch(fetchLogin(formValue))
navigate('/')
message.success('登录成功')
}

return ( 【部分代码略】 )
}

export default Login

token持久化

封装存取方法

utils/token.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 封装存取方法

const TOKENKEY = 'token_key'

function setToken (token) {
return localStorage.setItem(TOKENKEY, token)
}

function getToken () {
return localStorage.getItem(TOKENKEY)
}

function clearToken () {
return localStorage.removeItem(TOKENKEY)
}

export {
setToken,
getToken,
clearToken
}

持久化逻辑

store/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

【部分代码略】

import { getToken, setToken as _setToken } from '@/utils'

const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token: getToken() || ''
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// 存入本地
_setToken(state.token)
}
}
})

【部分代码略】

请求拦截器注入token

业务背景: Token作为用户的数据标识,在接口层面起到了接口权限控制的作用,也就是说后端有很多接口都需要通过查看当前请求头信息中是否含有token数据,来决定是否正常返回数据。

拼接方式:

1
config.headers.Authorization = Bearer ${token}

utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
【部分代码略】

import { getToken } from './token'

【部分代码略】

// 添加请求拦截器
request.interceptors.request.use((config)=> {
// if not login add token
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}, (error)=> {
return Promise.reject(error)
})

【部分代码略】

路由鉴权实现

封装AuthRoute路由鉴权高阶组件,实现未登录拦截,并跳转到登录页面。判断本地是否有token,如果有,就返回子组件,否则就重定向到登录Login

步骤:

  1. components目录中,创建AuthRoute.js文件。
  2. 登录时,直接渲染相应页面组件。
  3. 未登录时,重定向到登录页面。
  4. 将需要鉴权的页面路由配置,替换为AuthRoute组件渲染。

components/AuthRoute.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import { getToken } from '@/utils'
import { Navigate } from 'react-router-dom'

const AuthRoute = ({ children }) => {
const isToken = getToken()
if (isToken) {
return <>{children}</>
} else {
return <Navigate to="/login" replace />
}
}

export default AuthRoute

router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
import AuthRoute from '@/components/AuthRoute'

const router = createBrowserRouter([
{
path: '/',
element: <AuthRoute><Layout /></AuthRoute>,
},
{
path: '/login',
element: <Login />,
},
])

export default router

Layout

基本结构

Layout

pages/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { Layout, Menu, Popconfirm } from 'antd'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
{
label: '首页',
key: '1',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '2',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '3',
icon: <EditOutlined />,
},
]

const GeekLayout = () => {
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
内容
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout

pages/Layout/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
.ant-layout {
height: 100%;
}

.header {
padding: 0;
}

.logo {
width: 200px;
height: 60px;
background: url('~@/assets/logo.png') no-repeat center / 160px auto;
}

.layout-content {
overflow-y: auto;
}

.user-info {
position: absolute;
right: 0;
top: 0;
padding-right: 20px;
color: #fff;

.user-name {
margin-right: 20px;
}

.user-logout {
display: inline-block;
cursor: pointer;
}
}
.ant-layout-header {
padding: 0 !important;
}

样式初始化

样式初始化

如图所示,在上文完成了基本结构,但是整个样式很奇怪,我们需要进行如下操作:

  1. 安装normalize.css
    1
    npm install normalize.css
  2. 定义index.scss
    1
    2
    3
    4
    5
    6
    7
    8
    9
    html,
    body {
    margin: 0;
    height: 100%;
    }

    #root {
    height: 100%;
    }
  3. index.js中引入上述两个文件:
    1
    2
    import 'normalize.css'
    import './index.scss'

二级路由配置

步骤:

  1. pages目录中,分别创建:Home(数据概览)、Article(内容管理)、Publish(发布文章)页面文件夹。
  2. 分别在三个文件夹中创建index.js并创建基础组件后导出。
  3. router/index.js中配置嵌套子路由,在Layout中配置二级路由出口。
  4. 使用Link修改左侧菜单内容,与子路由规则匹配实现路由切换。

pages/Home/index.js

1
2
3
4
5
const Home = () => {
return <div>Home</div>
}

export default Home

pages/Article/index.js

1
2
3
4
5
const Article = () => {
return <div>Article</div>
}

export default Article

pages/Publish/index.js

1
2
3
4
5
const Publish = () => {
return <div>Publish</div>
}

export default Publish

router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import GeekLayout from '@/pages/Layout'
import Publish from '@/pages/Publish'
import Article from '@/pages/Article'
import Home from '@/pages/Home'
import AuthRoute from '@/components/AuthRoute'


const router = createBrowserRouter([
{
path: '/',
element: <AuthRoute> <GeekLayout /> </AuthRoute> ,
children: [
{
index: true,
element: <Home />,
},
{
path: 'article',
element: <Article />,
},
{
path: 'publish',
element: <Publish />,
},
],
},
{
path: '/login',
element: <Login />,
},
])

export default router

修改pages/Layout/index.js,配置二级路由出口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { Layout, Menu, Popconfirm } from 'antd'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'
import { Outlet } from "react-router-dom"


const { Header, Sider } = Layout

const items = [
{
label: '首页',
key: '1',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '2',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '3',
icon: <EditOutlined />,
},
]

const GeekLayout = () => {
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
<Outlet />
</Layout>
</Layout>
</Layout>
)
}

export default GeekLayout

重点关注如下内容:

1
2
3
4
5
import { Outlet } from "react-router-dom"

<Layout className="layout-content" style={{ padding: 20 }}>
<Outlet />
</Layout>

路由菜单点击交互

点击菜单跳转路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

import { Outlet, useNavigate } from "react-router-dom"


const { Header, Sider } = Layout

const items = [
{
label: '首页',
key: '/',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '/article',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '/publish',
icon: <EditOutlined />,
},
]


<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}
onClick={ menuClick }>
</Menu>

菜单反向高亮

实现效果:页面在刷新时可以根据当前的路由路径让对应的左侧菜单高亮显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { Outlet, useNavigate, useLocation } from "react-router-dom"

const items = [
{
label: '首页',
key: '/',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '/article',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '/publish',
icon: <EditOutlined />,
},
]


const GeekLayout = () => {
const navigate = useNavigate()
const menuClick = (route) => {
navigate(route.key)
}

// 省略部分代码
const location = useLocation()
const selectedKey = location.pathname

return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
selectedKeys={selectedKey}
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}
onClick={ menuClick }>
</Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
<Outlet />
</Layout>
</Layout>
</Layout>
)
}

export default GeekLayout

展示个人信息

步骤:

  1. 在Redux的store中编写获取用户信息的相关逻辑。
  2. 在Layout组件中触发action的执行。
  3. 在Layout组件使用使用store中的数据进行用户名的渲染。

store/modules/user.js新增userInfo相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { createSlice } from '@reduxjs/toolkit'
import { request } from '@/utils'

import { getToken, setToken as _setToken } from '@/utils'

const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token: getToken() || '',
userInfo: {}
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// 存入本地
_setToken(state.token)
},
setUserInfo (state, action){
state.userInfo = action.payload
}
}
})

// 解构出actionCreater
const { setToken, setUserInfo } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
dispatch(setToken(res.data.token))
}
}

const fetchUserInfo = () => {
return async (dispatch) => {
const res = await request.get('/user/profile')
dispatch(setUserInfo(res.data))
}
}

export { fetchLogin, fetchUserInfo }

export default userReducer

pages/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useEffect } from "react"
import { fetchUserInfo } from '@/store/modules/user'
import { useDispatch, useSelector } from 'react-redux'

const dispatch = useDispatch()
const name = useSelector(state => state.user.userInfo.name)
useEffect(() => {
dispatch(fetchUserInfo())
}, [dispatch])

<Header className="header">

<span className="user-name">{ name }</span>

</Header>

退出登录

退出登录

步骤:

  1. 为气泡确认框添加确认回调事件
  2. store/userStore.js中新增退出登录的action函数,在其中删除token
  3. 在回调事件中,调用userStore中的退出action
  4. 清除用户信息,返回登录页面。

store/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { createSlice } from '@reduxjs/toolkit'
import { request } from '@/utils'

import { getToken, setToken as _setToken, clearToken as _clearToken } from '@/utils'

const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token: getToken() || '',
userInfo: {}
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// 存入本地
_setToken(state.token)
},
setUserInfo (state, action){
state.userInfo = action.payload
},
clearToken (state) {
state.token = ''
state.userInfo = {}
_clearToken()
}
}
})

// 解构出actionCreater
const { setToken, setUserInfo, clearToken } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
dispatch(setToken(res.data.token))
}
}

const fetchUserInfo = () => {
return async (dispatch) => {
const res = await request.get('/user/profile')
dispatch(setUserInfo(res.data))
}
}

export { fetchLogin, fetchUserInfo, clearToken }

export default userReducer

pages/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { clearToken, fetchUserInfo } from '@/store/modules/user'

【部分代码略】

const GeekLayout = () => {
const navigate = useNavigate()

【部分代码略】

// 退出登录
const logOut = () => {
dispatch(clearToken())
navigate('/login')
}

return (

【部分代码略】

<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消" onConfirm={ logOut }>
<LogoutOutlined /> 退出
</Popconfirm>
</span>

【部分代码略】

)
}

export default GeekLayout

处理Token失效

  • 什么是Token失效?
    为了用户的安全和隐私考虑,在用户长时间未在网站中做任何操作且规定的失效时间到达之后,当前的Token就会失效,一旦失效,不能再作为用户令牌标识请求隐私数据。
  • 前端如何知道Token已经失效了?
    通常在Token失效之后再去请求接口,后端会返回401状态码,前端可以监控这个状态做后续的操作
  • Token失效了前端做什么?
    1. 在axios拦截中监控401状态码
    2. 清除失效Token,跳转登录

utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { getToken, clearToken } from './token'
import router from '@/router'

// 添加响应拦截器
request.interceptors.response.use((response)=> {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response.data
}, (error)=> {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
console.dir(error)
if (error.response.status === 401) {
clearToken()
router.navigate('/login')
// 强制刷新
window.location.reload()
}
return Promise.reject(error)
})

首页Home图表展示

echarts

ECharts,一款基于JavaScript的数据可视化图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表。

图表基础Demo

安装ECharts:

1
npm i echarts

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'

const Home = () => {
const chartRef = useRef(null)
useEffect(() => {
// 1. 生成实例
const myChart = echarts.init(chartRef.current)
// 2. 准备图表参数
const option = {
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar'
}
]
}
// 3. 渲染参数
myChart.setOption(option)
}, [])

return (
<div>
<div ref={chartRef} style={{ width: '400px', height: '300px' }} />
</div >
)
}

export default Home

组件封装

pages/Home/components/BarChart.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useRef, useEffect } from 'react'
import * as echarts from 'echarts'

const BarChart = ({ xData, sData, style = { width: '400px', height: '300px' } }) => {
const chartRef = useRef(null)
useEffect(() => {
// 1. 生成实例
const myChart = echarts.init(chartRef.current)
// 2. 准备图表参数
const option = {
xAxis: {
type: 'category',
data: xData
},
yAxis: {
type: 'value'
},
series: [
{
data: sData,
type: 'bar'
}
]
}
// 3. 渲染参数
myChart.setOption(option)
}, [sData, xData])
return <div ref={chartRef} style={style}></div>
}

export { BarChart }

pages/Home/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { BarChart } from './components/BarChart'

const Home = () => {
return (
<div>
<BarChart
xData={['Vue', 'React', 'Angular']}
sData={[2000, 5000, 1000]} />

<BarChart
xData={['Vue', 'React', 'Angular']}
sData={[200, 500, 100]}
style={{ width: '500px', height: '400px' }} />
</div >
)
}

export default Home

API模块封装

我们再把上文的请求,都封装为API模块。

apis/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 用户相关的所有请求
import { request } from "@/utils"
// 1. 登录请求

export function loginAPI (formData) {
return request({
url: '/authorizations',
method: 'POST',
data: formData
})
}

// 2. 获取用户信息

export function getProfileAPI () {
return request({
url: '/user/profile',
method: 'GET'
})
}

store/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { getProfileAPI, loginAPI } from '@/apis/user'

const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await loginAPI(loginForm)
dispatch(setToken(res.data.token))
}
}

const fetchUserInfo = () => {
return async (dispatch) => {
const res = await getProfileAPI()
dispatch(setUserInfo(res.data))
}
}

发布文章

基本结构

创建基本结构

创建基础结构

pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import {Card, Breadcrumb, Form, Button, Radio, Input, Upload, Space, Select} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'

const { Option } = Select

const Publish = () => {
return (
<div className="publish">
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '发布文章' },
]}
/>
}
>
<Form
labelCol={{ span: 4 }}
wrapperCol={{ span: 16 }}
initialValues={{ type: 1 }}
>
<Form.Item
label="标题"
name="title"
rules={[{ required: true, message: '请输入文章标题' }]}
>
<Input placeholder="请输入文章标题" style={{ width: 400 }} />
</Form.Item>
<Form.Item
label="频道"
name="channel_id"
rules={[{ required: true, message: '请选择文章频道' }]}
>
<Select placeholder="请选择文章频道" style={{ width: 400 }}>
<Option value={0}>推荐</Option>
</Select>
</Form.Item>
<Form.Item
label="内容"
name="content"
rules={[{ required: true, message: '请输入文章内容' }]}
></Form.Item>

<Form.Item wrapperCol={{ offset: 4 }}>
<Space>
<Button size="large" type="primary" htmlType="submit">
发布文章
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}

export default Publish

pages/Publish/index.scss

1
2
3
4
5
6
7
8
9
10
11
.publish {
position: relative;
}

.ant-upload-list {
.ant-upload-list-picture-card-container,
.ant-upload-select {
width: 146px;
height: 146px;
}
}

准备富文本编辑器

步骤:

  1. 安装富文本编辑器。
  2. 导入富文本编辑器组件以及样式文件。
  3. 渲染富文本编辑器组件。
  4. 调整富文本编辑器的样式。

react-quill,富文本编辑器。

安装react-quill

1
npm install react-quill

导入资源渲染组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'

<Form
labelCol={{ span: 4 }}
wrapperCol={{ span: 16 }}
initialValues={{ type: 1 }}>

<Form.Item
label="内容"
name="content"
rules={[{ required: true, message: '请输入文章内容' }]}>

<ReactQuill
className="publish-quill"
theme="snow"
placeholder="请输入文章内容" />

</Form.Item>

<Form.Item wrapperCol={{ offset: 4 }}>
1
2
3
4
5
.publish-quill {
.ql-editor {
min-height: 300px;
}
}

频道数据获取

频道数据获取

步骤:

  1. 在API模块封装接口函数。
  2. 使用useState初始化数据和修改数据的方法。
  3. useEffect中调用接口并保存数据。
  4. 使用数据渲染对应模版。

apis/article.js

1
2
3
4
5
6
7
8
9
// 文章相关的所有请求
import { request } from "@/utils"

export function getChannelsAPI () {
return request({
url: '/channels',
method: 'GET'
})
}

pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useState, useEffect } from 'react'
import { request } from '@/utils'



// 频道列表
const [channels, setChannels] = useState([])

// 调用接口
useEffect(() => {
async function fetchChannels() {
const res = await getChannelsAPI()
setChannels(res.data.channels)
} fetchChannels()}, [])


<Form.Item
label="频道"
name="channel_id"
rules={[{ required: true, message: '请选择文章频道' }]}
>
<Select placeholder="请选择文章频道" style={{ width: 400 }}>
{channels.map(item => (
<Option key={item.id} value={item.id}>
{item.name}
</Option>
))}
</Select>
</Form.Item>

发布

步骤:

  1. 使用Form组件收集表单数据
  2. 按照接口文档封装接口函数
  3. 按照接口文档处理表单数据
  4. 提交接口并验证是否成功

apis/article.js

1
2
3
4
5
6
7
8
9
10
11
// 文章相关的所有请求
import { request } from "@/utils"


export function createArticleAPI (data){
return request({
url: '/mp/articles?draft=false',
method: 'POST',
data
})
}

pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { message } from 'antd'


// 发布文章
const onFinish = async (formValue) => {
const { channel_id, content, title } = formValue
// 按照接口文档的格式处理收集到的表单数据
const params = {
channel_id,
content,
title,
type: 1,
cover: {
type: 1,
images: []
}
}
// 调用接口提交
await createArticleAPI(params)
message.success('发布文章成功')
}


<Form
labelCol={{ span: 4 }}
wrapperCol={{ span: 16 }}
initialValues={{ type: 1 }}
onFinish={ onFinish }
>
······
</Form>

发布文章

上传图片

基本结构

上传图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<Form.Item label="封面">
<Form.Item name="type">
<Radio.Group>
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>
</Radio.Group>
</Form.Item>
<Upload
listType="picture-card"
showUploadList
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>
</Form.Item>

上传功能

步骤:

  1. Upload组件添加action属性,配置封面图片上传接口地址。
  2. Upload组件添加name属性,接口要求的字段名。
  3. Upload添加onChange属性,在事件中拿到当前图片数据,并存储到React状态中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 上传图片
const [imageList, setImageList] = useState([])
const onChange = (info) => {
setImageList(info.fileList)
}


<Form.Item label="封面">
<Form.Item name="type">
<Radio.Group>
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>
</Radio.Group>
</Form.Item>
<Upload
listType="picture-card"
showUploadList
action={'http://geek.itheima.net/v1_0/upload'}
name='image'
onChange={onChange}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>
</Form.Item>

切换图片Type

实现效果:只有当前模式为单图或者三图模式时才显示上传组件

步骤:

  1. 点击单选框时拿到当前的类型value
  2. 根据value控制上传组件的显示(大于零时才显示)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 控制图片Type
const [imageType, setImageType] = useState(1)

const onTypeChange = (e) => {
console.log(e)
setImageType(e.target.value)
}


<Form.Item label="封面">
<Form.Item name="type">
<Radio.Group onChange={onTypeChange}>
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>
</Radio.Group>
</Form.Item>
{imageType > 0 &&
<Upload
listType="picture-card"
showUploadList
action={'http://geek.itheima.net/v1_0/upload'}
name='image'
onChange={onChange}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>
}
</Form.Item>

控制最大上传图片数量

通过maxCount属性限制图片的上传图片数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{imageType > 0 &&
<Upload
listType="picture-card"
showUploadList
action={'http://geek.itheima.net/v1_0/upload'}
name='image'
onChange={onChange}
maxCount={imageType}
multiple={imageType > 1}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>
}

暂存图片列表

业务描述:如果当前为三图模式,已经完成了上传,选择单图只显示一张,再切换到三图继续显示三张。

实现思路:在上传完毕之后通过ref存储所有图片,需要几张就显示几张,其实也就是把ref当仓库,用多少拿多少。

步骤:

  1. 通过useRef创建一个暂存仓库,在上传完毕图片的时候把图片列表存入。
  2. 如果是单图模式,就从仓库里取第一张图,以数组的形式存入fileList。
  3. 如果是三图模式,就把仓库里所有的图片,以数组的形式存入fileList。

注意:需要给Upload组件添加fileList属性,以达成受控的目的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useRef } from 'react'


const cacheImageList = useRef([])
// 上传图片
const [imageList, setImageList] = useState([])
const onChange = (info) => {
setImageList(info.fileList)
cacheImageList.current = info.fileList
}

// 控制图片Type
const [imageType, setImageType] = useState(1)

const onTypeChange = (e) => {
console.log(e)
const type = e.target.value
setImageType(e.target.value)
if (type === 1) {
// 单图,截取第一张展示
const imgList = cacheImageList.current[0] ? [cacheImageList.current[0]] : []
setImageList(imgList)
} else if (type === 3) {
// 三图,取所有图片展示
setImageList(cacheImageList.current)
}
}


{imageType > 0 &&
<Upload
listType="picture-card"
showUploadList
action={'http://geek.itheima.net/v1_0/upload'}
name='image'
onChange={onChange}
maxCount={imageType}
multiple={imageType > 1}
fileList={imageList}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>
}

发布带封面的文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 发布文章
const onFinish = async (formValue) => {
if (imageType !== imageList.length) return message.warning('图片类型和数量不一致')
const { channel_id, content, title } = formValue
// 按照接口文档的格式处理收集到的表单数据
const params = {
channel_id,
content,
title,
type: 1,
cover: {
type: imageType,
images: imageList.map(item => item.response.data.url)
}
}
// 调用接口提交
await createArticleAPI(params)
message.success('发布文章成功')
}

文章列表

基本架构

筛选区

筛选区

pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'

const { Option } = Select
const { RangePicker } = DatePicker

const Article = () => {
return (
<div>
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '文章列表' },
]} />
}
style={{ marginBottom: 20 }}
>
<Form initialValues={{ status: '' }}>
<Form.Item label="状态" name="status">
<Radio.Group>
<Radio value={''}>全部</Radio>
<Radio value={0}>草稿</Radio>
<Radio value={2}>审核通过</Radio>
</Radio.Group>
</Form.Item>

<Form.Item label="频道" name="channel_id">
<Select
placeholder="请选择文章频道"
defaultValue="lucy"
style={{ width: 120 }}
>
<Option value="jack">Jack</Option>
<Option value="lucy">Lucy</Option>
</Select>
</Form.Item>

<Form.Item label="日期" name="date">
{/* 传入locale属性 控制中文显示*/}
<RangePicker locale={locale}></RangePicker>
</Form.Item>

<Form.Item>
<Button type="primary" htmlType="submit" style={{ marginLeft: 40 }}>
筛选
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

export default Article

解释说明:initialValues={ { status: '' } },设置表单中名为status的字段的初始值为空字符串,即默认会选中<Radio value={''}>全部</Radio>

表格区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'

const Article = () => {

// 准备列数据
const columns = [
{
title: '封面',
dataIndex: 'cover',
width: 120,
render: cover => {
return <img src={cover.images[0] || img404} width={80} height={60} alt="" />
}
},
{
title: '标题',
dataIndex: 'title',
width: 220
},
{
title: '状态',
dataIndex: 'status',
render: data => data === 1 ? <Tag color='warning'>待审核</Tag>:<Tag color='success'>审核通过</Tag>
},
{
title: '发布时间',
dataIndex: 'pubdate'
},
{
title: '阅读数',
dataIndex: 'read_count'
},
{
title: '评论数',
dataIndex: 'comment_count'
},
{
title: '点赞数',
dataIndex: 'like_count'
},
{
title: '操作',
render: data => {
return (
<Space size="middle">
<Button type="primary" shape="circle" icon={<EditOutlined />} />
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
</Space>
)
}
}
]
// 准备表格body数据
const data = [
{
id: '8218',
comment_count: 0,
cover: {
images: [],
},
like_count: 0,
pubdate: '2019-03-11 09:00:00',
read_count: 2,
status: 2,
title: 'wkwebview离线化加载h5资源解决方案'
}
]

return (
<div>
{/* */}
<Card title={'根据筛选条件共查询到 count 条结果:'}>
<Table rowKey="id" columns={columns} dataSource={data} />
</Card>
</div>
)
}

渲染频道

又是频道数据,我们在上文也有这个。
我们可以:直接再写一遍、存到Redux中维护、使用自定义业务hook。

在本文,我们选择自定义业务hook。

步骤:

  1. 创建一个use打头的函数
  2. 在函数中封装业务逻辑,并return出组件中要用到的状态数据
  3. 组件中导入函数执行并解构状态数据使用

hooks/useChannel.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { getChannelsAPI } from "@/apis/article"
import { useState, useEffect } from "react"

// 封装获取频道列表逻辑


function useChannel() {
// 获取频道列表所有的逻辑
const [channels, setChannels] = useState([])

useEffect(() => {
async function fetchChannels() {
const res = await getChannelsAPI()
setChannels(res.data.channels)
}
fetchChannels()
}, [])

// 把组件中要用到的数据return出去
return { channels }
}

export { useChannel }

pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { channels } = useChannel()

// 渲染模板
<Form.Item label="频道" name="channel_id">
<Select
placeholder="请选择文章频道"
defaultValue=""
style={{ width: 120 }}
>
{channels.map(item => (
<Option key={item.id} value={item.id}>
{item.name}
</Option>
))}
</Select>
</Form.Item>

渲染表格

步骤:

  1. 声明列表相关数据管理。
  2. 使用useState声明参数相关数据管理。
  3. 调用接口获取数据。
  4. 使用接口数据渲染模板。

apis/article.js

1
2
3
4
5
6
7
export function getArticleListAPI (params){
return request({
url: '/mp/articles',
method: 'GET',
params
})
}

pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const [article, setArticleList] = useState({
list: [],
count: 0
})

const [params, setParams] = useState({
page: 1,
per_page: 4,
begin_pubdate: null,
end_pubdate: null,
status: null,
channel_id: null
})

useEffect(() => {
async function fetchArticleList () {
const res = await getArticleListAPI(params)
const { results, total_count } = res.data
setArticleList({
list: results,
count: total_count
})
}
fetchArticleList()
}, [params])


<Card title={`根据筛选条件共查询到 ${article.count} 条结果:`}>
<Table dataSource={article.list} columns={columns} />
</Card>

筛选功能

步骤:

  1. 为表单添加onFinish属性监听表单提交事件,获取参数。
  2. 根据接口字段格式要求格式化参数格式。
  3. 修改params参数并重新使用新参数重新请求数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 筛选功能
// 准备参数
const [reqData, setReqData] = useState({
status: '',
channel_id: '',
begin_pubdate: '',
end_pubdate: '',
page: 1,
per_page: 4
})

// 获取文章列表
const [list, setList] = useState([])
const [count, setCount] = useState(0)
useEffect(() => {
async function getList () {
const res = await getArticleListAPI(reqData)
setList(res.data.results)
setCount(res.data.total_count)
}
getList()
}, [reqData])

// 获取筛选数据
const onFinish = (formValue) => {
console.log(formValue)
// 把表单收集到数据放到参数中(不可变的方式)
setReqData({
...reqData,
channel_id: formValue.channel_id,
status: formValue.status,
begin_pubdate: formValue.date[0].format('YYYY-MM-DD'),
end_pubdate: formValue.date[1].format('YYYY-MM-DD')
})
// 重新拉取文章列表 + 渲染table逻辑重复的 - 复用
// reqData依赖项发生变化 重复执行副作用函数
}



<Card title={`根据筛选条件共查询到 ${count} 条结果:`}>
<Table dataSource={list} columns={columns} />
</Card>

分页功能

步骤:

  1. 为Table组件指定pagination属性来展示分页效果。
  2. 在分页切换事件中获取到筛选表单中选中的数据。
  3. 使用当前页数据修改params参数依赖引起接口重新调用获取最新数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const onPageChange = (page) => {
// 拿到当前页参数 修改引起接口更新
setReqData({
...reqData,
page
})
}


<Table dataSource={list} columns={columns} pagination={{
current: reqData.page,
pageSize: reqData.per_page,
onChange: onPageChange,
total: count
}}/>

删除功能

步骤:

  1. 给删除文章按钮绑定点击事件。
  2. 弹出确认窗口,询问用户是否确定删除文章。
  3. 拿到参数调用删除接口,更新列表。

apis/article.js

1
2
3
4
5
6
export function delArticleAPI (id) {
return request({
url: `/mp/articles/${id}`,
method: 'DELETE'
})
}

pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { delArticleAPI } from '@/apis/article'


const delArticle = async (data) => {
await delArticleAPI(data.id)
// 更新列表
setReqData({
page: 1,
per_page: 10
})
}


// 准备列数据
const columns = [

【部分代码略】

{
title: '操作',
render: data => {
return (
<Space size="middle">
<Button type="primary" shape="circle" icon={<EditOutlined />} />
<Popconfirm
title="确认删除该条文章吗?"
onConfirm={() => delArticle(data)}
okText="确认"
cancelText="取消"
>
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
)
}
}
]

编辑文章跳转

1
2
3
4
5
6
7
8
const navagite = useNavigate()

<Button
type="primary"
shape="circle"
icon={<EditOutlined />}
onClick={() => navagite(`/publish?id=${data.id}`)}
/>

编辑文章

基础数据回填

基础数据回填

apis/article.js

1
2
3
4
5
export function getArticleById (id) {
return request({
url: `/mp/articles/${id}`
})
}

pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { getArticleById } from '@/apis/article'


// 回填数据
const [searchParams] = useSearchParams()
const articleId = searchParams.get('id')
const [form] = Form.useForm()
useEffect(() => {
async function getArticleDetail () {
const res = await getArticleById(articleId)
console.log(res)
const data = res.data
form.setFieldsValue({
...data
})
}
if (articleId) {
// 拉取数据回显
getArticleDetail()
}
}, [articleId, form])


<Form
labelCol={{ span: 4 }}
wrapperCol={{ span: 16 }}
initialValues={{ type: 1 }}
onFinish={ onFinish }
form={form}
>
···
</Form>

注意,一定要在Form上绑定form属性。

回填封面信息

为什么上述方法无法回填封面。

因为,set方法,要求传入的数据,形如{type : 3};但是,我们现在传入的数据,形如{cover : {type : 3}}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
useEffect(() => {
async function getArticleDetail () {
const res = await getArticleById(articleId)
const data = res.data
const { cover } = data
form.setFieldsValue({
...data,
type: cover.type
})


// 回填图片列表
setImageType(res.data.cover.type)
// 显示图片({url:url})
setImageList(cover.images.map(url => {
return { url }
}))
}
if (articleId) {
// 拉取数据回显
getArticleDetail()
}
}, [articleId, form])

适配不同状态下的文案

适配不同状态下的文案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: `${articleId ? '编辑文章' : '发布文章'}` },
]}
/>
}
>


<Button size="large" type="primary" htmlType="submit">
{articleId ? '更新文章' : '发布文章'}
</Button>

更新文章

apis/article.js

1
2
3
4
5
6
7
export function updateArticleAPI (data) {
return request({
url: `/mp/articles/${data.id}?draft=false`,
method: 'PUT',
data
})
}

pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const onFinish = (formValue) => {
console.log(formValue)
// 校验封面类型imageType是否和实际的图片列表imageList数量是相等的
if (imageList.length !== imageType) return message.warning('封面类型和图片数量不匹配')
const { title, content, channel_id } = formValue
// 1. 按照接口文档的格式处理收集到的表单数据
const reqData = {
title,
content,
cover: {
type: imageType, // 封面模式
// 这里的url处理逻辑只是在新增时候的逻辑
// 编辑的时候需要做处理
images: imageList.map(item => {
if (item.response) {
return item.response.data.url
} else {
return item.url
}
}) // 图片列表
},
channel_id
}
// 2. 调用接口提交
// 处理调用不同的接口 新增 - 新增接口 编辑状态 - 更新接口 id
if (articleId) {
// 更新接口
updateArticleAPI({ ...reqData, id: articleId })
} else {
createArticleAPI(reqData)
}
}

打包部署

打包命令

1
npm run build

项目本地预览

  1. 全局安装本地服务包 npm i -g serve  该包提供了serve命令,用来启动本地服务器
  2. 在项目根目录中执行命令 serve -s ./build  在build目录中开启服务器
  3. 在浏览器中访问:http://localhost:3000/ 预览项目

路由懒加载

什么是路由懒加载

路由懒加载是指路由的JS资源只有在被访问时才会动态获取,目的是为了优化项目首次打开的时间。

配置方法

步骤:

  1. 使用lazy方法导入路由组件。
  2. 使用内置的Suspense组件渲染路由组件。

router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import GeekLayout from '@/pages/Layout'
import AuthRoute from '@/components/AuthRoute'

import { lazy, Suspense } from 'react'

const Publish = lazy(() => import('@/pages/Publish'))
const Article = lazy(() => import('@/pages/Article'))
const Home = lazy(() => import('@/pages/Article'))

const router = createBrowserRouter([
{
path: '/',
element: <AuthRoute> <GeekLayout /> </AuthRoute> ,
children: [
{
index: true,
element: (<Suspense fallback={'加载中'}> <Home /> </Suspense>),
},
{
path: 'article',
element: (<Suspense fallback={'加载中'}> <Article /> </Suspense>),
},
{
path: 'publish',
element: (<Suspense fallback={'加载中'}> <Publish /> </Suspense>),
},
],
},
{
path: '/login',
element: <Login />,
},
])

export default router

之前我们导入的方法是:

1
2
3
import Publish from '@/pages/Publish'
import Article from '@/pages/Article'
import Home from '@/pages/Home'

现在,需要修改成:

1
2
3
const Publish = lazy(() => import('@/pages/Publish'))
const Article = lazy(() => import('@/pages/Article'))
const Home = lazy(() => import('@/pages/Article'))

打包体积分析

什么是打包体积分析

通过可视化的方式,直观的体现项目中各种包打包之后的体积大小,方便做优化。

操作方法

步骤:

  1. 安装分析打包体积的包:npm i source-map-explorer
  2. package.json中的scripts标签中,添加分析打包体积的命令。
    1
    "analyze": "source-map-explorer 'build/static/js/*.js'"
  3. 对项目打包:npm run build(如果已经打过包,可省略这一步)。
  4. 运行分析命令:npm run analyze
  5. 通过浏览器打开的页面,分析图表中的包体积。

打包体积分析

配置CDN

哪些资源可以放到CDN服务器

体积较大的非业务JS文件,例如reactreact-dom

对于这些非业务JS文件,不需要经常做变动,CDN不用频繁更新缓存。

操作方法

步骤:

  1. 把需要做CDN缓存的文件排除在打包之外。
  2. 以CDN的方式重新引入资源。

我们通过craco修改webpack配置,从而实现CDN优化。

craco.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 添加自定义对于webpack的配置

const path = require('path')
const { whenProd, getPlugin, pluginByName } = require('@craco/craco')

module.exports = {
// webpack 配置
webpack: {
// 配置别名
alias: {
// 约定:使用 @ 表示 src 文件所在路径
'@': path.resolve(__dirname, 'src')
},
// 配置webpack
// 配置CDN
configure: (webpackConfig) => {
let cdn = {
js:[]
}
whenProd(() => {
// key: 不参与打包的包(由dependencies依赖项中的key决定)
// value: cdn文件中 挂载于全局的变量名称 为了替换之前在开发环境下
webpackConfig.externals = {
react: 'React',
'react-dom': 'ReactDOM'
}
// 配置现成的cdn资源地址
// 实际开发的时候 用公司自己花钱买的cdn服务器
cdn = {
js: [
'https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js',
]
}
})

// 通过 htmlWebpackPlugin插件 在public/index.html注入cdn资源url
const { isFound, match } = getPlugin(
webpackConfig,
pluginByName('HtmlWebpackPlugin')
)

if (isFound) {
// 找到了HtmlWebpackPlugin的插件
match.userOptions.files = cdn
}

return webpackConfig
}
}
}

还需要修改public/index.html

1
2
3
4
5
6
7
<body>
<div id="root"></div>
<!-- 加载第三发包的 CDN 链接 -->
<% htmlWebpackPlugin.options.files.js.forEach(cdnURL => { %>
<script src="<%= cdnURL %>"></script>
<% }) %>
</body>

基于NGINX部署注意事项

React项目部署到Nginx上,刷新浏览器页面,可能会出现404的问题。

原因:使用create-react-app创建的React应用为单页应用,使用BrowserRouter的话如果刷新页面Nginx会根据页面url来发送数据,但是Nginx服务器中没有关于该路由的配置,导致找不到文件,因而返回404页面。

解决方案

  1. 配置Nginx,使所有访问重定向到index.html
    在location的配置上加一句try_files $uri /index.html
    1
    2
    3
    4
    5
    location / {
    root /root/build;
    index index.html index.htm;
    try_files $uri /index.html;
    }
  2. 在项目中使用HashRouter,这一方法一个缺点是不利于SEO,因为是客户端渲染,并且会在url上加一个#符号,具体设置可看业务需求。

完整版本react-jike.zip提供下载链接

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/12008
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

留言板