今日目标
✔ 掌握个人中心功能的开发。
✔ 掌握 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
|
步骤
使用 antd-mobile 的 TabBar 组件创建底部标签栏。
样式在素材中已经准备好。
代码
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> ) }
|
路由切换功能
- 注册 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() 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
| <TabBar.Item key={item.path} icon={(active: boolean) => <Icon type={active ? `${item.icon}_sel` : item.icon} />} title={item.text} />
|
- 解决刷新时默认高亮的问题。
1
| <TabBar className='tab-bar' onChange={changeRoute} activeKey={location.pathname}></TabBar>
|
- 完整代码。
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
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
步骤
粘贴素材中【布局页面模板】文件夹中的 Home、Question、Video、Profile 到项目的 pages 目录。
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'
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 { 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; } } } }
|
获取用户信息
目标
完成获取用户信息的功能。
分析
先按照接口的返回数据,准备 TS 类型。
然后,在发送请求时,指定该请求的返回值类型。
在接下来的操作中,如果需要用到接口的数据,都会有类型提示了。
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 }
|
步骤
- 定义 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) } }
|
- 触发 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'
import { getUser } from '@/store/actions/profile'
const Profile = () => { const history = useHistory() const dispatch = useDispatch() useEffect(() => { dispatch(getUser()) }, [dispatch]) }
export default Profile
|
- 配置请求拦截器统一携带 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 中。
代码
- 准备保存状态到 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'
export type LoginAction = { type: 'login/login' payload: Token }
export type ProfileAction = { type: 'profile/getUser' payload: User }
export type RootState = ReturnType<typeof store.getState>
export type RootAction = LoginAction | ProfileAction
export type RootThunkAction = ThunkAction<void, RootState, unknown, RootAction>
|
- 在获取个人信息的 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, }) } }
|
- 创建
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
const profile = (state = initState, action: ProfileAction): ProfileState => { switch (action.type) { case 'profile/getUser': return { ...state, user: action.payload, } default: return state } } export default profile
|
- 合并 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 })
|
渲染用户信息
目标
能够展示用户信息到界面。
步骤
导入 useSelector。
调用 useSelector 获取 user 状态。
从 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); } } } }
: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; } } }
|
步骤
- 在
App.tsx
中配置个人信息页面的路由。
1
| <Route path='/profile/edit' component={ProfileEdit} />
|
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; } }
|
获取并渲染
- 在
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 }
|
- 在
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 }
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>
|
- 在
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, }) } }
|
- 在 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
| import { User, UserProfile } from '@/types/data' import { ProfileAction } from '@/types/store'
type ProfileState = { user: User; userProfile: UserProfile }
const initState = { user: {}, userProfile: {} } as ProfileState
const profile = (state = initState, action: ProfileAction): ProfileState => { switch (action.type) { case 'profile/getUser': return { ...state, user: action.payload, } case 'profile/getUserProfile': return { ...state, userProfile: action.payload, } default: return state } } export default profile
|
- 在
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
| const useXxx = (params) => { return xxx }
|
1 2 3 4
| const Hello = () => { const xxx = useXxx(...) }
|
🤔 自定义 Hooks 就是一个函数,可以完全按照对函数的理解,来理解自定义 Hooks,参数和返回值都可以可选的,可以提供也可以不提供,根据实际需求来实现即可。
总结
自定义 Hooks 是函数吗?
自定义 Hooks 的名称有什么约束?
useInitState
目标
能够通过封装自定义 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 { 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
| 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'
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 { user } = useInitState(getUser, 'profile') }
|
修改昵称和简介
显示弹层
- 准备弹层。
src/pages/Profile/Edit/index.tsx
1 2 3
| <Popup visible={true} position='right'> <div style={{ height: 400, width: '80vw' }}>xxx</div> </Popup>
|
- 准备状态,用于控制弹层的显示。
1 2 3 4 5 6 7 8
| type InputState = { visible: boolean type: '' | 'name' | 'intro' } const [showInput, setShowInput] = useState<InputState>({ type: '', visible: false, })
|
- 点击昵称和简介的时候,需要弹层并设置 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 组件,点击返回的时候隐藏弹层。
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
|
修改标题文字
- 传递 type 属性。
1
| <EditInput hideInput={hideInput} type={showInput.type} />
|
- 根据 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 type: '' | 'name' | 'intro' }
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) => { const { userProfile } = useSelector((state: RootState) => state.profile) 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
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) 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={<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(-1, -1) </script>
|
回传给父组件
目标
能够在点击提交时拿到输入的信息并回传给父组件。
分析
用户个人信息的状态是在 Edit 父组件中拿到的,所以修改用户个人信息也应该由 Edit 父组件发起,因此,需要将修改后的昵称回传给 Edit 父组件。
步骤
在 Edit 组件定义 onUpdate 函数并传递给 EditInput 组件。
在 EditInput 组件中为提交按钮绑定点击事件并调用传递过来的 onUpdate 函数。
在 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' onUpdate: (key: string, value: string) => void }
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; background-color: #f7f8fa; } } } } }
|
准备弹出层
目标
能够显示修改性别弹出层。
步骤
将准备好的修改性别的模板拷贝到 Edit 目录中。
导入修改性别组件,在 Popup 组件中渲染。
代码
Edit/index.tsx
1 2 3 4
| import EditList from './EditList' ;<Popup visible={true} position='bottom' destroyOnClose> <EditList /> </Popup>
|
显示与隐藏
目标
能搞控制修改性别弹出层的展示或隐藏。
分析
修改性别和修改头像的弹出层内容几乎是一样的,因此,也可以复用同一个弹出层组件。因此,接下来要从复用的角度,设计修改性别弹出层的逻辑(可以参考刚刚实现的修改昵称和简介)。
步骤
准备用于控制修改性别弹出层的状态。
为性别添加点击事件,在点击事件中修改状态进行展示。
创建隐藏弹出层的控制函数,在点击遮罩时关闭弹出层,并传递给 EditList 组件。
为 EditList 组件添加 props 类型,并接收隐藏函数。
为取消按钮添加点击事件来触发隐藏弹出层。
代码
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' }
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, }) } 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
|
内容的回显
- 父组件把 type 传给子组件。
src/pages/Profile/Edit/index.tsx
1
| <EditList hideList={hideList} type={showList.type}></EditList>
|
- 子组件根据 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
|
修改性别
- 父组件把 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} />
|
- 子组件注册点击事件并调用 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' onUpdate: (key: string, value: string) => void } const genderList = [ { title: '男', value: '0' }, { title: '女', value: '1' }, ]
const photoList = [ { title: '拍照', value: '' }, { title: '本地选择', value: '' }, ]
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, }) } const fileRef = useRef<HTMLInputElement>(null) const onUpdate = async (key: string, value: string) => { 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
|
获取选择的头像
目标
能够组装修改头像需要的数据。
步骤
创建函数,监听 input[type=file]
选择文件的变化 change。
在函数中,创建 FormData 对象。
根据接口,拿到接口需要规定的参数名,并将选择的文件添加到 FormData 对象中。
代码
pages/Profile/Edit/index.tsx
1 2 3 4 5 6
| 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} />
|
更新头像
目标
能够实现更新头像。
步骤
在 Edit 组件中,分发修改头像的 action,传入 FormData 对象,并关闭弹出层。
在 actions 中,创建修改头像的 action,接收到传递过来的 FormData 对象。
发送请求,更新用户头像。
分发 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) await dispatch(updateUserPhoto(fd)) Toast.show({ icon: 'success', content: '修改头像成功', }) hideList() }
|
修改生日
pages/Profile/Edit/index.tsx
- 创建状态 showBirthday 用来控制日期选择器的展示或隐藏。
1
| const [showBirthday, setShowBirthday] = useState(false)
|
- 将 showBirthday 设置为日期选择器的 visible 属性。
1
| <DatePicker visible={showBirthday} title='选择年月日' />
|
- 给生日绑定点击事件,在点击事件中修改 showBirthday 值为 true 来展示日期选择器。
1 2 3
| const onBirthdayShow = () => { setShowBirthday(true) }
|
1 2 3
| <Item arrow extra={userProfile.birthday} onClick={onBirthdayShow}> 生日 </Item>
|
- 为日期选择器设置 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()} />
|
- 监听日期组件的 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 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
基础方法来实现。
步骤
为退出登录按钮绑定点击事件。
在点击事件中,使用 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: () => { dispatch(logout()) history.replace('/login') Toast.show({ icon: 'success', content: '退出成功', }) }, }, ], ], }) }
|
功能完成
目标
能够实现退出功能。
步骤
为退出按钮,绑定点击事件,在点击事件中分发退出 action。
在 types/store.d.ts
中,创建退出登录的 action 类型。
1 2 3 4 5 6 7 8
| export type LoginAction = | { type: 'login/login' payload: Token } | { type: 'login/logout' }
|
- 在
actions/login.ts
中,创建退出 action 并清理 token。
1 2 3 4 5 6
| export function logout(): LoginAction { removeToken() return { type: 'login/logout', } }
|
- 在
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 } if (action.type === 'login/logout') { return { token: '', refresh_token: '', } } return state } export default login
|
界面访问控制
鉴权组件封装
目标
能够封装鉴权路由组件实现登录访问控制功能,链接。
步骤
在 components 目录中创建 PrivateRoute 路由组件。
在 PrivateRoute 组件中,实现路由的登录访问控制逻辑。
未登录时,重定向到登录页面,并传递要访问的路由地址。
登录时,直接渲染要访问的路由。
代码
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>
|
登录成功跳转到原页面
目标
能够在登录时根据重定向路径跳转到相应页面。
步骤
在 Login 组件中导入 useLocation 来获取路由重定向时传入的 state。
调用 useLocation hook 时,指定 state 的类型。
登录完成跳转页面时,判断 state 是否存在。
如果存在,跳转到 state 指定的页面。
如果不存在,默认跳转到首页。
代码
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.replace(location.state ? location.state.from : '/home') }, }) }
|
刷新 Token
概述
目标
能够理解什么是无感刷新 Token。
分析
一般情况下,我们用到的移动端的 App(比如,微信)只要登录过一次,一般就不需要再次重新登录,除非很长时间没有使用过 App,这是如何做到的呢?这就用到我们要讲的无感刷新 Token 了。
我们知道,登录时会拿到一个登录成功的标识 Token(身份令牌),有了这个令牌就可以进行登录后的操作了,比如,获取个人资料、修改个人信息等等。
但是,为了安全,登录标识 Token 一般都是有有效期的,比如,咱们的极客园项目中 Token 的有效期是 2 个小时,如果不进行额外的处理,2 小时以后,就得再次登录才可以,但是,这种用户体验不好,特别是移动端(不管是 App 还是 H5)。
相对来说,更好的用户体验是前面提到的微信的那种方式,它的原理简单来说是这样的,在登录时,同时拿到两个 Token。
登录成功的令牌:Token
。
刷新 Token 的令牌:refresh_token
。
刷新 Token 的令牌用来:在 Token 过期后,换取新的 Token(续费),从而实现“永久登录”效果,这就是所谓的:无感刷新 token。
思想总结如下。
实现
目标
能够实现无感刷新 Token 实现自动登录。
分析
概述:在登录超时或者 Token 失效时,也就是服务器接口返回 401,通过 refresh_token 换取新的 Token。
过程如下(以获取个人资料数据为例):
发送请求获取个人资料数据。
接口返回 401,也就是 Token 失效了。
在响应拦截器中统一处理,换取新的 Token。
将新的 Token 存储到本地缓存中。
继续发送获取个人资料的请求,完成数据获取,关键点。
如果整个过程中出现了任意异常,一般来说就是 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 响应代码】
|
步骤
使用 try-catch 处理异常,出现异常时,清除 token,清空 Redux token,跳转到登录页面。
判断本地存储中,是否有 refresh_token
。
如果没有,直接跳转到登录页面,让用户登录即可。
如果有,就使用 refresh_token
来通过 axios 发送请求,换取新的 token。
将新获取到的 Token 存储到本地缓存中和 Redux 中。
继续发送原来的请求。
代码
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) { Toast.show('网络繁忙,请稍后重试') return Promise.reject(error) } if (error.response.status === 401) { const token = getToken() if (!token.refresh_token) { history.replace('/login', { from: history.location.pathname, }) Toast.show('登录信息过期') return Promise.reject(error) } 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(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) { Toast.show('网络繁忙,请稍后重试') return Promise.reject(error) } if (error.response.status === 401) { const token = getToken() if (!token.refresh_token) { history.replace('/login', { from: history.location.pathname, }) Toast.show('登录信息过期') return Promise.reject(error) } const res = await axios.request<ApiResponse<{ token: string }>>({ method: 'put', url: '/authorizations', baseURL, headers: { Authorization: `Bearer ${token.refresh_token}`, }, }) store.dispatch( saveToken({ token: res.data.data.token, refresh_token: token.refresh_token, }) ) 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) { Toast.show('网络繁忙,请稍后重试') return Promise.reject(error) } if (error.response.status === 401) { const token = getToken() if (!token.refresh_token) { history.replace('/login', { from: history.location.pathname, }) Toast.show('登录信息过期') return Promise.reject(error) } try { const res = await axios.request<ApiResponse<{ token: string }>>({ method: 'put', url: '/authorizations', baseURL, headers: { Authorization: `Bearer ${token.refresh_token}`, }, }) store.dispatch( saveToken({ token: res.data.data.token, refresh_token: token.refresh_token, }) ) return request.request(error.config) } catch { history.replace('/login', { from: history.location.pathname, }) Toast.show('登录信息失败') 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 应用场景:客服、聊天室、广播、点餐通知等等。

静态结构
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 2 3 4
| const [messageList, setMessageList] = useState<{ type: 'robot' | 'user'; text: string }[]>([ { type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' }, { type: 'user', text: '你好' }, ])
|
- 从 Redux 中获取当前用户基本信息。
1 2
| const { user } = useInitState(getUser, 'profile')
|
- 根据数组数据,动态渲染聊天记录列表。
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() const [messageList] = useState<{ type: 'robot' | 'user'; text: string }[]>([ { type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' }, { type: 'user', text: '你好' }, ]) 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
|
建立连接
目标
使用 socket.io 客户端与服务器建立 WebSocket 长连接,文档。
基本使用
安装包:yarn add socket.io-client
只安装客户端要使用到的包。
和服务器进行连接。
1 2 3 4 5 6 7 8
| import io from 'socket.io-client'
const client = io('地址', { query: { token: 用户token, }, transports: ['websocket'], })
|
- 和服务器进行通讯。
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 连接
- 安装 socket.io 客户端库:
socket.io-client
1
| yarn add socket.io-client
|
- 在进入机器人客服页面时,创建 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'
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') 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
| const [message, setMessage] = useState('')
|
1
| <Input className='no-border' placeholder='请描述您的问题' value={message} onChange={(v) => setMessage(v)} />
|
- 为消息输入框添加键盘事件,在输入回车时发送消息。
1 2
| <Input className='no-border' placeholder='请描述您的问题' value={message} onChange={(v) => setMessage(v)} onKeyUp={onSendMessage} />
|
- 完整代码。
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: '你好' }, ]) const [message, setMessage] = useState('') const clientRef = useRef<Socket | null>(null) const { user } = useInitState(getUser, 'profile') const onSendMessage = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { 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, }, }) 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
|
接收消息
通过 socket.io 监听回复的消息,并添加到聊天列表中。
且当消息较多出现滚动条时,有后续新消息的话总将滚动条滚动到最底部。
使用 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: '我现在恭候着您的提问。' }]) }) client.on('message', (data) => { setMessageList((messageList) => [...messageList, { type: 'robot', text: data.msg }]) }) return () => { client.close() } }, [])
|
计算滚动位置
- 声明一个 ref 并设置到聊天列表的容器元素上。
1
| const chatListRef = useRef<HTMLDivElement>(null)
|
1
| <div className="chat-list" ref={chatListRef}>
|
- 通过
useEffect
监听聊天数据变化,对聊天容器元素的 scrollTop 进行设置。
1 2 3 4 5 6
| useEffect(() => { const current = chatListRef.current! current.scrollTop = current.scrollHeight }, [messageList])
|