危险

为之则易,不为则难

0%

17_极客园 H5

今日目标

✔ 掌握个人中心功能的开发。

✔ 掌握 WebSocket 的使用。

布局页面结构

目标

能够使用准备好的模板搭建布局页面结构。

素材

pages/Layout/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@import '@scss/hairline.scss';

.root {
height: 100%;

:global {
// 底部 tabbar 的样式
.tab-bar {
@include hairline(top, #f0f0f0);
position: fixed;
z-index: 1;
bottom: 0;
width: 100%;
height: 46px;
background-color: #f7f8fa;

.icon {
color: #fc6627;
}
}
}
}

pages/Layout/index.tsx

1
2
3
4
5
6
7
import styles from './index.module.scss'

const Layout = () => {
return <div className={styles.root}>{/* 使用 antd 的 TabBar 组件,并指定类名 tab-bar */}</div>
}

export default Layout

步骤

  1. 使用 antd-mobile 的 TabBar 组件创建底部标签栏。

  2. 样式在素材中已经准备好。

代码

pages/Layout/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import styles from './index.module.scss'
import { TabBar } from 'antd-mobile'
import Icon from '@/components/Icon'

const tabs = [
{ path: '/home', icon: 'iconbtn_home', text: '首页' },
{ path: '/home/question', icon: 'iconbtn_qa', text: '问答' },
{ path: '/home/video', icon: 'iconbtn_video', text: '视频' },
{ path: '/home/profile', icon: 'iconbtn_mine', text: '我的' },
]

export default function Layout() {
return (
<div className={styles.root}>
<TabBar className='tab-bar'>
{tabs.map((item) => (
<TabBar.Item key={item.path} icon={<Icon type={item.icon} />} title={item.text} />
))}
</TabBar>
</div>
)
}

路由切换功能

  1. 注册 onChange 事件,实现路由的跳转。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function Layout() {
const history = useHistory()
// #2: 注意 pages/Layout/index.tsx,后缀一定是 .tsx
const changeRoute = (path: string) => {
history.push(path)
}
return (
<div className={styles.root}>
{/* #1 */}
<TabBar className='tab-bar' onChange={changeRoute}>
{tabs.map((item) => (
<TabBar.Item key={item.path} icon={<Icon type={item.icon} />} title={item.text} />
))}
</TabBar>
</div>
)
}
  1. 点击高亮效果。
1
<TabBar.Item key={item.path} icon={(active: boolean) => <Icon type={active ? `${item.icon}_sel` : item.icon} />} title={item.text} />
  1. 解决刷新时默认高亮的问题。
1
<TabBar className='tab-bar' onChange={changeRoute} activeKey={location.pathname}></TabBar>
  1. 完整代码。
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
import styles from './index.module.scss'
import { TabBar } from 'antd-mobile'
import Icon from '@/components/Icon'
import { useHistory, useLocation } from 'react-router-dom'

const tabs = [
{ path: '/home', icon: 'iconbtn_home', text: '首页' },
{ path: '/home/question', icon: 'iconbtn_qa', text: '问答' },
{ path: '/home/video', icon: 'iconbtn_video', text: '视频' },
{ path: '/home/profile', icon: 'iconbtn_mine', text: '我的' },
]

export default function Layout() {
const history = useHistory()
const location = useLocation()
const changeRoute = (path: string) => {
history.push(path)
}
return (
<div className={styles.root}>
<TabBar className='tab-bar' onChange={changeRoute} activeKey={location.pathname}>
{tabs.map((item) => (
<TabBar.Item key={item.path} icon={(active: boolean) => <Icon type={active ? `${item.icon}_sel` : item.icon} />} title={item.text} />
))}
</TabBar>
</div>
)
}

配置二级路由

目标

能够点击 Tab 栏实现二级路由切换的效果。

素材

Home

pages/Home/index.tsx

1
2
3
4
5
6
7
import styles from './index.module.scss'

const Home = () => {
return <div className={styles.root}>Home</div>
}

export default Home

pages/Home/index.module.scss

1
2
.root {
}

Question

pages/Question/index.tsx

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
import { NavBar } from 'antd-mobile'
import styles from './index.module.scss'

const Question = () => {
return (
<div className={styles.root}>
<NavBar>问答</NavBar>

<div className='question-list'>
<div className='question-item'>
<div className='left'>
<h3>作为 IT 行业的过来人,你有什么话想对后辈说的?</h3>
<div className='info'>
<span>赞同 1000+</span>
<span>评论 500+</span>
<span>1小时前</span>
</div>
</div>
<div className='right'>
<img src='https://pic1.zhimg.com/80/v2-8e77b2771314f674cccba5581560d333_xl.jpg?source=4e949a73' alt='' />
</div>
</div>
<div className='question-item'>
<div className='left'>
<h3>作为 IT 行业的过来人,你有什么话想对后辈说的?</h3>
<div className='info'>
<span>赞同 1000+</span>
<span>评论 500+</span>
<span>1小时前</span>
</div>
</div>
<div className='right'>
<img src='https://pic1.zhimg.com/80/v2-8e77b2771314f674cccba5581560d333_xl.jpg?source=4e949a73' alt='' />
</div>
</div>
<div className='question-item'>
<div className='left'>
<h3>作为 IT 行业的过来人,你有什么话想对后辈说的?</h3>
<div className='info'>
<span>赞同 1000+</span>
<span>评论 500+</span>
<span>1小时前</span>
</div>
</div>
<div className='right'>
<img src='https://pic1.zhimg.com/80/v2-8e77b2771314f674cccba5581560d333_xl.jpg?source=4e949a73' alt='' />
</div>
</div>
<div className='question-item'>
<div className='left'>
<h3>作为 IT 行业的过来人,你有什么话想对后辈说的?</h3>
<div className='info'>
<span>赞同 1000+</span>
<span>评论 500+</span>
<span>1小时前</span>
</div>
</div>
<div className='right'>
<img src='https://pic1.zhimg.com/80/v2-8e77b2771314f674cccba5581560d333_xl.jpg?source=4e949a73' alt='' />
</div>
</div>
<div className='question-item'>
<div className='left'>
<h3>作为 IT 行业的过来人,你有什么话想对后辈说的?</h3>
<div className='info'>
<span>赞同 1000+</span>
<span>评论 500+</span>
<span>1小时前</span>
</div>
</div>
<div className='right'>
<img src='https://pic1.zhimg.com/80/v2-8e77b2771314f674cccba5581560d333_xl.jpg?source=4e949a73' alt='' />
</div>
</div>
</div>
</div>
)
}

export default Question

pages/Question/index.module.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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@import '@scss/hairline.scss';

.root {
:global {
.question-list {
padding: 0 16px;
}

.question-item {
position: relative;
display: flex;
padding: 16px 0;
@include hairline(bottom, #f0f0f0);
}

.left {
padding-right: 16px;

h3 {
margin-bottom: 16px;
line-height: 1.4;
font-size: 16px;
font-weight: normal;

display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

.info {
font-size: 12px;
color: #999;

span {
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
}
}

.right {
width: 114px;
height: 78px;

img {
width: 114px;
height: 78px;
border-radius: 4px;
}
}
}
}

Video

pages/Video/index.tsx

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
import { NavBar } from 'antd-mobile'
import styles from './index.module.scss'

const Video = () => {
return (
<div className={styles.root}>
<NavBar>视频</NavBar>

<div className='video-list'>
<div className='video-item'>
<h3 className='title'>格力电器将继续发展手机业务,并将向全产业覆盖!</h3>
<div className='play'>
<video src='https://ips.ifeng.com/video19.ifeng.com/video09/2021/05/26/p6803231351488126976-102-8-161249.mp4?reqtype=tsl&vid=2c791e3b-444e-4578-83e3-f4808228ae3b&uid=0puFR4&from=v_Free&pver=vHTML5Player_v2.0.0&sver=&se=&cat=&ptype=&platform=pc&sourceType=h5&dt=1622096387396&gid=6a4poXmsep1E&sign=39f76885daca6503ebf90acbfffc1ff1&tm=1622096387396'></video>
</div>
<span>1563次播放</span>
</div>

<div className='video-item'>
<h3 className='title'>你用上5G了吗?我国5G手机终端达3.1亿 占全球比例超80%</h3>
<div className='play'>
<video src='https://ips.ifeng.com/video19.ifeng.com/video09/2021/05/26/p6803268684325330944-102-8-184104.mp4?reqtype=tsl&vid=ec74b1e4-d1fa-488b-aaf5-71984ca7d13e&uid=1Vun5L&from=v_Free&pver=vHTML5Player_v2.0.0&sver=&se=&cat=&ptype=&platform=pc&sourceType=h5&dt=1622096310639&gid=fg3vsXmseXFv&sign=38e7c790561e1fd1b57e61a1cbd8031c&tm=1622096310639'></video>
</div>
<span>1563次播放</span>
</div>
</div>
</div>
)
}

export default Video

pages/Video/index.module.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
@import '@scss/hairline.scss';

.root {
:global {
.video-list {
padding: 0 16px;
}
.video-item {
position: relative;
padding: 10px 0;
@include hairline(bottom, #f0f0f0);

span {
font-size: 12px;
color: #999;
}
}
.title {
line-height: 1.4;
font-size: 16px;
font-weight: normal;
padding-bottom: 5px;
}

video {
width: 100%;
border-radius: 4px;
}
}
}

Profile

pages/Profile/index.tsx

1
2
3
4
5
6
7
import styles from './index.module.scss'

const Profile = () => {
return <div className={styles.root}>Profile</div>
}

export default Profile

pages/Profile/index.module.scss

1
2
.root {
}

步骤

  1. 粘贴素材中【布局页面模板】文件夹中的 Home、Question、Video、Profile 到项目的 pages 目录。

  2. pages/Layout/index.tsx 文件中配置路由出口。

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
import styles from './index.module.scss'
import { TabBar } from 'antd-mobile'
import Icon from '@/components/Icon'
// !#1
import { useHistory, useLocation, Switch, Route } from 'react-router-dom'
import Home from '@/pages/Home'
import Question from '@/pages/Question'
import Video from '@/pages/Video'
import Profile from '@/pages/Profile'

const tabs = [
{ path: '/home', icon: 'iconbtn_home', text: '首页' },
{ path: '/home/question', icon: 'iconbtn_qa', text: '问答' },
{ path: '/home/video', icon: 'iconbtn_video', text: '视频' },
{ path: '/home/profile', icon: 'iconbtn_mine', text: '我的' },
]

export default function Layout() {
const history = useHistory()
const location = useLocation()
const changeRoute = (path: string) => {
history.push(path)
}
return (
<div className={styles.root}>
{/* //!#2: 注意 Home 组件不要忘记加 exact */}
<Switch>
<Route path='/home' exact component={Home} />
<Route path='/home/question' exact component={Question} />
<Route path='/home/video' exact component={Video} />
<Route path='/home/profile' exact component={Profile} />
</Switch>
<TabBar className='tab-bar' onChange={changeRoute} activeKey={location.pathname}>
{tabs.map((item) => (
<TabBar.Item key={item.path} icon={(active: boolean) => <Icon type={active ? `${item.icon}_sel` : item.icon} />} title={item.text} />
))}
</TabBar>
</div>
)
}

渲染个人中心

结构与样式

目标

能够根据模板搭建个人中心页面结构。

步骤

pages/Profile/index.tsx,直接粘贴下面结构与样式。

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
86
87
88
89
import { Link, useHistory } from 'react-router-dom'

import Icon from '@/components/Icon'
import styles from './index.module.scss'

const Profile = () => {
const history = useHistory()

return (
<div className={styles.root}>
<div className='profile'>
{/* 个人信息 */}
<div className='user-info'>
<div className='avatar'>
<img src={'http://toutiao.itheima.net/images/user_head.jpg'} alt='' />
</div>
<div className='user-name'>黑马先锋</div>
<Link to='/profile/edit'>
个人信息 <Icon type='iconbtn_right' />
</Link>
</div>

{/* 今日阅读 */}
<div className='read-info'>
<Icon type='iconbtn_readingtime' />
今日阅读
<span>10</span>
分钟
</div>

{/* 动态 - 对应的这一行 */}
<div className='count-list'>
<div className='count-item'>
<p>1</p>
<p>动态</p>
</div>
<div className='count-item'>
<p>9</p>
<p>关注</p>
</div>
<div className='count-item'>
<p>99</p>
<p>粉丝</p>
</div>
<div className='count-item'>
<p>200</p>
<p>被赞</p>
</div>
</div>

{/* 消息通知 - 对应的这一行 */}
<div className='user-links'>
<div className='link-item'>
<Icon type='iconbtn_mymessages' />
<div>消息通知</div>
</div>
<div className='link-item'>
<Icon type='iconbtn_mycollect' />
<div>收藏</div>
</div>
<div className='link-item'>
<Icon type='iconbtn_history1' />
<div>浏览历史</div>
</div>
<div className='link-item'>
<Icon type='iconbtn_myworks' />
<div>我的作品</div>
</div>
</div>
</div>
{/* 更多服务 */}
<div className='more-service'>
<h3>更多服务</h3>
<div className='service-list'>
<div className='service-item'>
<Icon type='iconbtn_feedback' />
<div>用户反馈</div>
</div>
<div className='service-item' onClick={() => history.push('/chat')}>
<Icon type='iconbtn_xiaozhitongxue' />
<div>小智同学</div>
</div>
</div>
</div>
</div>
)
}

export default Profile

pages/Profile/index.module.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
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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
.root {
height: 100%;
background-color: #f8f8f8;

:global {
.profile {
height: 299px;
padding: 0 15px;
border-bottom-left-radius: 500px 120px;
border-bottom-right-radius: 500px 120px;
background: linear-gradient(318deg, #b2b5db 2%, #565482 85%, #494675 97%);
}

.user-info {
display: flex;
align-items: center;
padding: 40px 0 15px;

.user-name {
flex: 1;
margin-left: 18px;
font-size: 24px;
font-weight: 400;
color: #fff;
}

a {
line-height: 16px;
color: #fff;
font-size: 12px;
text-decoration: none;

&:visited {
color: #fff;
}
}

.icon {
width: 9px;
height: 16px;
vertical-align: middle;
}
}

.avatar {
width: 50px;
height: 50px;
border: 3px solid rgba(255, 255, 255, 0.16);
border-radius: 50%;
overflow: hidden;

img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

.read-info {
display: flex;
align-items: center;
width: fit-content;
height: 30px;
padding: 5px 8px 5px 5px;
border-radius: 15px;
font-size: 12px;
color: #f7f8fa;
background: linear-gradient(319deg, #585089 0%, #2c2a6b 98%);

.icon {
margin-right: 5px;
font-size: 20px;
}

span {
display: inline-block;
margin: 0 3px;
font-size: 18px;
font-weight: bold;
color: #ffbc3d;
}
}

.count-list {
display: flex;
justify-content: space-between;
padding: 20px 0 15px 0;

.count-item {
flex: 1;
text-align: center;
font-size: 13px;
color: #fff;

p {
&:first-child {
height: 14px;
margin-bottom: 6px;
}
}
}
}

.user-links {
display: flex;

padding: 20px 0;
font-size: 12px;
text-align: center;
background-color: #fff;
border-radius: 10px;
color: #333;

.link-item {
flex: 1;
}

.icon {
margin-bottom: 11px;
font-size: 23px;
}
}

.more-service {
// height: 120px;
margin: 11px 16px 0 16px;
border-radius: 10px;
background-color: #fff;

h3 {
padding: 15px;
font-size: 17px;
color: #333;
}

.service-list {
display: flex;
}

.service-item {
width: 25%;
padding-bottom: 20px;
text-align: center;
font-size: 13px;
}

.icon {
margin-bottom: 11px;
font-size: 22px;
}
}
}
}

获取用户信息

目标

完成获取用户信息的功能。

分析

  1. 先按照接口的返回数据,准备 TS 类型。

  2. 然后,在发送请求时,指定该请求的返回值类型。

  3. 在接下来的操作中,如果需要用到接口的数据,都会有类型提示了。

types/data.d.ts

1
2
3
4
5
6
7
8
9
export type User = {
id: string
name: string
photo: string
art_count: number
follow_count: number
fans_count: number
like_count: number
}

步骤

  1. 定义 action。

actions/profile.ts

1
2
3
4
5
6
7
8
9
10
import { ApiResponse, User } from '@/types/data'
import { RootThunkAction } from '@/types/store'
import request from '@/utils/request'

export function getUser(): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<User>>('/user')
console.log(res)
}
}
  1. 触发 action。

pages/Profile/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useEffect } from 'react'
import { Link, useHistory } from 'react-router-dom'
import { useDispatch } from 'react-redux'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
// !#1
import { getUser } from '@/store/actions/profile'

const Profile = () => {
const history = useHistory()
const dispatch = useDispatch()
// !#2
useEffect(() => {
dispatch(getUser())
}, [dispatch])
}

export default Profile
  1. 配置请求拦截器统一携带 Token。

utils/request.ts

1
2
3
4
5
6
7
8
9
10
11
12
request.interceptors.request.use(
function (config) {
const token = getToken().token
if (token) {
config.headers!.Authorization = `Bearer ${token}`
}
return config
},
function (error) {
return Promise.reject(error)
}
)

存储用户信息到 Redux

目标

能够将用户信息保存到 Redux 中。

代码

  1. 准备保存状态到 Redux 的 action 类型。

types/store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import store from '@/store'
import { ThunkAction } from 'redux-thunk'
import { Token } from './data'

// 各个默认的 action
export type LoginAction = {
type: 'login/login'
payload: Token
}
// #1
export type ProfileAction = {
type: 'profile/getUser'
payload: User
}

// store 的 state 的类型
export type RootState = ReturnType<typeof store.getState>
// 所有的 action 的类型
// #2
export type RootAction = LoginAction | ProfileAction
// thunkAction 类型
export type RootThunkAction = ThunkAction<void, RootState, unknown, RootAction>
  1. 在获取个人信息的 action 中将用户信息存储到 Redux 中。

actions/profile.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { ApiResponse, User } from '@/types/data'
import { RootThunkAction } from '@/types/store'
import request from '@/utils/request'

export function getUser(): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<User>>('/user')
const { data } = res.data
dispatch({
type: 'profile/getUser',
payload: data,
})
}
}
  1. 创建 reducers/profile.ts,并完成存储用户信息的功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { User } from '@/types/data'
import { ProfileAction } from '@/types/store'
type ProfileState = { user: User }
const initState = { user: {} } as ProfileState // 建议再加一个 `user: {}`,防止后面报错
// const initState: ProfileState = { user: {} } as ProfileState // 建议再加一个 `user: {}`,防止后面报错
// const initState: ProfileState = { user: {} as User } // 也 ok

const profile = (state = initState, action: ProfileAction): ProfileState => {
switch (action.type) {
case 'profile/getUser':
return {
...state,
user: action.payload,
}
default:
return state
}
}
export default profile
  1. 合并 profileReducer 到 rootReducer。

reducers/index.ts

1
2
3
4
import { combineReducers } from 'redux'
import login from './login'
import profile from './profile'
export default combineReducers({ login, profile })

渲染用户信息

目标

能够展示用户信息到界面。

步骤

  1. 导入 useSelector。

  2. 调用 useSelector 获取 user 状态。

  3. 从 user 对象中解构出用户数据并展示在页面中。

代码

pages/Profile/index.tsx

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
86
87
88
89
90
91
92
93
94
95
import { useEffect } from 'react'
import { Link, useHistory } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { getUser } from '@/store/actions/profile'
import { RootState } from '@/types/store'
const Profile = () => {
const history = useHistory()
const dispatch = useDispatch()
const user = useSelector((state: RootState) => state.profile.user)
useEffect(() => {
dispatch(getUser())
}, [dispatch])
return (
<div className={styles.root}>
<div className='profile'>
{/* 个人信息 */}
<div className='user-info'>
<div className='avatar'>
<img src={user.photo} alt='' />
</div>
<div className='user-name'>{user.name}</div>
<Link to='/profile/edit'>
个人信息 <Icon type='iconbtn_right' />
</Link>
</div>

{/* 今日阅读 */}
<div className='read-info'>
<Icon type='iconbtn_readingtime' />
今日阅读
<span>10</span>
分钟
</div>

{/* 动态 - 对应的这一行 */}
<div className='count-list'>
<div className='count-item'>
<p>{user.art_count}</p>
<p>动态</p>
</div>
<div className='count-item'>
<p>{user.follow_count}</p>
<p>关注</p>
</div>
<div className='count-item'>
<p>{user.fans_count}</p>
<p>粉丝</p>
</div>
<div className='count-item'>
<p>{user.like_count}</p>
<p>被赞</p>
</div>
</div>

{/* 消息通知 - 对应的这一行 */}
<div className='user-links'>
<div className='link-item'>
<Icon type='iconbtn_mymessages' />
<div>消息通知</div>
</div>
<div className='link-item'>
<Icon type='iconbtn_mycollect' />
<div>收藏</div>
</div>
<div className='link-item'>
<Icon type='iconbtn_history1' />
<div>浏览历史</div>
</div>
<div className='link-item'>
<Icon type='iconbtn_myworks' />
<div>我的作品</div>
</div>
</div>
</div>
{/* 更多服务 */}
<div className='more-service'>
<h3>更多服务</h3>
<div className='service-list'>
<div className='service-item'>
<Icon type='iconbtn_feedback' />
<div>用户反馈</div>
</div>
<div className='service-item' onClick={() => history.push('/chat')}>
<Icon type='iconbtn_xiaozhitongxue' />
<div>小智同学</div>
</div>
</div>
</div>
</div>
)
}

export default Profile

渲染个人信息

页面结构

素材

将【个人中心模板】中的 Edit 文件夹拷贝到 Profile 项目的目录中。

pages/Profile/Edit/index.tsx

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
import { Button, List, DatePicker, NavBar } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import classNames from 'classnames'
import styles from './index.module.scss'
const Item = List.Item

const ProfileEdit = () => {
const history = useHistory()
return (
<div className={styles.root}>
<div className='content'>
{/* 标题 */}
<NavBar
style={{
'--border-bottom': '1px solid #F0F0F0',
}}
onBack={() => history.go(-1)}
>
个人信息
</NavBar>

<div className='wrapper'>
{/* 列表 */}
<List className='profile-list'>
{/* 列表项 */}
<Item
extra={
<span className='avatar-wrapper'>
<img width={24} height={24} src={'http://toutiao.itheima.net/images/user_head.jpg'} alt='' />
</span>
}
arrow
>
头像
</Item>
<Item arrow extra={'黑马先锋'}>
昵称
</Item>
<Item arrow extra={<span className={classNames('intro', 'normal')}>{'未填写'}</span>}>
简介
</Item>
</List>

<List className='profile-list'>
<Item arrow extra={'男'}>
性别
</Item>
<Item arrow extra={'1999-9-9'}>
生日
</Item>
</List>

<DatePicker visible={false} value={new Date()} title='选择年月日' min={new Date(1900, 0, 1, 0, 0, 0)} max={new Date()} />
</div>

<div className='logout'>
<Button className='btn'>退出登录</Button>
</div>
</div>
</div>
)
}

export default ProfileEdit

pages/Profile/Edit/index.module.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
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
86
87
88
89
90
91
92
93
94
.root {
height: 100%;

:global {
.content {
position: relative;
z-index: 1;
height: 100%;
}

.wrapper {
background-color: #f7f8fa;
}

.profile-list {
font-size: 14px;

.adm-list-item {
padding-left: 17px;
}

.adm-list-item-content-extra {
padding-right: 13px;
color: #3a3948;

.avatar-wrapper {
display: inline-block;
border-radius: 50%;
overflow: hidden;
vertical-align: middle;

img {
display: block;
object-fit: cover;
}
}

.intro {
color: #c8c9cc;

&.normal {
color: #3a3948;
}
}
}

.adm-list-item-content-arrow {
padding-left: 0;
}

&:nth-child(2) {
margin-top: 12px;
}
}

.logout {
position: absolute;
bottom: 60px;
width: 100%;
height: 44px;
padding: 0 16px;

.btn {
width: 100%;
height: 100%;
border-radius: 4px;
color: #fff;
font-size: 16px;
background: linear-gradient(315deg, #fe4f4f, #fc6627);
}
}
}
}

// popup 不在当前组件结构中,因此,只能设置全局样式
:global {
.adm-picker-popup-body {
.adm-picker-popup-header {
background-size: 0;
&::after {
display: none !important;
}
}
}
.adm-picker-header {
.adm-picker-header-button {
font-size: 14px;
color: #969799;
}
.adm-picker-header-button:nth-last-child(1) {
color: #fc6627;
}
}
}

步骤

  1. App.tsx 中配置个人信息页面的路由。
1
<Route path='/profile/edit' component={ProfileEdit} />
  1. App.scss 中调整下字体大小。
1
2
3
4
5
6
7
8
9
10
11
12
13
.app {
height: 100%;
.adm-list-default {
border: none;
font-size: 16px;
}
.adm-nav-bar-title {
font-size: 17px;
}
.adm-nav-bar-back-arrow {
font-size: 17px;
}
}

获取并渲染

  1. types/data.d.ts 中,根据接口准备好返回数据类型。
1
2
3
4
5
6
7
8
9
export type UserProfile = {
id: string
photo: string
name: string
mobile: string
gender: number
birthday: string
intro: string
}
  1. types/store.d.ts 中创建相应的 Redux action 类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import store from '@/store'
import { ThunkAction } from 'redux-thunk'
import { Token } from './data'

export type LoginAction = {
type: 'login/login'
payload: Token
}
// #mark
export type ProfileAction =
| {
type: 'profile/getUser'
payload: User
}
| {
type: 'profile/getUserProfile'
payload: UserProfile
}

export type RootState = ReturnType<typeof store.getState>
export type RootAction = LoginAction | ProfileAction
export type RootThunkAction = ThunkAction<void, RootState, unknown, RootAction>
  1. actions/profile.ts 中,创建获取编辑时的个人信息的 action 并进行 dispatch。
1
2
3
4
5
6
7
8
9
export const getUserProfile = (): RootThunkAction => {
return async (dispatch) => {
const res = await request.get<ApiResponse<UserProfile>>('/user/profile')
dispatch({
type: 'profile/getUserProfile',
payload: res.data.data,
})
}
}
  1. 在 reducers 中处理该 action,并将状态存储到 Redux 中。

reducers/profile.ts

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
// #1
import { User, UserProfile } from '@/types/data'
import { ProfileAction } from '@/types/store'
// #2
type ProfileState = { user: User; userProfile: UserProfile }
// #3
const initState = { user: {}, userProfile: {} } as ProfileState

const profile = (state = initState, action: ProfileAction): ProfileState => {
switch (action.type) {
case 'profile/getUser':
return {
...state,
user: action.payload,
}
// #4
case 'profile/getUserProfile':
return {
...state,
userProfile: action.payload,
}
default:
return state
}
}
export default profile
  1. Profile/Edit/index.tsx 获取信息并渲染。
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
import { useEffect } from 'react'
import { Button, List, DatePicker, NavBar } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import classNames from 'classnames'
import styles from './index.module.scss'
import { getUserProfile } from '@/store/actions/profile'
import { RootState } from '@/types/store'
const Item = List.Item

const ProfileEdit = () => {
const history = useHistory()
const dispatch = useDispatch()
const { userProfile } = useSelector((state: RootState) => state.profile)
useEffect(() => {
dispatch(getUserProfile())
}, [dispatch])
return (
<div className={styles.root}>
<div className='content'>
{/* 标题 */}
<NavBar
style={{
'--border-bottom': '1px solid #F0F0F0',
}}
onBack={() => history.go(-1)}
>
个人信息
</NavBar>

<div className='wrapper'>
{/* 列表 */}
<List className='profile-list'>
{/* 列表项 */}
<Item
extra={
<span className='avatar-wrapper'>
<img width={24} height={24} src={userProfile.photo} alt='' />
</span>
}
arrow
>
头像
</Item>
<span arrow extra={userProfile.name}>
昵称
</Item>
<Item arrow extra={<span className={classNames('intro', userProfile.intro && 'normal')}>{userProfile.intro || '未填写'}</span>}>
简介
</Item>
</List>

<List className='profile-list'>
<Item arrow extra={userProfile.gender === 0 ? '男' : '女'}>
性别
</Item>
<Item arrow extra={userProfile.birthday}>
生日
</Item>
</List>

<DatePicker visible={false} value={new Date()} title='选择年月日' min={new Date(1900, 0, 1, 0, 0, 0)} max={new Date()} />
</div>

<div className='logout'>
<Button className='btn'>退出登录</Button>
</div>
</div>
</div>
)
}

export default ProfileEdit

自定义 Hook

概述

目标

能够知道什么是自定义 Hooks。

内容

  • 除了使用 React 提供的 Hooks 之外,开发者还可以创建自己的 Hooks,也就是自定义 Hooks。

  • 问题:为什么要创建自定义 Hooks?

  • 回答:实现状态逻辑复用,也就是将与状态相关的逻辑代码封装到一个函数中,哪个地方用到了,哪个地方调用即可。

  • 自定义 Hooks 的特点如下。

    a,名称必须以 use 开头。

    b,和内置的 React Hooks 一样,自定义 Hooks 也是一个函数。

1
2
3
4
5
// 创建自定义 Hooks 函数
const useXxx = (params) => {
// 需要复用的状态逻辑代码
return xxx
}
1
2
3
4
// 使用自定义 Hooks 函数
const Hello = () => {
const xxx = useXxx(...)
}

🤔 自定义 Hooks 就是一个函数,可以完全按照对函数的理解,来理解自定义 Hooks,参数和返回值都可以可选的,可以提供也可以不提供,根据实际需求来实现即可。

总结

  • 自定义 Hooks 是函数吗?

  • 自定义 Hooks 的名称有什么约束?

useInitState

目标

能够通过封装自定义 Hook 实现进入页面就获取数据的功能。

内容

函数封装的基本思想:将相同的逻辑直接拷贝到函数中,不同的逻辑通过函数参数传入,外部需要用到的数据,就通过函数返回值返回。

  • 分发的 action 函数不同,获取的状态不同。

  • 所以,只需要把这两点作为自定义 Hook 的参数即可,最后把状态返回。

utils/hooks.ts

1
2
3
4
5
6
7
8
9
10
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
export function useInitState(action: any, stateName: any) {
const dispatch = useDispatch()
useEffect(() => {
dispatch(action())
}, [dispatch, action])
const state = useSelector((state: any) => state[stateName])
return state
}

pages/Profile/Edit.tsx

1
2
3
4
5
6
7
8
9
10
11
const ProfileEdit = () => {
const history = useHistory()
/* const dispatch = useDispatch()
const { userProfile } = useSelector((state: RootState) => state.profile)
useEffect(() => {
dispatch(getUserProfile())
}, [dispatch]) */
const { userProfile } = useInitState(getUserProfile, 'profile')
}

export default ProfileEdit

useInitState 类型

目标

能够为实现的自定义 Hook 添加类型。

分析

对于 useInitState 这个自定义 Hook 来说,只需要为参数指定类型即可。

  • 参数 action。

    就是一个函数,所以,直接指定为最简单的函数类型即可。

  • 参数 stateName。

    a,stateName 表示从 Redux 状态中取出的状态名称,比如,'profile'

    b,所以,stateName 应该是 RootState 中的所有状态名称中的任意一个。

    c,但是,具体是哪一个不确定,只有在使用该函数时才能确定下来。

    d,问题:如果一个类型不确定,应该是什么 TS 中的什么类型来实现?

代码

utils/hooks.ts

1
2
3
4
5
6
7
8
9
10
11
import { RootState } from '@/types/store'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
export function useInitState(action: () => void, stateName: 'login' | 'profile') {
const dispatch = useDispatch()
useEffect(() => {
dispatch(action())
}, [dispatch, action])
const state = useSelector((state: RootState) => state[stateName])
return state
}

问题:pages/Profile/Edit/index.tsx 中会有错误。

1
2
// 这里 TS 并没有根据传递的 profile 参数识别出来返回值,useInitState 的返回值可能是 Token | ProfileState
const { userProfile } = useInitState(getUserProfile, 'profile')

解决如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { RootState } from '@/types/store'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
// T 就来自于 RootState 中的某个 key,让 T 和 RootState 产生了关系
// 还有一个好处就是,即便 RootState 中的内容变多了,这里也是自动适配的
export function useInitState<T extends keyof RootState>(action: () => void, stateName: T) {
const dispatch = useDispatch()
useEffect(() => {
dispatch(action())
}, [dispatch, action])
const state = useSelector((state: RootState) => state[stateName])
return state
}

src/pages/Profile/index.tsx 也可以改写如下。

1
2
3
4
5
6
7
8
9
10
import { useInitState } from '@/utils/hooks'
const Profile = () => {
const history = useHistory()
/* const dispatch = useDispatch()
const user = useSelector((state: RootState) => state.profile.user)
useEffect(() => {
dispatch(getUser())
}, [dispatch]) */
const { user } = useInitState(getUser, 'profile')
}

修改昵称和简介

显示弹层

  1. 准备弹层。

src/pages/Profile/Edit/index.tsx

1
2
3
<Popup visible={true} position='right'>
<div style={{ height: 400, width: '80vw' }}>xxx</div>
</Popup>
  1. 准备状态,用于控制弹层的显示。
1
2
3
4
5
6
7
8
type InputState = {
visible: boolean
type: '' | 'name' | 'intro'
}
const [showInput, setShowInput] = useState<InputState>({
type: '',
visible: false,
})
  1. 点击昵称和简介的时候,需要弹层并设置 type。
1
2
3
4
5
6
7
8
9
10
11
12
<Item
arrow
extra={userProfile.name}
onClick={() =>
setShowInput({
visible: true,
type: 'name',
})
}
>
昵称
</Item>

准备 EditInput 组件

目标

准备 EditInput 组件,点击返回的时候隐藏弹层。

准备 EditInput 组件

src/pages/Profile/Edit/EditInput/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Input, NavBar } from 'antd-mobile'

import styles from './index.module.scss'

const EditInput = () => {
return (
<div className={styles.root}>
<NavBar className='navbar' right={<span className='commit-btn'>提交</span>}>
编辑昵称
</NavBar>

<div className='edit-input-content'>
<h3>昵称</h3>

<div className='input-wrap'>
<Input placeholder='请输入' />
</div>
</div>
</div>
)
}

export default EditInput

src/pages/Profile/Edit/EditInput/index.module.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
38
39
40
41
42
43
44
45
46
47
48
49
50
.root {
width: 375px;
height: 100%;
background-color: #f7f8fa;

:global {
.navbar {
background-color: transparent;
}

.commit-btn {
color: #fc6627;
font-size: 17px;
}

.edit-input-content {
padding: 0 16px;

h3 {
padding: 15px 0;
font-size: 17px;
color: #333;
}

.input-wrap {
height: 44px;
padding-left: 16px;

background-color: #fff;

input {
height: 44px;
font-size: 14px;
}
}

.textarea {
--font-size: 14px;
padding: 16px;
padding-bottom: 8px;
border-radius: 4px;
background-color: #fff;

.adm-text-area-count {
font-size: 12px;
}
}
}
}
}

点击返回隐藏弹层

src/pages/Profile/Edit/index.tsx

1
2
3
4
5
6
7
8
const hideInput = () => {
setShowInput({
type: '',
visible: false,
})
}

;<EditInput hideInput={hideInput}></EditInput>

src/pages/Profile/Edit/EditInput/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Input, NavBar } from 'antd-mobile'
import styles from './index.module.scss'
type Props = {
hideInput: () => void
}
const EditInput = ({ hideInput }: Props) => {
return (
<div className={styles.root}>
<NavBar className='navbar' right={<span className='commit-btn'>提交</span>} onBack={hideInput}>
编辑昵称
</NavBar>

<div className='edit-input-content'>
<h3>昵称</h3>

<div className='input-wrap'>
<Input placeholder='请输入' />
</div>
</div>
</div>
)
}

export default EditInput

修改标题文字

  1. 传递 type 属性。
1
<EditInput hideInput={hideInput} type={showInput.type} />
  1. 根据 type 控制内容的展示。
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
import { Input, NavBar, TextArea } from 'antd-mobile'
import styles from './index.module.scss'
type Props = {
hideInput: () => void
// #1
type: '' | 'name' | 'intro'
}
// #2
const EditInput = ({ hideInput, type }: Props) => {
return (
<div className={styles.root}>
<NavBar className='navbar' right={<span className='commit-btn'>提交</span>} onBack={hideInput}>
{/* #3 */}
编辑{type === 'name' ? '昵称' : '简介'}
</NavBar>

<div className='edit-input-content'>
{/* #4 */}
<h3>{type === 'name' ? '昵称' : '简介'}</h3>

{/* #5 */}
{type === 'name' ? (
<div className='input-wrap'>
<Input placeholder='请输入' />
</div>
) : (
<TextArea className='textarea' placeholder='请输入简介' showCount maxLength={99} />
)}
</div>
</div>
)
}

export default EditInput

数据回显

目标

能够在弹出层文本框中展示昵称或者简介。

代码

Edit/EditInput/index.tsx

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
import { useState } from 'react'
import { RootState } from '@/types/store'
import { Input, NavBar, TextArea } from 'antd-mobile'
import { useSelector } from 'react-redux'
import styles from './index.module.scss'
type Props = {
hideInput: () => void
type: '' | 'name' | 'intro'
}
const EditInput = ({ hideInput, type }: Props) => {
// #1
const { userProfile } = useSelector((state: RootState) => state.profile)
// #2
const [value, setValue] = useState(type === 'name' ? userProfile.name : userProfile.intro)
return (
<div className={styles.root}>
<NavBar className='navbar' right={<span className='commit-btn'>提交</span>} onBack={hideInput}>
编辑{type === 'name' ? '昵称' : '简介'}
</NavBar>

<div className='edit-input-content'>
<h3>{type === 'name' ? '昵称' : '简介'}</h3>

{/* #3 */}
{type === 'name' ? (
<div className='input-wrap'>
<Input placeholder='请输入昵称' value={value} onChange={(v) => setValue(v)} maxLength={11} />
</div>
) : (
<TextArea className='textarea' placeholder='请输入简介' showCount maxLength={99} value={value} onChange={(v) => setValue(v)} />
)}
</div>
</div>
)
}

export default EditInput

修复回显 Bug

  • 默认情况,关闭弹层之后,EditInput 组件并没有销毁,导致 useState 中的初始值永远是最初获取的那一次。

  • 通过 destroyOnClose 可以保证关闭弹层时销毁组件。

pages/Profile/Edit/index.tsx

1
2
3
<Popup visible={showInput.visible} position='right' onMaskClick={() => setShowInput({ type: '', visible: false })} destroyOnClose>
<EditInput hideInput={hideInput} type={showInput.type} />
</Popup>

自动获取光标

pages/Profile/Edit/EditInput/index.tsx

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
import { useState, useRef, useEffect } from 'react'
import { RootState } from '@/types/store'
import { Input, NavBar, TextArea } from 'antd-mobile'
import { useSelector } from 'react-redux'
import styles from './index.module.scss'
import { InputRef } from 'antd-mobile/es/components/input'
import { TextAreaRef } from 'antd-mobile/es/components/text-area'
type Props = {
hideInput: () => void
type: '' | 'name' | 'intro'
}
const EditInput = ({ hideInput, type }: Props) => {
const { userProfile } = useSelector((state: RootState) => state.profile)
const [value, setValue] = useState(type === 'name' ? userProfile.name : userProfile.intro)
// #1
const inputRef = useRef<InputRef>(null)
const textRef = useRef<TextAreaRef>(null)

// #3
useEffect(() => {
if (type === 'name') {
inputRef.current?.focus()
} else {
textRef.current?.focus()
// 设置光标到特定的位置
document.querySelector('textarea')?.setSelectionRange(-1, -1)
}
}, [type])
return (
<div className={styles.root}>
<NavBar className='navbar' right={<span className='commit-btn'>提交</span>} onBack={hideInput}>
编辑{type === 'name' ? '昵称' : '简介'}
</NavBar>

<div className='edit-input-content'>
<h3>{type === 'name' ? '昵称' : '简介'}</h3>
{/* #2 */}
{type === 'name' ? (
<div className='input-wrap'>
<Input placeholder='请输入昵称' value={value} onChange={(v) => setValue(v)} maxLength={11} ref={inputRef} />
</div>
) : (
<TextArea className='textarea' placeholder='请输入简介' showCount maxLength={99} value={value} onChange={(v) => setValue(v)} ref={textRef} />
)}
</div>
</div>
)
}

export default EditInput

setSelectionRange

1
2
3
4
5
6
7
8
<input type="text" value="Hello World" />
<script>
const oInput = document.querySelector('input')
oInput.focus()
// oInput.setSelectionRange(0, oInput.value.length) // 选中所有
// oInput.setSelectionRange(0, -1) // 第二个参数为负数,此时表示从 0 选到最后
oInput.setSelectionRange(-1, -1) // 从最后选到最后
</script>

回传给父组件

目标

能够在点击提交时拿到输入的信息并回传给父组件。

分析

用户个人信息的状态是在 Edit 父组件中拿到的,所以修改用户个人信息也应该由 Edit 父组件发起,因此,需要将修改后的昵称回传给 Edit 父组件。

步骤

  1. 在 Edit 组件定义 onUpdate 函数并传递给 EditInput 组件。

  2. 在 EditInput 组件中为提交按钮绑定点击事件并调用传递过来的 onUpdate 函数。

  3. 在 Edit 组件中根据 onUpdate 形参做相应的处理。

代码

Edit/index.tsx

1
2
3
4
5
const onUpdate = (key: string, value: string) => {
console.log(key, value)
}

;<EditInput hideInput={hideInput} type={showInput.type} onUpdate={onUpdate} />

Edit/EditInput/index.tsx

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
import { useState, useRef, useEffect } from 'react'
import { RootState } from '@/types/store'
import { Input, NavBar, TextArea } from 'antd-mobile'
import { useSelector } from 'react-redux'
import styles from './index.module.scss'
import { InputRef } from 'antd-mobile/es/components/input'
import { TextAreaRef } from 'antd-mobile/es/components/text-area'
type Props = {
hideInput: () => void
type: '' | 'name' | 'intro'
// #1
onUpdate: (key: string, value: string) => void
}
// #2
const EditInput = ({ hideInput, type, onUpdate }: Props) => {
const { userProfile } = useSelector((state: RootState) => state.profile)
const [value, setValue] = useState(type === 'name' ? userProfile.name : userProfile.intro)
const inputRef = useRef<InputRef>(null)
const textRef = useRef<TextAreaRef>(null)

useEffect(() => {
if (type === 'name') {
inputRef.current?.focus()
} else {
textRef.current?.focus()
document.querySelector('textarea')?.setSelectionRange(-1, -1)
}
}, [type])
return (
<div className={styles.root}>
<NavBar
className='navbar'
right={
// #3
<span className='commit-btn' onClick={() => onUpdate(type, value)}>
提交
</span>
}
onBack={hideInput}
>
编辑{type === 'name' ? '昵称' : '简介'}
</NavBar>

<div className='edit-input-content'>
<h3>{type === 'name' ? '昵称' : '简介'}</h3>
{type === 'name' ? (
<div className='input-wrap'>
<Input placeholder='请输入昵称' value={value} onChange={(v) => setValue(v)} maxLength={11} ref={inputRef} />
</div>
) : (
<TextArea className='textarea' placeholder='请输入简介' showCount maxLength={99} value={value} onChange={(v) => setValue(v)} ref={textRef} />
)}
</div>
</div>
)
}

export default EditInput

发送请求修改

actions/profile.ts

1
2
3
4
5
6
7
8
export function updateUserProfile(key: string, value: string): RootThunkAction {
return async (dispatch) => {
await request.patch('/user/profile', {
[key]: value,
})
dispatch(getUserProfile())
}
}

pages/Profile/Edit/index.tsx

1
2
3
4
5
const onUpdate = async (key: string, value: string) => {
await dispatch(updateUserProfile(key, value))
Toast.show({ icon: 'success', content: '修改成功' })
hideInput()
}

修改头像和性别

弹框控制

准备素材

src/pages/Profile/Edit/EditList/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import styles from './index.module.scss'

const EditList = () => {
return (
<div className={styles.root}>
<div className='list-item'></div>
<div className='list-item'></div>

<div className='list-item'>取消</div>
</div>
)
}

export default EditList

src/pages/Profile/Edit/EditList/index.module.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
.root {
height: 100%;
padding: 8px 0;
background-color: #fff;

:global {
.list-item {
height: 50px;
line-height: 50px;
text-align: center;
font-size: 16px;
color: #333;

&:active {
background-color: #f2f3f5;
}

&:last-child {
color: #a5a6ab;

&::before {
content: ' ';
display: block;
height: 8px;
// width: 100%;
background-color: #f7f8fa;
}
}
}
}
}

准备弹出层

目标

能够显示修改性别弹出层。

步骤
  1. 将准备好的修改性别的模板拷贝到 Edit 目录中。

  2. 导入修改性别组件,在 Popup 组件中渲染。

代码

Edit/index.tsx

1
2
3
4
import EditList from './EditList'
;<Popup visible={true} position='bottom' destroyOnClose>
<EditList />
</Popup>

显示与隐藏

目标

能搞控制修改性别弹出层的展示或隐藏。

分析

修改性别和修改头像的弹出层内容几乎是一样的,因此,也可以复用同一个弹出层组件。因此,接下来要从复用的角度,设计修改性别弹出层的逻辑(可以参考刚刚实现的修改昵称和简介)。

步骤
  1. 准备用于控制修改性别弹出层的状态。

  2. 为性别添加点击事件,在点击事件中修改状态进行展示。

  3. 创建隐藏弹出层的控制函数,在点击遮罩时关闭弹出层,并传递给 EditList 组件。

  4. 为 EditList 组件添加 props 类型,并接收隐藏函数。

  5. 为取消按钮添加点击事件来触发隐藏弹出层。

代码

pages/Profile/Edit/index.tsx

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import { useState } from 'react'
import { Button, List, DatePicker, NavBar, Popup, Toast } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import { useDispatch } from 'react-redux'
import classNames from 'classnames'
import styles from './index.module.scss'
import { getUserProfile, updateUserProfile } from '@/store/actions/profile'
import { useInitState } from '@/utils/hooks'
import EditInput from './EditInput'
import EditList from './EditList'
const Item = List.Item

type InputState = {
visible: boolean
type: '' | 'name' | 'intro'
}
// #1
type ListState = {
visible: boolean
type: '' | 'gender' | 'photo'
}

const ProfileEdit = () => {
const history = useHistory()
const dispatch = useDispatch()
const { userProfile } = useInitState(getUserProfile, 'profile')
const [showInput, setShowInput] = useState<InputState>({
type: '',
visible: false,
})
// #2
const [showList, setShowList] = useState<ListState>({
visible: false,
type: '',
})
const hideInput = () => {
setShowInput({
type: '',
visible: false,
})
}
// #3
const hideList = () => {
setShowList({
type: '',
visible: false,
})
}
const onUpdate = async (key: string, value: string) => {
await dispatch(updateUserProfile(key, value))
Toast.show({ icon: 'success', content: '修改成功' })
hideInput()
}
return (
<div className={styles.root}>
<div className='content'>
{/* 标题 */}
<NavBar
style={{
'--border-bottom': '1px solid #F0F0F0',
}}
onBack={() => history.go(-1)}
>
个人信息
</NavBar>

<div className='wrapper'>
{/* 列表 */}
<List className='profile-list'>
{/* 列表项 */}
{/* #5 */}
<Item
extra={
<span className='avatar-wrapper'>
<img width={24} height={24} src={userProfile.photo} alt='' />
</span>
}
arrow
onClick={() =>
setShowList({
visible: true,
type: 'photo',
})
}
>
头像
</Item>
<Item
arrow
extra={userProfile.name}
onClick={() =>
setShowInput({
visible: true,
type: 'name',
})
}
>
昵称
</Item>
<Item
arrow
extra={<span className={classNames('intro', userProfile.intro && 'normal')}>{userProfile.intro || '未填写'}</span>}
onClick={() =>
setShowInput({
visible: true,
type: 'intro',
})
}
>
简介
</Item>
</List>

<List className='profile-list'>
{/* #6 */}
<Item arrow extra={userProfile.gender === 0 ? '男' : '女'} onClick={() => setShowList({ visible: true, type: 'gender' })}>
性别
</Item>
<Item arrow extra={userProfile.birthday}>
生日
</Item>
</List>

<DatePicker visible={false} value={new Date()} title='选择年月日' min={new Date(1900, 0, 1, 0, 0, 0)} max={new Date()} />
</div>

<div className='logout'>
<Button className='btn'>退出登录</Button>
</div>
</div>
<Popup visible={showInput.visible} position='right' onMaskClick={() => setShowInput({ type: '', visible: false })} destroyOnClose>
<EditInput hideInput={hideInput} type={showInput.type} onUpdate={onUpdate} />
</Popup>
{/* #4 */}
<Popup visible={showList.visible} position='bottom' destroyOnClose onMaskClick={hideList}>
{/* #7 */}
<EditList onClose={hideList} />
</Popup>
</div>
)
}

export default ProfileEdit

pages/Profile/Edit/EditList/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import styles from './index.module.scss'
type Props = {
onClose: () => void
}
const EditList = ({ onClose }: Props) => {
return (
<div className={styles.root}>
<div className='list-item'></div>
<div className='list-item'></div>

<div className='list-item' onClick={onClose}>
取消
</div>
</div>
)
}

export default EditList

内容的回显

  1. 父组件把 type 传给子组件。

src/pages/Profile/Edit/index.tsx

1
<EditList hideList={hideList} type={showList.type}></EditList>
  1. 子组件根据 type 属性控制需要显示的内容。

src/pages/Profile/Edit/EditList/index.tsx

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 styles from './index.module.scss'
type Props = {
onClose: () => void
type: '' | 'gender' | 'photo'
}
const genderList = [
{ title: '男', value: '0' },
{ title: '女', value: '1' },
]

const photoList = [
{ title: '拍照', value: '' },
{ title: '本地选择', value: '' },
]
const EditList = ({ onClose, type }: Props) => {
const list = type === 'gender' ? genderList : photoList
return (
<div className={styles.root}>
{list.map((item) => (
<div key={item.title} className='list-item'>
{item.title}
</div>
))}

<div className='list-item' onClick={onClose}>
取消
</div>
</div>
)
}

export default EditList

修改性别

  1. 父组件把 onUpdate 传递给子组件。
1
2
3
4
5
6
const onUpdate = async (key: string, value: string) => {
await dispatch(updateUserProfile(key, value))
Toast.show({ icon: 'success', content: '修改成功' })
hideInput()
hideList()
}
1
<EditList onClose={hideList} type={showList.type} onUpdate={onUpdate} />
  1. 子组件注册点击事件并调用 onUpdate。
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 styles from './index.module.scss'
type Props = {
onClose: () => void
type: '' | 'gender' | 'photo'
// #1
onUpdate: (key: string, value: string) => void
}
const genderList = [
{ title: '男', value: '0' },
{ title: '女', value: '1' },
]

const photoList = [
{ title: '拍照', value: '' },
{ title: '本地选择', value: '' },
]
// #2
const EditList = ({ onClose, type, onUpdate }: Props) => {
const list = type === 'gender' ? genderList : photoList
return (
<div className={styles.root}>
{/* #3 onClick */}
{list.map((item) => (
<div key={item.title} className='list-item' onClick={() => onUpdate(type, item.value)}>
{item.title}
</div>
))}

<div className='list-item' onClick={onClose}>
取消
</div>
</div>
)
}

export default EditList

修改头像

弹窗选择图片

目标

能够在点击拍照或本地选择时弹窗选择图片。

代码

pages/Profile/Edit/index.tsx

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { useState, useRef } from 'react'
import { Button, List, DatePicker, NavBar, Popup, Toast } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import { useDispatch } from 'react-redux'
import classNames from 'classnames'
import styles from './index.module.scss'
import { getUserProfile, updateUserProfile } from '@/store/actions/profile'
import { useInitState } from '@/utils/hooks'
import EditInput from './EditInput'
import EditList from './EditList'
const Item = List.Item

type InputState = {
visible: boolean
type: '' | 'name' | 'intro'
}
type ListState = {
visible: boolean
type: '' | 'gender' | 'photo'
}

const ProfileEdit = () => {
const history = useHistory()
const dispatch = useDispatch()
const { userProfile } = useInitState(getUserProfile, 'profile')
const [showInput, setShowInput] = useState<InputState>({
type: '',
visible: false,
})
const [showList, setShowList] = useState<ListState>({
visible: false,
type: '',
})
const hideInput = () => {
setShowInput({
type: '',
visible: false,
})
}
const hideList = () => {
setShowList({
type: '',
visible: false,
})
}
// #3
const fileRef = useRef<HTMLInputElement>(null)
const onUpdate = async (key: string, value: string) => {
// #1 修改头像
if (key === 'photo') {
fileRef.current!.click()
return
}
await dispatch(updateUserProfile(key, value))
Toast.show({ icon: 'success', content: '修改成功' })
hideInput()
hideList()
}
return (
<div className={styles.root}>
<div className='content'>
{/* 标题 */}
<NavBar
style={{
'--border-bottom': '1px solid #F0F0F0',
}}
onBack={() => history.go(-1)}
>
个人信息
</NavBar>

<div className='wrapper'>
{/* 列表 */}
<List className='profile-list'>
{/* 列表项 */}
<Item
extra={
<span className='avatar-wrapper'>
<img width={24} height={24} src={userProfile.photo} alt='' />
</span>
}
arrow
onClick={() =>
setShowList({
visible: true,
type: 'photo',
})
}
>
头像
</Item>
<Item
arrow
extra={userProfile.name}
onClick={() =>
setShowInput({
visible: true,
type: 'name',
})
}
>
昵称
</Item>
<Item
arrow
extra={<span className={classNames('intro', userProfile.intro && 'normal')}>{userProfile.intro || '未填写'}</span>}
onClick={() =>
setShowInput({
visible: true,
type: 'intro',
})
}
>
简介
</Item>
</List>

<List className='profile-list'>
<Item arrow extra={userProfile.gender === 0 ? '男' : '女'} onClick={() => setShowList({ visible: true, type: 'gender' })}>
性别
</Item>
<Item arrow extra={userProfile.birthday}>
生日
</Item>
</List>

<DatePicker visible={false} value={new Date()} title='选择年月日' min={new Date(1900, 0, 1, 0, 0, 0)} max={new Date()} />
</div>

<div className='logout'>
<Button className='btn'>退出登录</Button>
</div>
</div>
{/* #2 */}
<input type='file' hidden ref={fileRef} />
<Popup visible={showInput.visible} position='right' onMaskClick={() => setShowInput({ type: '', visible: false })} destroyOnClose>
<EditInput hideInput={hideInput} type={showInput.type} onUpdate={onUpdate} />
</Popup>
<Popup visible={showList.visible} position='bottom' destroyOnClose onMaskClick={hideList}>
<EditList onClose={hideList} type={showList.type} onUpdate={onUpdate} />
</Popup>
</div>
)
}

export default ProfileEdit

获取选择的头像

目标

能够组装修改头像需要的数据。

步骤
  1. 创建函数,监听 input[type=file] 选择文件的变化 change。

  2. 在函数中,创建 FormData 对象。

  3. 根据接口,拿到接口需要规定的参数名,并将选择的文件添加到 FormData 对象中。

代码

pages/Profile/Edit/index.tsx

1
2
3
4
5
6
// e => 移上 e
const onChangePhoto = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files![0]
const fd = new FormData()
fd.append('photo', file)
}
1
<input type='file' hidden ref={fileRef} onChange={onChangePhoto} />

更新头像

目标

能够实现更新头像。

步骤
  1. 在 Edit 组件中,分发修改头像的 action,传入 FormData 对象,并关闭弹出层。

  2. 在 actions 中,创建修改头像的 action,接收到传递过来的 FormData 对象。

  3. 发送请求,更新用户头像。

  4. 分发 action 重新渲染。

代码

actions/profile.ts

1
2
3
4
5
6
export function updateUserPhoto(fd: FormData): RootThunkAction {
return async (dispatch) => {
await request.patch('/user/photo', fd)
dispatch(getUserProfile())
}
}

pages/Profile/Edit/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const onChangePhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files![0]
const fd = new FormData()
fd.append('photo', file)
// #1
await dispatch(updateUserPhoto(fd))
// #2
Toast.show({
icon: 'success',
content: '修改头像成功',
})
// #3
hideList()
}

修改生日

pages/Profile/Edit/index.tsx

  1. 创建状态 showBirthday 用来控制日期选择器的展示或隐藏。
1
const [showBirthday, setShowBirthday] = useState(false)
  1. 将 showBirthday 设置为日期选择器的 visible 属性。
1
<DatePicker visible={showBirthday} title='选择年月日' />
  1. 给生日绑定点击事件,在点击事件中修改 showBirthday 值为 true 来展示日期选择器。
1
2
3
const onBirthdayShow = () => {
setShowBirthday(true)
}
1
2
3
<Item arrow extra={userProfile.birthday} onClick={onBirthdayShow}>
生日
</Item>
  1. 为日期选择器设置 value,值为用户的生日值。
1
<DatePicker visible={showBirthday} title='选择年月日' value={new Date(userProfile.birthday)} />

有可能 value 没有生效,需要指定 min 属性。

1
<DatePicker visible={showBirthday} title='选择年月日' value={new Date(userProfile.birthday)} min={new Date('1900-01-01')} max={new Date()} />
  1. 监听日期组件的 onCancel 事件,隐藏日期选择器。
1
<DatePicker visible={showBirthday} title='选择年月日' onCancel={onBirthdayHide} value={new Date(userProfile.birthday)} min={new Date('1900-01-01')} max={new Date()} />
1
2
3
const onBirthdayHide = () => {
setShowBirthday(false)
}
  1. 修改功能。
1
2
3
4
5
6
7
8
9
10
11
<DatePicker
visible={showBirthday}
title='选择年月日'
onCancel={onBirthdayHide}
value={new Date(userProfile.birthday)}
min={new Date('1900-01-01')}
max={new Date()}
onConfirm={(val) => {
onUpdate('birthday', dayjs(val).format('YYYY-MM-DD'))
}}
/>

退出登录功能

弹窗确认

目标

能够点击退出按钮时弹窗确认是否退出。

分析

不需要自定义样式的情况下,使用 Dialog.confirm 来弹窗确认即可。如果需要自定义弹窗按钮的样式,需要使用 Dialog.show 基础方法来实现。

步骤

  1. 为退出登录按钮绑定点击事件。

  2. 在点击事件中,使用 Dialog 弹窗让用户确认是否退出登录。

代码

pages/Profile/Edit/index.tsx

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
const logoutFn = () => {
Dialog.show({
title: '温馨提示',
content: '你确定要退出吗?',
closeOnAction: true, // 触发操作后自动关闭
actions: [
[
{
key: 'cancel',
text: '取消',
style: {
color: 'blue',
},
},
{
key: 'confirm',
text: '确定',
danger: true,
bold: true,
onClick: () => {
// 1. 清除 Token,包含了本地的和 Redux 中的
dispatch(logout())
// 2. 跳转到登录
history.replace('/login')
// 3. 给一个提示消息
Toast.show({
icon: 'success',
content: '退出成功',
})
},
},
],
],
})
}

功能完成

目标

能够实现退出功能。

步骤

  1. 为退出按钮,绑定点击事件,在点击事件中分发退出 action。

  2. types/store.d.ts 中,创建退出登录的 action 类型。

1
2
3
4
5
6
7
8
export type LoginAction =
| {
type: 'login/login'
payload: Token
}
| {
type: 'login/logout'
}
  1. actions/login.ts 中,创建退出 action 并清理 token。
1
2
3
4
5
6
export function logout(): LoginAction {
removeToken()
return {
type: 'login/logout',
}
}
  1. reducers/login.ts 中,处理退出 action 清空 token。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Token } from '@/types/data'
import { LoginAction } from '@/types/store'
import { getToken } from '@/utils/storage'
const initState: Token = getToken()
const login = (state = initState, action: LoginAction) => {
if (action.type === 'login/login') {
return action.payload
}
// !mark
if (action.type === 'login/logout') {
return {
token: '',
refresh_token: '',
}
}
return state
}
export default login

界面访问控制

鉴权组件封装

目标

能够封装鉴权路由组件实现登录访问控制功能,链接

步骤

  1. 在 components 目录中创建 PrivateRoute 路由组件。

  2. 在 PrivateRoute 组件中,实现路由的登录访问控制逻辑。

  3. 未登录时,重定向到登录页面,并传递要访问的路由地址。

  4. 登录时,直接渲染要访问的路由。

代码

components/PrivateRoute/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { hasToken } from '@/utils/storage'
import { Route, Redirect, RouteProps } from 'react-router-dom'

export default function PrivateRoute({ children, ...rest }: RouteProps) {
return (
<Route
{...rest}
render={({ location }) =>
hasToken() ? (
children
) : (
<Redirect
to={{
pathname: '/login',
state: { from: location.pathname },
}}
/>
)
}
/>
)
}

App.tsx

1
2
3
<PrivateRoute path='/profile/edit'>
<ProfileEdit />
</PrivateRoute>

pages/Layout/index.tsx

1
2
3
<PrivateRoute path='/home/profile' exact>
<Profile />
</PrivateRoute>

登录成功跳转到原页面

目标

能够在登录时根据重定向路径跳转到相应页面。

步骤

  1. 在 Login 组件中导入 useLocation 来获取路由重定向时传入的 state。

  2. 调用 useLocation hook 时,指定 state 的类型。

  3. 登录完成跳转页面时,判断 state 是否存在。

  4. 如果存在,跳转到 state 指定的页面。

  5. 如果不存在,默认跳转到首页。

代码

pages/Login/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 记得定义参数的类型
const location = useLocation<{ from: string }>()

const onFinish = async (values: LoginForm) => {
await dispatch(login(values))
Toast.show({
content: '登录成功',
icon: 'success',
duration: 600,
afterClose() {
// history.push('/home')
history.replace(location.state ? location.state.from : '/home')
},
})
}

刷新 Token

概述

目标

能够理解什么是无感刷新 Token。

分析

一般情况下,我们用到的移动端的 App(比如,微信)只要登录过一次,一般就不需要再次重新登录,除非很长时间没有使用过 App,这是如何做到的呢?这就用到我们要讲的无感刷新 Token 了。

我们知道,登录时会拿到一个登录成功的标识 Token(身份令牌),有了这个令牌就可以进行登录后的操作了,比如,获取个人资料、修改个人信息等等。

但是,为了安全,登录标识 Token 一般都是有有效期的,比如,咱们的极客园项目中 Token 的有效期是 2 个小时,如果不进行额外的处理,2 小时以后,就得再次登录才可以,但是,这种用户体验不好,特别是移动端(不管是 App 还是 H5)。

相对来说,更好的用户体验是前面提到的微信的那种方式,它的原理简单来说是这样的,在登录时,同时拿到两个 Token。

  1. 登录成功的令牌:Token

  2. 刷新 Token 的令牌:refresh_token

刷新 Token 的令牌用来:在 Token 过期后,换取新的 Token(续费),从而实现“永久登录”效果,这就是所谓的:无感刷新 token

思想总结如下。

  • 无 Token,直接跳到登录页。

  • 有 Token,则用 Refresh Token 换新 Token:换成功则用新 Token 重发原先的请求,没换成功则跳到登录页。

实现

目标

能够实现无感刷新 Token 实现自动登录。

分析

概述:在登录超时或者 Token 失效时,也就是服务器接口返回 401,通过 refresh_token 换取新的 Token。

过程如下(以获取个人资料数据为例):

  1. 发送请求获取个人资料数据。

  2. 接口返回 401,也就是 Token 失效了。

  3. 在响应拦截器中统一处理,换取新的 Token。

  4. 将新的 Token 存储到本地缓存中。

  5. 继续发送获取个人资料的请求,完成数据获取,关键点。

  6. 如果整个过程中出现了任意异常,一般来说就是 refresh_token 也过期了,换取 Token 失败,此时,要进行错误处理,也就是: 清除 Token,跳转到登录页面。

axios 请求拦截过程说明:

1
2
3
4
5
6
7
8
1 axios.get()
2 请求拦截器
3 响应代码
4 响应拦截器

以上 4 个步骤的执行顺序:

【1 axios.get()】 --> 【2 请求拦截器】>>> 服务器处理 >>> 【4 响应拦截器】 --> 【3 响应代码】

步骤

  1. 使用 try-catch 处理异常,出现异常时,清除 token,清空 Redux token,跳转到登录页面。

  2. 判断本地存储中,是否有 refresh_token

  3. 如果没有,直接跳转到登录页面,让用户登录即可。

  4. 如果有,就使用 refresh_token 来通过 axios 发送请求,换取新的 token。

  5. 将新获取到的 Token 存储到本地缓存中和 Redux 中。

  6. 继续发送原来的请求。

代码

utils/request.ts

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
request.interceptors.response.use(
function (response) {
return response
},
function (error: AxiosError<{ message: string }>) {
if (!error.response) {
// Network Error
Toast.show('网络繁忙,请稍后重试')
return Promise.reject(error)
}
if (error.response.status === 401) {
const token = getToken()
// #1 没有刷新 refresh_token
if (!token.refresh_token) {
history.replace('/login', {
from: history.location.pathname,
})
Toast.show('登录信息过期')
return Promise.reject(error)
}
// #2 使用 refresh_token 换取新 token
// ...
return Promise.reject(error)
}
Toast.show(error.response.data.message)
return Promise.reject(error)
}
)

utils/history.js

1
2
3
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
export default history

src/App.tsx

1
2
import history from './utils/history'
;<Router history={history}></Router>

actions/login.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 改造了一下,方便复用
export function saveToken(token: Token): LoginAction {
setToken(token)
return {
type: 'login/login',
payload: token,
}
}

export const login = (values: LoginForm): RootThunkAction => {
return async (dispatch) => {
const res = await request.post<ApiResponse<Token>>('/authorizations', values)
/* dispatch({
type: 'login/login',
payload: res.data.data,
})
// !存到本地
setToken(res.data.data) */
dispatch(saveToken(res.data.data))
}
}

utils/request.ts

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
request.interceptors.response.use(
function (response) {
return response
},
async function (error: AxiosError<{ message: string }>) {
if (!error.response) {
// Network Error
Toast.show('网络繁忙,请稍后重试')
return Promise.reject(error)
}
if (error.response.status === 401) {
const token = getToken()
// !#1 没有刷新 refresh_token
if (!token.refresh_token) {
history.replace('/login', {
from: history.location.pathname,
})
Toast.show('登录信息过期')
return Promise.reject(error)
}
// !#2 使用 refresh_token 换取新 token
// axios.put('/authorizations', null, { headers: {} })
const res = await axios.request<ApiResponse<{ token: string }>>({
method: 'put',
url: '/authorizations',
baseURL,
headers: {
Authorization: `Bearer ${token.refresh_token}`,
},
})
// !#3 存储到本地和 Redux
store.dispatch(
saveToken({
token: res.data.data.token,
refresh_token: token.refresh_token,
})
)
// !#4 重新发请求
return request.request(error.config)
}
Toast.show(error.response.data.message)
return Promise.reject(error)
}
)

刷新 Token 失败的处理。

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
request.interceptors.response.use(
function (response) {
return response
},
async function (error: AxiosError<{ message: string }>) {
if (!error.response) {
// Network Error
Toast.show('网络繁忙,请稍后重试')
return Promise.reject(error)
}
if (error.response.status === 401) {
const token = getToken()
// !#1 没有刷新 refresh_token
if (!token.refresh_token) {
history.replace('/login', {
from: history.location.pathname,
})
Toast.show('登录信息过期')
return Promise.reject(error)
}
try {
// !#2 使用 refresh_token 换取新 token
// axios.put('/authorizations', null, { headers: {} })
const res = await axios.request<ApiResponse<{ token: string }>>({
method: 'put',
url: '/authorizations',
baseURL,
headers: {
Authorization: `Bearer ${token.refresh_token}`,
},
})
// !#3 存储到本地和 Redux
store.dispatch(
saveToken({
token: res.data.data.token,
refresh_token: token.refresh_token,
})
)
// !#4 重新发请求
return request.request(error.config)
} catch {
// 刷新 Token 失败
history.replace('/login', {
from: history.location.pathname,
})
Toast.show('登录信息失败')
// store.dispatch(saveToken({ token: '', refresh_token: '' }))
store.dispatch(logout())
return Promise.reject(error)
}
}
Toast.show(error.response.data.message)
return Promise.reject(error)
}
)

小智同学聊天

WebSocket

为什么需要

初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?

答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。http 基于请求响应实现。

举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。

这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用“轮询”:每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。

轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。

基本介绍

WebSocket 是一种数据通信协议,类似于我们常见的 http 协议,在 2008 年诞生,2011 年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

典型的 websocket 应用场景:客服、聊天室、广播、点餐通知等等。

image-20201121170006970

静态结构

pages/Profile/Chat/index.tsx

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
import Icon from '@/components/Icon'
import { NavBar, Input } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import styles from './index.module.scss'

const Chat = () => {
const history = useHistory()

return (
<div className={styles.root}>
{/* 顶部导航栏 */}
<NavBar className='fixed-header' onBack={() => history.go(-1)}>
小智同学
</NavBar>

{/* 聊天记录列表 */}
<div className='chat-list'>
{/* 机器人的消息 */}
<div className='chat-item'>
<Icon type='iconbtn_xiaozhitongxue' />
<div className='message'>你好!</div>
</div>

{/* 用户的消息 */}
<div className='chat-item user'>
<img src={'http://toutiao.itheima.net/images/user_head.jpg'} alt='' />
<div className='message'>你好?</div>
</div>
</div>

{/* 底部消息输入框 */}
<div className='input-footer'>
<Input className='no-border' placeholder='请描述您的问题' />
<Icon type='iconbianji' />
</div>
</div>
)
}

export default Chat

pages/Profile/Chat/index.module.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
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
86
87
88
89
90
91
92
93
94
@import '~@scss/hairline.scss';

.root {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
padding: 46px 0 50px 0;

:global {
.fixed-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
}

.chat-list {
height: 100%;
overflow-y: scroll;
padding: 15px 16px;
}

.chat-item {
display: flex;
margin-bottom: 12px;

img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}

.icon {
width: 32px;
height: 32px;
margin-right: 8px;
}

.message {
max-width: 254px;
padding: 12px 8px;
line-height: 1.4;
border-radius: 5px;
font-size: 14px;
background-color: #f7f8fa;
}
}

.user {
flex-direction: row-reverse;

.icon,
img {
margin-right: 0;
margin-left: 8px;
}
}

.input-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 50px;
padding: 7px 16px;

.input {
height: 36px;
min-height: 0;
padding-left: 27px;
border-radius: 18px;
background-color: #f7f8fa;
font-size: 14px;
&::placeholder {
font-size: 14px;
}
}
.no-border {
@include hairline-remove(bottom);
}

.icon {
position: absolute;
top: 50%;
margin-top: -6.5px;
margin-left: 10px;
font-size: 13px;
}
}
}
}

App.tsx 中配置路由出口。

1
2
3
<PrivateRoute path='/chat'>
<Chat />
</PrivateRoute>

渲染聊天记录

  1. 声明一个数组状态。
1
2
3
4
const [messageList, setMessageList] = useState<{ type: 'robot' | 'user'; text: string }[]>([
{ type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
{ type: 'user', text: '你好' },
])
  1. 从 Redux 中获取当前用户基本信息。
1
2
// 用户渲染用户头像
const { user } = useInitState(getUser, 'profile')
  1. 根据数组数据,动态渲染聊天记录列表。
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 { useState } from 'react'
import Icon from '@/components/Icon'
import { NavBar, Input } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import styles from './index.module.scss'
import { useInitState } from '@/utils/hooks'
import { getUser } from '@/store/actions/profile'

const Chat = () => {
const history = useHistory()
// #1
const [messageList] = useState<{ type: 'robot' | 'user'; text: string }[]>([
{ type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
{ type: 'user', text: '你好' },
])
// #2
const { user } = useInitState(getUser, 'profile')
return (
<div className={styles.root}>
{/* 顶部导航栏 */}
<NavBar className='fixed-header' onBack={() => history.go(-1)}>
小智同学
</NavBar>

<div className='chat-list'>
{/* #3 */}
{messageList.map((msg, index) => {
if (msg.type === 'robot') {
// 机器
return (
<div className='chat-item' key={index}>
<Icon type='iconbtn_xiaozhitongxue' />
<div className='message'>{msg.text}</div>
</div>
)
} else {
// 用户
return (
<div className='chat-item user' key={index}>
<img src={user.photo} alt='' />
<div className='message'>{msg.text}</div>
</div>
)
}
})}
</div>
{/* 底部消息输入框 */}
<div className='input-footer'>
<Input className='no-border' placeholder='请描述您的问题' />
<Icon type='iconbianji' />
</div>
</div>
)
}

export default Chat
image-20210904085509862

建立连接

目标

使用 socket.io 客户端与服务器建立 WebSocket 长连接,文档

基本使用

  1. 安装包:yarn add socket.io-client 只安装客户端要使用到的包。

  2. 和服务器进行连接。

1
2
3
4
5
6
7
8
import io from 'socket.io-client'
// 和服务器建立链接
const client = io('地址', {
query: {
token: 用户token,
},
transports: ['websocket'],
})
  1. 和服务器进行通讯。
1
2
3
4
5
6
7
8
9
10
11
client.on('connect', () => {
// 当和服务器建立连接成功触发
})
client.on('message', () => {
// 接收到服务器的消息触发
})
client.on('disconnect', () => {
// 和服务器断开链接触发
})
client.emit('message', 值) // 主动给服务器发送消息
client.close() // 主动关闭和服务器的链接

具体操作

本项目聊天客服的后端接口,使用的是基于 WebSocket 协议的 socket.io 接口,我们可以使用专门的 socket.io 客户端库,就能轻松建立起连接并进行互相通信。

借助 useEffect,在进入页面时调用客户端库建立 socket.io 连接

  1. 安装 socket.io 客户端库:socket.io-client
1
yarn add socket.io-client
  1. 在进入机器人客服页面时,创建 socket.io 客户端
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
import { useState, useEffect } from 'react'
import Icon from '@/components/Icon'
import { NavBar, Input } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import styles from './index.module.scss'
import { useInitState } from '@/utils/hooks'
import { getUser } from '@/store/actions/profile'
// #1
import io from 'socket.io-client'
import { getToken } from '@/utils/storage'

const Chat = () => {
const history = useHistory()
const [messageList, setMessageList] = useState<{ type: 'robot' | 'user'; text: string }[]>([
{ type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
{ type: 'user', text: '你好' },
])
const { user } = useInitState(getUser, 'profile')
// #2
useEffect(() => {
const client = io('http://toutiao.itheima.net', {
transports: ['websocket'],
query: {
token: getToken().token,
},
})
client.on('connect', () => {
// 向聊天记录中添加一条消息
setMessageList((messageList) => [...messageList, { type: 'robot', text: '主人,我现在恭候着您的提问。' }])
})
return () => {
client.close()
}
}, [])
}

发送消息

目标

将输入框内容通过 socket.io 发送到服务端。

步骤

实现思路:使用 socket.io 实例的 emit() 方法发送信息。

  1. 声明一个状态,并绑定消息输入框。
1
const [message, setMessage] = useState('')
1
<Input className='no-border' placeholder='请描述您的问题' value={message} onChange={(v) => setMessage(v)} />
  1. 为消息输入框添加键盘事件,在输入回车时发送消息。
1
2
// #2
<Input className='no-border' placeholder='请描述您的问题' value={message} onChange={(v) => setMessage(v)} onKeyUp={onSendMessage} />
  1. 完整代码。
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
86
87
88
89
90
import { useState, useEffect, useRef } from 'react'
import Icon from '@/components/Icon'
import { NavBar, Input } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import styles from './index.module.scss'
import { useInitState } from '@/utils/hooks'
import { getUser } from '@/store/actions/profile'
import io, { Socket } from 'socket.io-client'
import { getToken } from '@/utils/storage'

const Chat = () => {
const history = useHistory()
const [messageList, setMessageList] = useState<{ type: 'robot' | 'user'; text: string }[]>([
{ type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
{ type: 'user', text: '你好' },
])
// #1
const [message, setMessage] = useState('')
// #4: 注意当初始值是 null 的时候,不允许对 clientRef.current 赋值另外一个类型数据,解决办法如下。
const clientRef = useRef<Socket | null>(null)
const { user } = useInitState(getUser, 'profile')
// #3
const onSendMessage = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// #6: 向服务端推送一条消息
clientRef.current?.emit('message', {
msg: message,
timestamp: Date.now(),
})
// 并展示到页面上
setMessageList((messageList) => [...messageList, { type: 'user', text: message }])
setMessage('')
}
}
useEffect(() => {
const client = io('http://toutiao.itheima.net', {
transports: ['websocket'],
query: {
token: getToken().token,
},
})
// #5
clientRef.current = client
client.on('connect', () => {
// 向聊天记录中添加一条消息
setMessageList((messageList) => [...messageList, { type: 'robot', text: '我现在恭候着您的提问。' }])
})
return () => {
client.close()
}
}, [])
return (
<div className={styles.root}>
{/* 顶部导航栏 */}
<NavBar className='fixed-header' onBack={() => history.go(-1)}>
小智同学
</NavBar>

<div className='chat-list'>
{messageList.map((msg, index) => {
if (msg.type === 'robot') {
// 机器
return (
<div className='chat-item' key={index}>
<Icon type='iconbtn_xiaozhitongxue' />
<div className='message'>{msg.text}</div>
</div>
)
} else {
// 用户
return (
<div className='chat-item user' key={index}>
<img src={user.photo} alt='' />
<div className='message'>{msg.text}</div>
</div>
)
}
})}
</div>
{/* 底部消息输入框 */}
{/* #2 */}
<div className='input-footer'>
<Input className='no-border' placeholder='请描述您的问题' value={message} onChange={(v) => setMessage(v)} onKeyUp={onSendMessage} />
<Icon type='iconbianji' />
</div>
</div>
)
}

export default Chat

接收消息

  1. 通过 socket.io 监听回复的消息,并添加到聊天列表中。

  2. 且当消息较多出现滚动条时,有后续新消息的话总将滚动条滚动到最底部。

  3. 使用 socket.io 实例的 message 事件接收信息,在聊天列表数据变化时,操作列表容器元素来设置滚动量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
useEffect(() => {
const client = io('http://toutiao.itheima.net', {
transports: ['websocket'],
query: {
token: getToken().token,
},
})
clientRef.current = client
client.on('connect', () => {
// 向聊天记录中添加一条消息
setMessageList((messageList) => [...messageList, { type: 'robot', text: '我现在恭候着您的提问。' }])
})
// 在 socket.io 实例的 `message` 事件中,将接收到的消息添加到聊天列表
client.on('message', (data) => {
// 向聊天记录中添加机器人回复的消息
setMessageList((messageList) => [...messageList, { type: 'robot', text: data.msg }])
})
return () => {
client.close()
}
}, [])

计算滚动位置

  1. 声明一个 ref 并设置到聊天列表的容器元素上。
1
const chatListRef = useRef<HTMLDivElement>(null)
1
<div className="chat-list" ref={chatListRef}>
  1. 通过 useEffect 监听聊天数据变化,对聊天容器元素的 scrollTop 进行设置。
1
2
3
4
5
6
// 监听聊天数据的变化,改变聊天容器元素的 scrollTop 值让页面滚到最底部
useEffect(() => {
const current = chatListRef.current!
current.scrollTop = current.scrollHeight
// current.scrollTop = current.scrollHeight - current.offsetHeight
}, [messageList])