技术栈
目标:了解开发本项目所要使用的各类框架、库
脚手架工具:
create-react-app
组件编写方式:
函数组件
+Hooks
路由组件库:
react-router-dom
全局状态库:
redux + redux-thunk
网络请求库:
axios
UI组件库:
antd-mobile
、以及一些用来实现特定功能的第三方组件(如:formik
、react-content-loader
、react-window-infinite-loader
等)
项目准备
创建新项目
目标:使用脚手架命令创新项目
操作步骤
- 通过命令行创建项目
create-react-app geek-park
- 修改页面模板
public/index.html
中的页面标题
<title>极客园 App</title>
- 删除
src
目录中的所有文件 - 新增文件
/src
/assets 项目资源文件,比如,图片 等
/components 通用组件
/pages 页面
/utils 工具,比如,token、axios 的封装等
App.js 根组件
index.scss 全局样式
index.js 项目入口
公用样式
目标:将本项目要用的公用样式文件放入合适的目录,并调用
【重要说明】
在本课程发放的资料中,有个 `资源 > src代码文件 > assets` 目录,里面存放着公用样式文件和图片资源,可直接复制到你的代码中使用。
操作步骤
- 将上面提到的
assets
目录,直接拷贝到新项目的src
目录下
- 在主入口
index.js
中导入公用样式文件
import './assets/styles/index.scss'
配置 SASS 支持
目标:让项目样式支持使用 SASS/SCSS 语法编写
操作步骤
- 安装
sass
yarn add sass --save-dev
配置 UI 组件库
目标:安装本项目使用的 UI 组件库 Ant Design Mobile,并通过 Babel 插件实现按需加载
https://mobile.ant.design/index-cn
操作步骤
- 安装
antd-mobile
yarn add antd-mobile
- 导入样式
import 'antd-mobile/dist/antd-mobile.css'
- 使用组件
import { Button, Toast } from 'antd-mobile'
export default function App() {
return (
<div className="app">
<Button
type="primary"
onClick={() => Toast.success('Load success !!!', 1)}
>
default disabled
</Button>
</div>
)
}
antd-按需加载
https://mobile.ant.design/docs/react/use-with-create-react-app-cn
craco
实现思路:
- 使用 customize-cra 来添加和覆盖脚手架的 webpack 配置
- 使用
react-app-rewired
来打包和运行代码
- 使用 babel-plugin-import, babel-plugin-import 是一个用于按需加载组件代码和样式的 babel 插件
操作步骤
- 安装
customize-cra
和react-app-rewired
yarn add customize-cra react-app-rewired babel-plugin-import -D
- 在项目根目录中创建
config-overrides.js
,并编写如下代码:
const { override, fixBabelImports } = require('customize-cra')
// 导出要进行覆盖的 webpack 配置
module.exports = override(
fixBabelImports('import', {
libraryName: 'antd-mobile',
style: 'css',
})
)
- 修改启动命令
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
- 删除index.js的样式导入
- import 'antd-mobile/dist/antd-mobile.css'
- 重启项目测试
配置快捷路径 @
目标:让代码中支持以
@/xxxx
形式的路径来导入文件
操作步骤
- 在项目根目录中创建
config-overrides.js
,并编写如下代码:
const path = require('path')
const { override, fixBabelImports, addWebpackAlias } = require('customize-cra')
const babelPlugins = fixBabelImports('import', {
libraryName: 'antd-mobile',
style: 'css',
})
const webpackAlias = addWebpackAlias({
'@': path.resolve(__dirname, 'src'),
'@scss': path.resolve(__dirname, 'src', 'assets', 'styles'),
})
// 导出要进行覆盖的 webpack 配置
module.exports = override(babelPlugins, webpackAlias)
- 在项目根目录中创建
jsconfig.json
,并编写如下代码,为了路径有提示
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@scss/*": ["src/assets/styles/*"]
}
}
}
配置视口单位插件
目标:通过 webpack 插件将 px 单位自动转换成视口长度单位 vw/vh,实现页面对不同屏幕的自动适配
实现思路:
使用 postcss-px-to-viewport
插件,可让我们直接在代码中按设计稿的 px 值来编写元素尺寸,它们最终会自动转换成 vw/vh 长度单位。
操作步骤
- 安装
postcss-px-to-viewport
yarn add postcss-px-to-viewport -D
- 在
config-overrides.js
中添加配置代码:
const path = require('path')
const { override, addWebpackAlias, addPostcssPlugins } = require('customize-cra')
const px2viewport = require('postcss-px-to-viewport')
// 配置路径别名
// ...
// 配置 PostCSS 样式转换插件
const postcssPlugins = addPostcssPlugins([
// 移动端布局 viewport 适配方案
px2viewport({
// 视口宽度:可以设置为设计稿的宽度
viewportWidth: 375,
// 白名单:不需对其中的 px 单位转成 vw 的样式类类名
// selectorBlackList: ['.ignore', '.hairlines']
})
])
// 导出要进行覆盖的 webpack 配置
module.exports = override(alias, postcssPlugins)
配置路由管理器
目标:安装 react-router-dom,创建 App 根组件并在该组件中配置路由
操作步骤
- 安装
react-router-dom
yarn add react-router-dom
- 创建两个组件
pages/Home/index.js
pages/Login/index.js
- 创建
App.js
,编写根组件:
import React, { Suspense } from 'react'
import {
BrowserRouter as Router,
Route,
Switch,
Redirect,
} from 'react-router-dom'
import './App.scss'
const Login = React.lazy(() => import('@/pages/Login'))
const Home = React.lazy(() => import('@/pages/Home'))
export default function App() {
return (
<Router>
<div className="app">
{/* <Link to="/login">登录</Link>
<Link to="/home">首页</Link> */}
<Suspense fallback={<div>loading...</div>}>
<Switch>
<Redirect exact from="/" to="/home"></Redirect>
<Route path="/login" component={Login}></Route>
<Route path="/home" component={Home}></Route>
</Switch>
</Suspense>
</div>
</Router>
)
}
配置 Redux
目标:安装
redux
和redux-thunk
相关的依赖包,并创建 Redux Store 实例后关联到应用上
所要用到的依赖包:
- redux
- react-redux
- redux-thunk
- redux-devtools-extension
操作步骤
- 安装依赖包
yarn add redux react-redux redux-thunk redux-devtools-extension
- 创建
store
目录及它的子目录actions
、reducers
,专门存放 redux 相关代码
- 创建
store/reducers/index.js
,用来作为组合所有 reducers 的主入口:
import { combineReducers } from 'redux'
// 组合各个 reducer 函数,成为一个根 reducer
const rootReducer = combineReducers({
// 一个测试用的 reducer,避免运行时因没有 reducer 而报错
test: (state = 0, action) => (state)
// 在这里配置有所的 reducer ...
})
// 导出根 reducer
export default rootReducer
- 创建
store/index.js
,编写 Redux Store 实例:
import { applyMiddleware, createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunk from 'redux-thunk'
import rootReducer from './reducers'
// 创建 Store 实例
const store = createStore(
// 参数一:根 reducer
rootReducer,
// 参数二:初始化时要加载的状态
{},
// 参数三:增强器
composeWithDevTools(
applyMiddleware(thunk)
)
)
// 导出 Store 实例
export default store
- 在主入口
index.js
中,配置 Redux Provider
import '@scss/index.scss'
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import store from '@/store'
import { Provider } from 'react-redux'
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
页面
字体图标的基本使用
- 如果使用class类名的方式,彩色图标无法生效
- 可以通过js的方式来使用图标
1. 引入js
<script src="//at.alicdn.com/t/font_2791161_ymhdfblw14.js"></script>
2. 样式
/* 字体图标 */
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
3. 使用
<svg className="icon" aria-hidden="true">
<use xlinkHref="#icon-mianxingfeizhunan"></use>
</svg>
封装 svg 图标小组件
//at.alicdn.com/t/font_2503709_f4q9dl3hktl.js
目标:实现一个用于在页面上显示 svg 小图标的组件,方便后续开发中为界面添加小图标
实现思路:
- 在组件中,输出一段使用 <use> 标签引用事先准备好的 SVG 图片资源的 <svg> 代码
- 组件需要传入 SVG 图片的名字,用来显示不同的图标
- 组件可以设置额外的样式类名、及点击事件监听
操作步骤
- 安装
classnames
,辅助组件的开发
yarn add classnames
- 在
public/index.html
中引入 svg 图标资源:
<script src="//at.alicdn.com/t/font_2503709_f4q9dl3hktl.js"></script>
- 创建
components/Icon/index.js
,编写图标组件:
import React from 'react'
import classNames from 'classnames'
import PropTypes from 'prop-types'
// ``
function Icon({ type, className, ...rest }) {
return (
<svg {...rest} className={classNames('icon', className)} aria-hidden="true">
<use xlinkHref={`#${type}`}></use>
</svg>
)
}
Icon.propTypes = {
type: PropTypes.string.isRequired,
}
export default Icon
- 测试组件,确认能否正确显示出图标
<Icon
type="iconbtn_share"
className="test-icon"
onClick={() => { alert('clicked') }}
/>
实现顶部导航栏组件
- 基础结构
import React from 'react'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
export default function Login() {
return (
<div className={styles.root}>
{/* 后退按钮 */}
<div className="left">
<Icon type="iconfanhui" />
</div>
{/* 居中标题 */}
<div className="title">我是标题</div>
{/* 右侧内容 */}
<div className="right">右侧内容</div>
</div>
)
}
- 样式
.root {
position: relative;
display: flex;
align-items: center;
height: 46px;
width: 100%;
// padding: 0 42px;
background-color: #fff;
border-bottom: 1px solid #ccc;
:global {
.left {
padding: 0 12px 0 16px;
line-height: 46px;
}
.icon {
font-size: 16px;
}
.title {
flex: 1;
margin: 0 auto;
color: #323233;
font-weight: 500;
font-size: 16px;
text-align: center;
}
.right {
padding-right: 16px;
// position: absolute;
// right: 16px;
}
}
}
移动端 1px 像素边框
- 参考 antd-mobile 的实现
- 实现方式参考
- 实现原理:伪元素 + transform 缩放
- 伪元素
::after
或::before
独立于当前元素,可以单独对其缩放而不影响元素本身的缩放
- 伪元素
// src/assets/styles/hairline.scss
@mixin scale-hairline-common($color, $top, $right, $bottom, $left) {
content: '';
position: absolute;
display: block;
z-index: 1;
top: $top;
right: $right;
bottom: $bottom;
left: $left;
background-color: $color;
}
// 添加边框
/*
用法:
// 导入
@import '@scss/hairline.scss';
// 在类中使用
.a {
@include hairline(bottom, #f0f0f0);
}
*/
@mixin hairline($direction, $color: #000, $radius: 0) {
@if $direction == top {
border-top: 1px solid $color;
// min-resolution 用来检测设备的最小像素密度
@media (min-resolution: 2dppx) {
border-top: none;
&::before {
@include scale-hairline-common($color, 0, auto, auto, 0);
width: 100%;
height: 1px;
transform-origin: 50% 50%;
transform: scaleY(0.5);
@media (min-resolution: 3dppx) {
transform: scaleY(0.33);
}
}
}
} @else if $direction == right {
border-right: 1px solid $color;
@media (min-resolution: 2dppx) {
border-right: none;
&::after {
@include scale-hairline-common($color, 0, 0, auto, auto);
width: 1px;
height: 100%;
background: $color;
transform-origin: 100% 50%;
transform: scaleX(0.5);
@media (min-resolution: 3dppx) {
transform: scaleX(0.33);
}
}
}
} @else if $direction == bottom {
border-bottom: 1px solid $color;
@media (min-resolution: 2dppx) {
border-bottom: none;
&::after {
@include scale-hairline-common($color, auto, auto, 0, 0);
width: 100%;
height: 1px;
transform-origin: 50% 100%;
transform: scaleY(0.5);
@media (min-resolution: 3dppx) {
transform: scaleY(0.33);
}
}
}
} @else if $direction == left {
border-left: 1px solid $color;
@media (min-resolution: 2dppx) {
border-left: none;
&::before {
@include scale-hairline-common($color, 0, auto, auto, 0);
width: 1px;
height: 100%;
transform-origin: 100% 50%;
transform: scaleX(0.5);
@media (min-resolution: 3dppx) {
transform: scaleX(0.33);
}
}
}
} @else if $direction == all {
border: 1px solid $color;
border-radius: $radius;
@media (min-resolution: 2dppx) {
position: relative;
border: none;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 200%;
height: 200%;
border: 1px solid $color;
border-radius: $radius * 2;
transform-origin: 0 0;
transform: scale(0.5);
box-sizing: border-box;
pointer-events: none;
}
}
}
}
// 移除边框
@mixin hairline-remove($position: all) {
@if $position == left {
border-left: 0;
&::before {
display: none !important;
}
} @else if $position == right {
border-right: 0;
&::after {
display: none !important;
}
} @else if $position == top {
border-top: 0;
&::before {
display: none !important;
}
} @else if $position == bottom {
border-bottom: 0;
&::after {
display: none !important;
}
} @else if $position == all {
border: 0;
&::before {
display: none !important;
}
&::after {
display: none !important;
}
}
}
- 需要导入这个scss
// 导入另一个scss文件
@import '@scss/hiarline.scss';
.root {
position: relative;
display: flex;
align-items: center;
height: 46px;
width: 100%;
// padding: 0 42px;
background-color: #fff;
// border-bottom: 1px solid red;
@include hairline('bottom', red);
实现顶部导航栏组件-封装
目标:封装顶部导航栏组件,可以用来显示页面标题、后退按钮、及添加额外的功能区域
图例一:
<img src="极客园移动端1.assets/image-20210831163053705.png" alt="image-20210831163053705" />
图例二:
<img src="极客园移动端1.assets/image-20210831163126729.png" alt="image-20210831163126729" />
图例三:
<img src="极客园移动端1.assets/image-20210831205954290.png" alt="image-20210831205954290" />
实现思路:
- 组件布局分为:左、中、右三个区域
- 可通过组件属性传入内容,填充中间和右边区域
- 可为左边的“后退”按钮添加事件监听
操作步骤
- 创建
components/NavBar/index.js
,并在该目录拷贝入资源包中的样式文件,然后编写组件代码:
import React from 'react'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { useHistory } from 'react-router'
// import { withRouter } from 'react-router-dom'
// 1. withRouter的使用
// history match location: 这个组件必须是通过路由配置的 <Route></Route>
// 自己渲染的组件,无法获取到路由信息 <NavBar></NavBar>
// 2. 路由提供了几个和路由相关的hook
// useHistory useLocation useParams
function NavBar({ children, extra }) {
const history = useHistory()
const back = () => {
// 跳回上一页
history.go(-1)
}
return (
<div className={styles.root}>
{/* 后退按钮 */}
<div className="left">
<Icon type="iconfanhui" onClick={back} />
</div>
{/* 居中标题 */}
<div className="title">{children}</div>
{/* 右侧内容 */}
<div className="right">{extra}</div>
</div>
)
}
export default NavBar
- 测试组件功能
<NavBar
onLeftClick={() => alert(123)}
rightContent={
<span>右侧内容</span>
}
>
标题内容
</NavBar>
效果:
<img src="极客园移动端1.assets/image-20210831212932784.png" alt="image-20210831212932784" />
表单基本结构
- 结构
import React from 'react'
import NavBar from '@/components/NavBar'
import styles from './index.module.scss'
export default function Login() {
return (
<div className={styles.root}>
<NavBar>登录</NavBar>
<div className="content">
{/* 标题 */}
<h3>短信登录</h3>
<form>
{/* 手机号输入框 */}
<div className="input-item">
<div className="input-box">
<input
className="input"
name="mobile"
placeholder="请输入手机号"
autoComplete="off"
/>
</div>
<div className="validate">手机号验证错误信息</div>
</div>
{/* 短信验证码输入框 */}
<div className="input-item">
<div className="input-box">
<input
className="input"
name="code"
placeholder="请输入验证码"
maxLength={6}
autoComplete="off"
/>
<div className="extra">获取验证码</div>
</div>
<div className="validate">验证码验证错误信息</div>
</div>
{/* 登录按钮 */}
<button type="submit" className="login-btn">
登录
</button>
</form>
</div>
</div>
)
}
- 样式
@import '@scss/hairline.scss';
.root {
:global {
.iconfanhui {
font-size: 20px;
}
.content {
padding: 0 32px;
h3 {
padding: 30px 0;
font-size: 24px;
}
.input-item {
position: relative;
&:first-child {
margin-bottom: 17px;
}
.input-box {
position: relative;
@include hairline(bottom, #ccc);
.input {
width: 100%;
height: 58px;
padding: 0;
font-size: 16px;
&::placeholder {
color: #a5a6ab;
}
}
.extra {
position: absolute;
right: 0;
top: 50%;
margin-top: -8px;
color: #999;
}
}
}
.validate {
position: absolute;
color: #ee0a24;
font-size: 12px;
}
.login-btn {
width: 100%;
height: 50px;
margin-top: 38px;
border-radius: 8px;
border: 0;
color: #fff;
background: linear-gradient(315deg, #fe4f4f, #fc6627);
}
.disabled {
background: linear-gradient(315deg, #ff9999, #ffa179);
}
}
}
}
实现能显示额外内容的Input组件
目标:将原生的 input 标签进行封装,使得该组件可在 input 右侧放置额外内容元素
<img src="极客园移动端1.assets/image-20210831213942878.png" alt="image-20210831213942878" />
实现思路:
- 左右布局:左侧
<input>
,右侧是一个可自定义的内容区域 - 将封装的组件传入的属性,全部传递到
<input>
标签上,使得能充分利用原标签的功能
操作步骤
- 创建
components/Input/index.js
,并在该目录拷贝入资源包中的样式文件,然后编写组件代码:
import React from 'react'
import styles from './index.module.scss'
export default function Input({ extra, onExtraClick, ...rest }) {
return (
<div className={styles.root}>
<input className="input" {...rest} />
{extra && (
<div className="extra" onClick={onExtraClick}>
{extra}
</div>
)}
</div>
)
}
登录页面的静态结构
目标:实现登录页的页面静态结构和样式
登录页面布局分解:
<img src="极客园移动端1.assets/image-20210831220941555.png" alt="image-20210831220941555" />
【特别说明】
本案例中,表单尽量不使用 antd-mobile 组件库里的表单组件来实现,因为它的表单组件并不好用,尤其是当要实现表单验证时比较麻烦。
因此,我们会使用原生的表单标签来实现。
操作步骤
- 将资源包中登录页面的样式文件拷贝到
pages/Login
目录中,然后在pages/Login/index.js
中编写如下代码:
import NavBar from '@/components/NavBar'
import styles from './index.module.scss'
export default function Login() {
return (
<div className={styles.root}>
{/* 导航条 */}
<NavBar>登录</NavBar>
{/* 内容 */}
<div className="content">
<h3>短信登录</h3>
<form>
<div className="input-item">
<input type="text" />
<div className="validate">手机号验证错误信息</div>
</div>
<div className="input-item">
<input type="text" />
<div className="validate">验证码验证错误信息</div>
</div>
{/* 登录按钮 */}
<button type="submit" className="login-btn">
登录
</button>
</form>
</div>
</div>
)
}
登录表单的数据绑定
目标:为登录表单中的输入组件进行数据绑定,收集表单数据
实现思路:
- 使用
formik
库进行表单的数据绑定
操作步骤
- 安装
formik
yarn add formik
- 使用
formik
库中提供的 Hook 函数创建 formik 表单对象
import { useFormik } from 'formik'
// ...
// Formik 表单对象
const form = useFormik({
// 设置表单字段的初始值
initialValues: {
mobile: '13900001111',
code: '246810'
},
// 提交
onSubmit: values => {
console.log(values)
}
})
- 绑定表单元素和 formik 表单对象
<form onSubmit={form.handleSubmit}>
<Input
name="mobile"
placeholder="请输入手机号"
value={form.values.mobile}
onChange={form.handleChange}
/>
<Input
name="code"
placeholder="请输入验证码"
extra="发送验证码"
maxLength={6}
value={form.values.code}
onChange={form.handleChange}
/>
登录表单的数据验证-基本
- 给useFormik提供validate函数进行校验
const formik = useFormik({
initialValues: {
mobile: '',
code: '',
},
// 当表单提交的时候,会触发
onSubmit(values) {
console.log(values)
},
validate(values) {
const errors = {}
if (!values.mobile) {
errors.mobile = '手机号不能为空'
}
if (!values.code) {
errors.code = '验证码不能为空'
}
return errors
},
})
- 需要给每一个表单元素绑定一个事件 onBlur,,,目的是为了区分那些输入框是被点击过的
<Input
placeholder="请输入手机号"
value={mobile}
name="mobile"
autoComplete="off"
onChange={handleChange}
onBlur={handleBlur}
></Input>
- 通过formik可以解构出来两个属性 touched 和 errors,,,,控制错误信息的展示
{touched.mobile && errors.mobile (
<div className="validate">{errors.mobile}</div>
) : null}
登录表单的数据验证
目标:验证表单中输入的内容的合法性
<img src="极客园移动端1.assets/image-20210901091137594.png" alt="image-20210901091137594" />
实现思路:
- 使用
formik
自带的表单验证功能 - 使用
yup
辅助编写数据的验证规则 - 验证不通过时,在输入项下显示验证后得到的实际错误信息
- 验证不通过时,禁用提交按钮
操作步骤
- 安装
yup
npm i yup --save
- 在创建
formik
表单对象时,添加表单验证相关参数
import * as Yup from 'yup'
// Formik 表单对象
const form = useFormik({
// 表单验证
validationSchema: Yup.object().shape({
// 手机号验证规则
mobile: Yup.string()
.required('请输入手机号')
.matches(/^1[3456789]\d{9}$/, '手机号格式错误'),
// 手机验证码验证规则
code: Yup.string()
.required('请输入验证码')
.matches(/^\d{6}$/, '验证码6个数字')
}),
// ...
})
- 处理验证错误信息
// 原先的两处错误信息代码
<div className="validate">手机号验证错误信息</div>
<div className="validate">{form.errors.code}</div>
// 改造成如下代码
{form.errors.mobile && form.touched.mobile && (
<div className="validate">{form.errors.mobile}</div>
)}
{form.errors.code && form.touched.code && (
<div className="validate">{form.errors.code}</div>
)}
- 验证出错时禁用登录按钮
import classnames from 'classnames'
<button
type="submit"
className={classnames('login-btn', form.isValid '' : 'disabled')}
disabled={!form.isValid}
>
登录
</button>
初步封装网络请求模块
目标:将 axios 封装成公用的网络请求模块,方便后续调用后端接口
(本章节中暂不处理 token 和 token 续期)
操作步骤
- 安装
axios
npm i axios --save
- 创建
utils/request.js
,并编写如下代码
import axios from 'axios'
// 1. 创建新的 axios 实例
const http = axios.create({
baseURL: 'http://geek.itheima.net/v1_0'
})
// 2. 设置请求拦截器和响应拦截器
http.interceptors.request.use(config => {
return config
})
http.interceptors.response.use(response => {
return response.data
}, error => {
return Promise.reject(error)
})
// 3. 导出该 axios 实例
export default http
发送手机验证码
目标:点击登录界面中的发送验证码按钮,调用后端接口进行验证码的发送
<img src="极客园移动端1.assets/image-20210901092838694.png" alt="image-20210901092838694" />
实现思路:
- 实现一个 redux action 函数,请求发送验证码后端接口
- 在验证码的 Input 组件的
onExtraClick
事件监听函数中调用 action
操作步骤
- 创建
store/actions/login.js
,并实现一个 Action 函数
import http from '@/utils/http'
/**
* 发送短信验证码
* @param {string} mobile 手机号码
* @returns thunk
*/
export const sendValidationCode = (mobile) => {
return async (dispatch) => {
const res = await http.get(`/sms/codes/${mobile}`)
console.log(res)
}
}
- 为验证码输入框组件添加
onExtraClick
事件监听
<Input
{/* ... */}
onExtraClick={sendSMSCode}
/>
- 实现事件监听函数,调用 Action
import { useDispatch } from 'react-redux'
// 获取 Redux 分发器
const dispatch = useDispatch()
// 发送短信验证码
const sendSMSCode = () => {
try {
// 手机号
const mobile = form.values.mobile
// 获取 Action
const action = sendValidationCode(mobile)
// 调用 Action
dispatch(action)
} catch (e) { }
}
验证码倒计时功能
const onExtraClick = async () => {
if (time > 0) return
// 先对手机号进行验证
if (!/^1[3-9]\d{9}$/.test(mobile)) {
formik.setTouched({
mobile: true,
})
return
}
try {
await dispatch(sendCode(mobile))
Toast.success('获取验证码成功', 1)
// 开启倒计时
setTime(5)
let timeId = setInterval(() => {
// 当我们每次都想要获取到最新的状态,需要写成 箭头函数的形式
setTime((time) => {
if (time === 1) {
clearInterval(timeId)
}
return time - 1
})
}, 1000)
} catch (err) {
if (err.response) {
Toast.info(err.response.data.message, 1)
} else {
Toast.info('服务器繁忙,请稍后重试')
}
}
}
函数组件的特性
React 中的函数组件是通过函数来实现的,函数组件的公式:f(state) => UI
,即:数据到视图的映射。
函数组件本身很简单,但因为是通过函数实现的,所以,在使用函数组件时,就会体现出函数所具有的特性来。
函数组件的特性说明:
- 对于函数组件来说,每次状态更新后,组件都会重新渲染。
- 并且,每次组件更新都像是在给组件拍照。每张照片就代表组件在某个特定时刻的状态。快照
- 或者说:
组件的每次特定渲染,都有自己的 props/state/事件处理程序
等。 - 这些照片记录的状态,从代码层面来说,是通过 JS 中函数的闭包机制来实现的。
这就是 React 中函数组件的特性,更加的函数式(利用函数的特性)
import { useState } from 'react'
import ReactDOM from 'react-dom'
// 没有 hooks 的函数组件:
const Counter = ({ count }) => {
// console.log(count)
const showCount = () => {
setTimeout(() => {
console.log('展示 count 值:', count)
}, 3000)
}
return (
<div>
<button onClick={showCount}>点击按钮3秒后显示count</button>
</div>
)
}
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
<h1>计数器:{count}</h1>
<button onClick={() => setCount(count + 1)}>+1</button>
<hr />
{/* 子组件 */}
<Counter count={count} />
</div>
)
}
ReactDOM.render(<App />, document.getElementById('root'))
useRef高级用法
登录并获取 Token
目标:点击登录按钮后,发送表单数据到后端登录接口,获取登录凭证 Token
实现思路:
- 实现一个 Action,去调用后端登录接口
- 在
formik
的表单提交方法onSubmit
中调用 Action
操作步骤
- 在
store/actions/login.js
中,添加一个 Action 函数
/**
* 登录
* @param {{ mobile, code }} values 登录信息
* @returns thunk
*/
export const login = params => {
return async dispatch => {
const res = await http.post('/authorizations', params)
const tokenInfo = res.data.data
console.log(tokenInfo)
}
}
- 在登录页面组件中的
formik
表单对象的onSubmit
方法中,调用 Action
import { login, sendValidationCode } from '@/store/actions/login'
// Formik 表单对象
const form = useFormik({
// ...
// 提交
onSubmit: async values => {
await dispatch(login(values))
}
})
如果能成功获取 Token 信息,控制台会打印出如下内容:
<img src="极客园移动端1.assets/image-20210901101009155.png" alt="image-20210901101009155" />
保存 Token 到 Redux
目标:将调用后端接口获取到的 Token 信息,放入 Redux 进行维护
实现思路:
- 实现一个 Reducer,用于在 Redux 中操作 Token 状态
- 实现一个 Action,在该 Action 中调用 Reducer 来保存 Token 状态
- 在上一章节获取 Token 的 Action 中,调用上面的 Action 来保存从后端刚获取到的 Token
操作步骤
- 创建
store/reducers/login.js
,并编写一个 Reducer 函数
// 初始状态
const initialState = {
token: '',
refresh_token: ''
}
// 操作 Token 状态信息的 reducer 函数
export const login = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'login/token': return { ...payload }
default: return state
}
}
- 在
store/reducers/index.js
中,将以上的 Reducer 函数组合进根 Reducer
import { combineReducers } from 'redux'
import { login } from './login'
// 组合各个 reducer 函数,成为一个根 reducer
const rootReducer = combineReducers({
login
})
// 导出根 reducer
export default rootReducer
- 在
store/actions/login.js
中,实现一个调用以上 Reducer 的 Action
/**
* 将 Token 信息保存到 Redux 中
* @param {*} tokens
* @returns
*/
export const saveToken = tokenInfo => {
return {
type: 'login/token',
payload: tokenInfo
}
}
- 在原先调用后端接口获取 Token 的 Action 中,调用
saveToken
Action
// 提交
onSubmit: async (values) => {
try {
await dispatch(login(values))
console.log('登陆成功')
} catch (e) {
console.log(e.response.data.message)
}
},
可以通过 Redux DevTools 插件,查看保存后的值:
<img src="极客园移动端1.assets/image-20210901182935828.png" alt="image-20210901182935828" />
提示消息优化
onSubmit: async (values) => {
try {
await dispatch(login(values))
Toast.success('登陆成功')
history.push('/home')
} catch (e) {
// console.log(e.response.data.message)
Toast.fail(e.response.data.message)
}
},
保存 Token 到本地缓存
目标:将从后端获取到的 Token 保存到浏览器的 LocalStorage 中
实现思路:
- 实现一个工具模块,在该模块中专门操作 LocalStorage 中的 Token 信息
- 在调用后端接口获取 Token 的 Action 中,调用该工具模块中的方法来存储 Token
操作步骤
- 创建
utils/storage.js
,并编写 Token 的设置、获取、删除等工具方法
// 用户 Token 的本地缓存键名
const TOKEN_KEY = 'geek-itcast'
/**
* 从本地缓存中获取 Token 信息
*/
export const getTokenInfo = () => {
return JSON.parse(localStorage.getItem(TOKEN_KEY)) || {}
}
/**
* 将 Token 信息存入缓存
* @param {Object} tokenInfo 从后端获取到的 Token 信息
*/
export const setTokenInfo = tokenInfo => {
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenInfo))
}
/**
* 删除本地缓存中的 Token 信息
*/
export const removeTokenInfo = () => {
localStorage.removeItem(TOKEN_KEY)
}
/**
* 判断本地缓存中是否存在 Token 信息
*/
export const hasToken = () => {
return !!getTokenInfo().token
}
- 原先调用后端接口获取 Token 的 Action 中,调用以上的本地缓存工具方法来保存 Token 信息
import { http, removeTokens, setTokens } from '@/utils'
export const login = params => {
return async dispatch => {
const res = await http.post('/authorizations', params)
const tokenInfo = res.data.data
// 保存 Token 到 Redux 中
dispatch(saveToken(tokenInfo))
// 保存 Token 到 LocalStorage 中
setTokenInfo(tokenInfo)
}
}
效果:
<img src="极客园移动端1.assets/image-20210901104851802.png" alt="image-20210901104851802" />
加载缓存的 Token 来初始化 Redux
目标:从缓存中读取 token 信息,如果存在则设置为 Redux Store 的初始状态
【如果不做本操作的话,会出现当页面刷新后,缓存中有值而 Redux 中无值的情况】
操作步骤
- 在
store/index.js
中,调用缓存工具方法来读取 Token 信息,并设置给createStore
相关参数:
import { getTokenInfo } from '@/utils/storage'
const store = createStore(
// ...
// 参数二:初始化时要加载的状态
{
login: getTokenInfo()
},
// ...
)
Redux 在实际开发中的常用模式
目标:根据上面几章的 redux 使用情况,总结实际开发时的最佳实践模式
推荐的目录结构
<img src="极客园移动端1.assets/image-20210901150309264.png" alt="image-20210901150309264" />
目录:store/actions
按功能模块的不同,拆分若干独立的文件,存放 Action Creator 函数。
- Action Creator 返回函数:用于含有异步行为的操作
export const test1 = params => {
return async dispatch => {
// 执行异步业务逻辑 ...
// 通过 dispatch 可以再调用其他 Action ...
}
}
- Action Creator 返回对象:用于同步行为的操作
export const test2 = params => {
// 推荐返回的 action 对象中,只存放两个属性:type、payload
return {
// 注意命名规范,推荐规则为 domain/eventName。例如:login/token
type: 'abc/hello',
// 所有要传递给 reducer 的业务数据,都放到 payload 属性上
payload: {}
}
}
目录:store/reducers
按功能模块的不同,拆分若干独立文件,存放 Reducer 函数。
最后,将这些独立的 Reducer 模块通过该目录中的 index.js
合并为根 Reducer。
// 根 Reducer
const rootReducer = combineReducers({
login,
profile,
home,
// ...
})
文件:store/index.js
用于创建和配置 Redux Store。
在组件中调用 Redux 的极简流程
// 第一步:使用 useDispatch() 获取分发器
const dispatch = useDispatch()
// 第二步:调用 Action Creator 获取 Action
const action = someActionCreatorFuncion()
// 第三步:通过向分发器调用 Action 函数内或 Reducer 函数内的业务逻辑
dispatch(action)
为网络请求添加 Token 请求头
目标:在发送请求时在请求头中携带上 Token 信息,以便在请求需要鉴权的后端接口时可以顺利调用
实现思路:
- 在 axios 请求拦截器中,读取保存在 Redux 或 LocalStorage 中的 Token 信息,并设置到请求头上
操作步骤
- 在
utils/reqeust.js
中,改造请求拦截器:
import { getTokenInfo } from './storage'
// 2. 设置请求拦截器和响应拦截器
http.interceptors.request.use((config) => {
// 获取缓存中的 Token 信息
const token = getTokenInfo().token
if (token) {
// 设置请求头的 Authorization 字段
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
整体布局
实现底部 tab 布局
目标:实现一个带有底部 tab 导航栏的页面布局容器组件,当点击底部按钮后,可切换显示不同内容
<img src="极客园移动端1.assets/image-20210830181305183.png" alt="image-20210830181305183" />
实现思路:
- 在组件中存在两个区域:页面内容区域、tab 按钮区域
- 定义一个数组来存放 tab 按钮相关数据,这样可以方便统一管理按钮
- 通过遍历数组来渲染 tab 按钮
- 点击按钮时,根据当前访问的页面路径和按钮本身的路径,判断当前按钮是否是选中状态,并添加高亮样式
- 点击按钮后,进行路由跳转
操作步骤
- 准备基本结构
export default function Home() {
return (
<div className={styles.root}>
{/* 区域一:点击按钮切换显示内容的区域 */}
<div className="tab-content"></div>
{/* 区域二:按钮区域,会使用固定定位显示在页面底部 */}
<div className="tabbar">
<div className="tabbar-item tabbar-item-active">
<Icon type="iconbtn_home_sel" />
<span>首页</span>
</div>
<div className="tabbar-item">
<Icon type="iconbtn_qa" />
<span>问答</span>
</div>
<div className="tabbar-item">
<Icon type="iconbtn_video" />
<span>视频</span>
</div>
<div className="tabbar-item">
<Icon type="iconbtn_mine" />
<span>我的</span>
</div>
</div>
</div>
)
}
将发放资料中的
资源 > src代码文件 > layouts > index.module.scss
拷贝到该目录下在组件定义一个数组,代表 tab 按钮的数据
// 将 tab 按钮的数据放在一个数组中
// - id 唯一性ID
// - title 按钮显示的文本
// - to 点击按钮后切换到的页面路径
// - icon 按钮上显示的图标名称
const buttons = [
{ id: 1, title: '首页', to: '/home', icon: 'iconbtn_home' },
{ id: 2, title: '问答', to: '/home/question', icon: 'iconbtn_qa' },
{ id: 3, title: '视频', to: '/home/video', icon: 'iconbtn_video' },
{ id: 4, title: '我的', to: '/home/profile', icon: 'iconbtn_mine' }
]
- 动态渲染TabBar
import Icon from '@/components/Icon'
import classnames from 'classnames'
import { useHistory, useLocation } from 'react-router-dom'
import styles from './index.module.scss'
// 将 tab 按钮的数据放在一个数组中
// ...
/**
* 定义 tab 布局组件
*/
const TabBarLayout = () => {
// 获取路由历史 history 对象
const history = useHistory()
// 获取路由信息 location 对象
const location = useLocation()
return (
<div className={styles.root}>
{/* 区域一:点击按钮切换显示内容的区域 */}
<div className="tab-content">
</div>
{/* 区域二:按钮区域,会使用固定定位显示在页面底部 */}
<div className="tabbar">
{buttons.map(btn => {
// 判断当前页面路径和按钮路径是否一致,如果一致则表示该按钮处于选中状态
const selected = btn.to === location.pathname
return (
<div
key={btn.id}
className={classnames('tabbar-item', selected 'tabbar-item-active' : '')}
onClick={() => history.push(btn.to)}
>
<Icon type={btn.icon + (selected '_sel' : '')} />
<span>{btn.title}</span>
</div>
)
})}
</div>
</div>
)
}
export default TabBarLayout
效果:
<img src="极客园移动端1.assets/image-20210831131915002.png" alt="image-20210831131915002" />
创建 tab 按钮页面并配置嵌套路由
目标:为 tab 布局组件中的 4 个按钮创建对应的页面;并配置路由,使按钮点击后能显示对应页面
操作步骤
- 创建四个页面组件:
- 首页:pages/Home/index.js
- 问答:pages/Question/index.js
- 视频:pages/Video/index.js
- 我的:pages/Profile/index.js
当前,这些组件的代码使用最简单的即可,如:
const Home = () => {
return (
<div>首页</div>
)
}
export default Home
- 在
layouts/TabBarLayout.js
中配置4个页面的路由
const Home = React.lazy(() => import('@/pages/Home'))
const QA = React.lazy(() => import('@/pages/QA'))
const Video = React.lazy(() => import('@/pages/Video'))
const Profile = React.lazy(() => import('@/pages/Profile'))
// ...
{/* 区域一:点击按钮切换显示内容的区域 */}
<div className="tab-content">
<Route path="/home/index" exact component={Home} />
<Route path="/home/question" exact component={Question} />
<Route path="/home/video" exact component={Video} />
<Route path="/home/profile" exact component={Profile} />
</div>
效果:
<img src="极客园移动端1.assets/image-20210831161825392.png" alt="image-20210831161825392" />
创建其他功能页面并配置路由
目标:事先创建本项目中将要开发的各个页面组件,并配置路由
【本章节所做的事,你也可以不一次性做完,可以一个一个页面边开发边配置】
说明:这些页面是除了 tab 底部导航栏上的4个页面以外的其他功能页
操作步骤
- 创建以下页面组件:
- 登录页面:pages/Login/index.js
- 搜索页面:pages/Search/index.js
- 搜索结果页面:pages/Search/Result/index.js
- 文章详情页面:pages/Article/index.js
- 个人信息编辑页面:pages/Profile/Edit/index.js
- 用户反馈页面:pages/Profile/Feedback/index.js
- 机器人客服聊天页面:pages/Profile/Chat/index.js
- 404 错误页面:pages/NotFound/index.js
当前,这些组件的代码使用最简单的即可,如:
const Login = () => {
return (
<div>登录</div>
)
}
export default Login
- 在根组件 App 中,配置以上页面的路由:
import Article from "./pages/Article"
import Login from "./pages/Login"
import NotFound from "./pages/NotFound"
import Chat from "./pages/Profile/Chat"
import ProfileEdit from "./pages/Profile/Edit"
import ProfileFeedback from "./pages/Profile/Feedback"
import Search from "./pages/Search"
import SearchResult from "./pages/Search/Result"
// ...
const App = () => {
return (
<Router history={history}>
<Switch>
{/* ... */}
{/* 不使用 tab 布局的界面 */}
<Route path="/login" component={Login} />
<Route path="/search" component={Search} />
<Route path="/article/:id" component={Article} />
<Route path="/search/result" component={SearchResult} />
<Route path="/profile/edit" component={ProfileEdit} />
<Route path="/profile/feedback" component={ProfileFeedback} />
<Route path="/profile/chat" component={Chat} />
<Route component={NotFound} />
</Switch>
</Router>
)
}
个人中心
个人中心主页的静态结构
目标:实现个人中心主页面的静态结构和样式
页面布局分解示意:
<img src="极客园移动端1.assets/image-20210901112241300.png" alt="image-20210901112241300" />
操作步骤
- 将资源包中个人中心页面的样式文件拷贝到
pages/Profile
目录中,然后在pages/Profile/index.js
中编写如下代码:
import Icon from '@/components/Icon'
import { Link, useHistory } from 'react-router-dom'
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={''} alt="" />
</div>
<div className="user-name">{'xxxxxxxx'}</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>{0}</p>
<p>动态</p>
</div>
<div className="count-item">
<p>{0}</p>
<p>关注</p>
</div>
<div className="count-item">
<p>{0}</p>
<p>粉丝</p>
</div>
<div className="count-item">
<p>{0}</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" onClick={() => history.push('/profile/feedback')}>
<Icon type="iconbtn_feedback" />
<div>用户反馈</div>
</div>
<div className="service-item" onClick={() => history.push('/profile/chat')}>
<Icon type="iconbtn_xiaozhitongxue" />
<div>小智同学</div>
</div>
</div>
</div>
</div>
)
}
export default Profile
请求个人基本信息
目标:进入个人中心页面时,调用后端接口,获取个人基本信息数据
实现思路:
- 使用 Hook 函数
useEffect
,在页面进入时,通过调用 Action 来调用后端接口
操作步骤
- 创建
store/actions/profile.js
,并编写 Action Creator 函数:
import http from "@/utils/http"
/**
* 获取用户基本信息
* @returns thunk
*/
export const getUser = () => {
return async dispatch => {
const res = await http.get('/user')
console.log(res);
}
}
- 在
pages/Profile/index.js
中,使用useEffect
在进入页面时调用 Action:
import { getUser } from '@/store/actions/profile'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
const dispatch = useDispatch()
// 在进入页面时执行
useEffect(() => {
dispatch(getUser())
}, [dispatch])
成功调用后,可在控制台中查看打印的个人基本信息数据:
<img src="极客园移动端1.assets/image-20210901175404937.png" alt="image-20210901175404937" />
将个人基本信息存入 Redux
目标:将从后端获取到的个人基本信息存入 Redux,以备用于后续的个人中心主页的界面渲染等
实现思路:
- 实现一个 Reducer,用于操作 Store 中的个人基本信息状态
- 通过一个 Action 来调用 Reducer,将个人基本信息保存到 Store 中
操作步骤
- 创建
store/reducers/profile.js
,编写操作个人基本信息的 Reducer 函数
// 初始状态
const initialState = {
// 基本信息
user: {},
}
// 操作用户个人信息状态的 reducer 函数
export const profile = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
// 设置基本信息
case 'profile/user':
return {
...state,
user: { ...payload }
}
// 默认
default:
return state
}
}
- 在
store/index.js
中配置以上新增的 Reducer
import { profile } from './profile'
const rootReducer = combineReducers({
login,
profile
})
- 在
store/actions/profile.js
中,添加一个可用于调用以上 Reducer 中的profile/user
逻辑的 Action Creator:
/**
* 设置个人基本信息
* @param {*} user
* @returns
*/
export const setUser = user => {
return {
type: 'profile/user',
payload: user
}
}
- 在之前调用后端接口的 Action Creator 函数
getUser
中,调用setUser
将数据保存到 Redux Store:
export const getUser = () => {
return async dispatch => {
const res = await http.get('/user')
const user = res.data.data
// 保存到 Redux 中
dispatch(setUser(user))
}
}
在 Redux DevTools 中确认数据是否已正确设置:
<img src="极客园移动端1.assets/image-20210901183426325.png" alt="image-20210901183426325" />
将个人基本信息渲染到界面
目标:从 Redux Store 中获取之前存入的用户基本信息,并渲染到个人中心页面的对应位置
实现思路:
- 使用
useSelector
从 Redux Store 中获取状态 - 将获取的状态渲染到界面上
操作步骤
- 在
pages/Profile/index.js
中,调用react-redux
提供的 Hook 函数useSelector
,从 Store 中获取之前存储的user
状态:
import { useDispatch, useSelector } from 'react-redux'
// 获取 Redux Store 中的个人基本信息
const user = useSelector(state => state.profile.user)
- 使用以上获取到的数据,填充界面上的相关元素
用户头像和用户名:
<div className="avatar">
<img src={user.photo} alt="" />
</div>
<div className="user-name">{user.name}</div>
<img src="极客园移动端1.assets/image-20210902083418445.png" alt="image-20210902083418445" />
统计信息:
<div className="count-list">
<div className="count-item">
<p>{art_count}</p>
<p>动态</p>
</div>
<div className="count-item">
<p>{follow_count}</p>
<p>关注</p>
</div>
<div className="count-item">
<p>{fans_count}</p>
<p>粉丝</p>
</div>
<div className="count-item">
<p>{like_count}</p>
<p>被赞</p>
</div>
</div>
<img src="极客园移动端1.assets/image-20210902083434060.png" alt="image-20210902083434060" />
个人详情页面的静态结构
目标:实现个人详情页的静态结构和样式
页面布局分解示意:
<img src="极客园移动端1.assets/image-20210902090634094.png" alt="image-20210902090634094" />
操作步骤
- 将资源包中个人详情页面的样式文件拷贝到
pages/Profile/Edit/
目录中,然后在pages/Profile/Edit/index.js
中编写如下代码
import NavBar from '@/components/NavBar'
import { DatePicker, List } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import styles from './index.module.scss'
const ProfileEdit = () => {
const history = useHistory()
return (
<div className={styles.root}>
<div className="content">
{/* 顶部导航栏 */}
<NavBar onLeftClick={() => history.go(-1)}>个人信息</NavBar>
<div className="wrapper">
{/* 列表一:显示头像、昵称、简介 */}
<List className="profile-list">
<List.Item arrow="horizontal" extra={
<span className="avatar-wrapper">
<img src={''} alt="" />
</span>
}>头像</List.Item>
<List.Item arrow="horizontal" extra={'昵称xxxx'}>昵称</List.Item>
<List.Item arrow="horizontal" extra={
<span className="intro">{'未填写'}</span>
}>简介</List.Item>
</List>
{/* 列表二:显示性别、生日 */}
<List className="profile-list">
<List.Item arrow="horizontal" extra={'男'}>性别</List.Item>
<DatePicker
mode="date"
title="选择年月日"
value={new Date()}
minDate={new Date(1900, 1, 1, 0, 0, 0)}
maxDate={new Date()}
onChange={() => { }}
>
<List.Item arrow="horizontal" extra={'2020-02-02'}>生日</List.Item>
</DatePicker>
</List>
{/* 文件选择框,用于头像图片的上传 */}
<input type="file" hidden />
</div>
{/* 底部栏:退出登录按钮 */}
<div className="logout">
<button className="btn">退出登录</button>
</div>
</div>
</div>
)
}
export default ProfileEdit
请求个人详情
目标:进入个人详情页面时,调用后端接口,获取个人详情数据
实现思路:
- 使用 Hook 函数
useEffect
,在页面进入时,通过调用 Action 来调用后端接口
操作步骤
- 在
store/actions/profile.js
中编写 Action Creator 函数:
/**
* 获取用户详情
* @returns thunk
*/
export const getUserProfile = () => {
return async dispatch => {
const res = await http.get('/user/profile')
console.log(res)
}
}
- 在
pages/Profile/Edit/index.js
中,使用useEffect
在进入页面时调用 Action:
import { getUserProfile } from '@/store/actions/profile'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
const dispatch = useDispatch()
useEffect(() => {
dispatch(getUserProfile())
}, [dispatch])
成功调用后,可在控制台中查看打印数据:
<img src="极客园移动端1.assets/image-20210902093122663.png" alt="image-20210902093122663" />
将个人详情存入 Redux
目标:将从后端获取到的个人详情存入 Redux,以备用于后续的个人详情页面的界面渲染
实现思路:
- 实现一个 Reducer,用于操作 Store 中的个人详情状态
- 通过一个 Action 来调用 Reducer,将个人详情保存到 Store 中
操作步骤
- 在
store/reducers/profile.js
中,添加个人详情状态,以及设置个人详情的 Reducer 逻辑:
// 初始状态
const initialState = {
// ...
// 详情信息
userProfile: {}
}
// 操作用户个人信息状态的 reducer 函数
export const profile = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
// 设置详情信息
case 'profile/profile':
return {
...state,
userProfile: { ...payload }
}
// ...
}
}
- 在
store/actions/profile.js
中,添加一个可用于调用以上 Reducer 中的profile/profile
逻辑的 Action Creator:
/**
* 设置个人详情
* @param {*} profile
* @returns
*/
export const setUserProfile = profile => ({
type: 'profile/profile',
payload: profile
})
- 在之前调用后端接口的 Action Creator 函数
getUserProfile
中,调用setUserProfile
将数据保存到 Redux Store:
export const getUserProfile = () => {
return async dispatch => {
const res = await http.get('/user/profile')
const profile = res.data.data
// 保存到 Redux 中
dispatch(setUserProfile(profile))
}
}
将个人详情渲染到界面
目标:从 Redux Store 中获取之前保存的个人详情,并渲染到个人详情页的对应位置
实现思路:
- 使用
useSelector
从 Redux Store 中获取状态 - 将获取的状态渲染到界面上
操作步骤
- 在
pages/Profile/Edit/index.js
中,调用react-redux
提供的 Hook 函数useSelector
,从 Store 中获取之前存储的userProfile
状态:
import { useDispatch, useSelector } from 'react-redux'
// 获取 Redux Store 中个人详情
const profile = useSelector(state => state.profile.userProfile)
- 使用以上获取到的数据,填充界面上的相关元素
头像、昵称、简介:
<List.Item arrow="horizontal" extra={
<span className="avatar-wrapper">
<img src={profile.photo} alt="" />
</span>
}>头像</List.Item>
<List.Item arrow="horizontal" extra={profile.name}>昵称</List.Item>
<List.Item arrow="horizontal" extra={
<span className={classnames("intro", profile.intro 'normal' : '')}>
{profile.intro || '未填写'}
</span>
}>简介</List.Item>
性别、生日:
<List.Item arrow="horizontal" extra={profile.gender === 0 '男' : '女'}>性别</List.Item>
<DatePicker
mode="date"
title="选择年月日"
value={new Date(profile.birthday)}
minDate={new Date(1900, 1, 1, 0, 0, 0)}
maxDate={new Date()}
onChange={() => { }}
>
<List.Item arrow="horizontal" extra={profile.birthday}>生日</List.Item>
</DatePicker>
编辑个人详情:介绍
目标:了解编辑个人详情时,对于不同字段的编辑界面形式
当点击个人信息项时会以滑动抽屉的形式展现输入界面,主要有两种:
一、从屏幕右侧滑入的:全屏表单抽屉
<img src="极客园移动端1.assets/image-20210902110852864.png" alt="image-20210902110852864" />
该界面的布局是固定的:顶部导航栏、要编辑的字段名称、一个内容输入框。
采用这种界面方式进行编辑的是:昵称、简介。
二、从屏幕底部滑入的:菜单列表抽屉
<img src="极客园移动端1.assets/image-20210902111141816.png" alt="image-20210902111141816" />
该界面的布局是固定的:一个列表、一个取消按钮。
采用这种界面方式进行编辑的是:头像、性别。
实现思路
- 将这两种界面封装成2个组件
- 向组件传入配置信息,让组件按配置信息显示对应的内容
编辑个人详情-抽屉组件基本使用
// 控制抽屉组件的显示
const [inputOpen, setInputOpen] = useState(false)
{/* 全屏表单抽屉 */}
<Drawer
position="right"
className="drawer"
style={{ minHeight: document.documentElement.clientHeight }}
sidebar={<div onClick={() => setInputOpen(false)}>全屏抽屉</div>}
open={inputOpen}
/>
<List.Item
arrow="horizontal"
extra={profile.name}
onClick={() => setInputOpen(true)}
>
昵称
</List.Item>
<List.Item
arrow="horizontal"
extra={
<span
className={classNames('intro', profile.intro 'normal' : '')}
>
{profile.intro || '未填写'}
</span>
}
onClick={() => setInputOpen(true)}
>
简介
</List.Item>
编辑个人详情-EditInput组件
- 导入样式
- 准备结构
import React from 'react'
import NavBar from '@/components/NavBar'
import styles from './index.module.scss'
export default function EditInput({ onClose }) {
return (
<div className={styles.root}>
<NavBar
rightContent={<span className="commit-btn">提交</span>}
className="navbar"
onLeftClick={onClose}
>
编辑昵称
</NavBar>
<div className="content">
<h3>昵称</h3>
</div>
</div>
)
}
- 父组件渲染
{/* 全屏表单抽屉 */}
<Drawer
position="right"
className="drawer"
style={{ minHeight: document.documentElement.clientHeight }}
sidebar={<EditInput onClose={onClose}></EditInput>}
open={inputOpen}
children={''}
/>
编辑个人详情-navBar组件修改
编辑个人详情-同时控制昵称和简介
- 修改数据格式
// 控制抽屉组件的显示
const [inputOpen, setInputOpen] = useState({
// 抽屉显示状态
visible: false,
// 显示的类型
type: '',
})
const onClose = () => {
setInputOpen({
visible: false,
type: '',
})
}
<List.Item
arrow="horizontal"
extra={profile.name}
onClick={() =>
setInputOpen({
visible: true,
type: 'name',
})
}
>
昵称
</List.Item>
<List.Item
arrow="horizontal"
extra={
<span
className={classNames('intro', profile.intro 'normal' : '')}
>
{profile.intro || '未填写'}
</span>
}
onClick={() =>
setInputOpen({
visible: true,
type: 'intro',
})
}
>
简介
</List.Item>
{/* 全屏表单抽屉 */}
<Drawer
position="right"
className="drawer"
style={{ minHeight: document.documentElement.clientHeight }}
sidebar={
<EditInput onClose={onClose} type={inputOpen.type}></EditInput>
}
open={inputOpen.visible}
children={''}
/>
编辑个人详情-封装包含字数统计的TextArea
目标:对
<textarea>
进行封装,使得在输入内容时可以显示当前已输入字数和允许输入的总字数
<img src="极客园移动端1.assets/image-20210902154346207.png" alt="image-20210902154346207" />
实现思路:
- 声明一个状态,用于记录输入字数
- 在
<textarea>
输入内容触发change
事件时,获取当前最新内容得到最新字数,更新到状态中
操作步骤
- 创建
components/Textarea/index.js
,并将资源包中的样式文件拷贝过来,然后编写以下代码:
import classnames from 'classnames'
import { useState } from 'react'
import styles from './index.module.scss'
/**
* 带字数统计的多行文本
* @param {String} className 样式类
* @param {String} value 文本框的内容
* @param {String} placeholder 占位文本
* @param {Function} onChange 输入内容变动事件
* @param {String} maxLength 允许最大输入的字数(默认100个字符)
*/
const Textarea = ({ className, value, placeholder, onChange, maxLength = 100 }) => {
// 字数状态
const [count, setCount] = useState(value.length || 0)
// 输入框的 change 事件监听函数
const onValueChange = e => {
// 获取最新的输入内容,并将它的长度更新到 count 状态
const newValue = e.target.value
setCount(newValue.length)
// 调用外部传入的事件回调函数
onChange(e)
}
return (
<div className={classnames(styles.root, className)}>
{/* 文本输入框 */}
<textarea
className="textarea"
maxLength={maxLength}
placeholder={placeholder}
value={value}
onChange={onValueChange}
/>
{/* 当前字数/最大允许字数 */}
<div className="count">{count}/{maxLength}</div>
</div>
)
}
export default Textarea
编辑个人详情-昵称和简介的回显
- 控制显示昵称和简介
import React from 'react'
import NavBar from '@/components/NavBar'
import styles from './index.module.scss'
import Input from '@/components/Input'
import Textarea from '@/components/Textarea'
export default function EditInput({ onClose, type }) {
return (
<div className={styles.root}>
<NavBar
rightContent={<span className="commit-btn">提交</span>}
className="navbar"
onLeftClick={onClose}
>
编辑{type === 'name' '昵称' : '简介'}
</NavBar>
<div className="content-box">
<h3>{type === 'name' '昵称' : '简介'}</h3>
{type === 'name' (
<div className="input-wrap">
<Input />
</div>
) : (
<Textarea placeholder="请输入" />
)}
</div>
</div>
)
}
- 数据回显
import Input from '@/components/Input'
import NavBar from '@/components/NavBar'
import Textarea from '@/components/Textarea'
import { useState } from 'react'
import styles from './index.module.scss'
const EditInput = ({ config, onClose, onCommit }) => {
const [value, setValue] = useState(config.value || '')
const { title, type } = config
const onValueChange = (e) => {
setValue(e.target.value)
}
return (
<div className={styles.root}>
<NavBar
className="navbar"
onLeftClick={onClose}
rightContent={
<span className="commit-btn" onClick={() => onCommit(type, value)}>
提交
</span>
}
>
编辑{title}
</NavBar>
<div className="content">
<h3>{title}</h3>
{type === 'name' (
<div className="input-wrap">
<Input value={value} onChange={onValueChange} />
</div>
) : (
<Textarea
placeholder="请输入"
value={value}
onChange={onValueChange}
/>
)}
</div>
</div>
)
}
export default EditInput
编辑个人详情:完成昵称和简介的修改
目标:在抽屉表单中编辑昵称或简介后,将表单返回的数据提交到后端进行更新,并更新到 Redux
实现思路:
- 编写用于在 Redux 中更新个人详情字段的 Reducer
- 编写用于通过调用后端接口即 Reducer 来更新个人详情字段的 Action
- 在抽屉表单提交数据时调用 Action
操作步骤
- 在
store/actions/profile.js
中,编写 Action Creator:
/**
* 修改个人详情:昵称、简介、生日、性别 (每次修改一个字段)
* @param {String} name 要修改的字段名称
* @param {*} value 要修改的字段值
* @returns thunk
*/
export const updateProfile = (name, value) => {
return async dispatch => {
// 调用接口将数据更新到后端
const res = await http.patch('/user/profile', { [name]: value })
// 如果后端更新成功,则再更新 Redux 中的数据
if (res.data.message === 'OK') {
dispatch(getUserProfile())
}
}
}
- 为抽屉表单组件设置
onCommit
回调函数,并在该函数中调用以上的 Action:
<EditInput
// ...
onCommit={onFormCommit}
/>
import { getUserProfile, updateProfile } from '@/store/actions/profile'
// 抽屉表单的数据提交
const onFormCommit = (name, value) => {
// 调用 Action 更新数据
dispatch(updateProfile(name, value))
// 关闭抽屉
toggleDrawer(false)
}
编辑个人详情-准备性别和头像的抽屉组件
// 关闭昵称和简介的显示
const onClose = () => {
setInputOpen({
visible: false,
type: '',
})
setListOpen({
visible: false,
type: '',
})
}
// 控制头像和性别
const [listOpen, setListOpen] = useState({
visible: false,
type: '',
})
{/* 头像、性别 */}
<Drawer
className="drawer-list"
position="bottom"
sidebar={<div>性别和头像</div>}
open={listOpen.visible}
onOpenChange={onClose}
>
{''}
</Drawer>
</div>
<List.Item
arrow="horizontal"
onClick={() =>
setListOpen({
visible: true,
type: 'avatar',
})
}
extra={
<span className="avatar-wrapper">
<img src={profile.photo} alt="" />
</span>
}
>
头像
</List.Item>
<List.Item
arrow="horizontal"
extra={profile.gender === 0 '男' : '女'}
onClick={() =>
setListOpen({
visible: true,
type: 'avatar',
})
}
>
性别
</List.Item>
编辑个人详情-EditList组件
- 准备样式
- 准备结构
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
编辑个人详情-控制显示
- 父组件提供数据
const config = {
avatar: [
{
title: '拍照',
onClick: () => {},
},
{
title: '本地选择',
onClick: () => {},
},
],
gender: [
{
title: '男',
onClick: () => {},
},
{
title: '女',
onClick: () => {},
},
],
}
- 传递给子组件
{/* 头像、性别 */}
<Drawer
className="drawer-list"
position="bottom"
sidebar={<EditList config={config} type={listOpen.type}></EditList>}
open={listOpen.visible}
onOpenChange={onClose}
>
{''}
</Drawer>
- 子组件渲染
import styles from './index.module.scss'
const EditList = ({ type, config, onClose }) => {
const list = config[type]
return (
<div className={styles.root}>
{list.map((item) => (
<div className="list-item" key={item.title}>
{item.title}
</div>
))}
<div className="list-item" onClick={onClose}>
取消
</div>
</div>
)
}
export default EditList
编辑个人详情:抽屉上的列表组件
目标:封装用于显示在列表抽屉中的列表组件,它可通过配置的方式显示不同列表项
<img src="极客园移动端1.assets/image-20210902171500980.png" alt="image-20210902171500980" />
<img src="极客园移动端1.assets/image-20210902171524407.png" alt="image-20210902171524407" />
实现思路:
- 界面主要由一个列表和一个取消按钮组成
- 界面中的列表数据通过组件属性传入
- “取消” 按钮的监听函数通过组件属性传入
操作步骤
- 创建
pages/Profile/Edit/components/EditList/
目录,并将资源包中的样式文件拷贝进来
- 创建
pages/Profile/Edit/components/EditList/index.js
,编写组件:
//【说明】:组件的 config 属性是一个对象,包含以下内容:
{
"字段1": {
name: '数组字段名',
items: [
{
title: '选项一',
value: '选项一的值'
},
{
title: '选项二',
value: '选项二的值'
}
]
},
// 其他字段...
}
组件代码:
import styles from './index.module.scss'
/**
* 个人信息项修改列表
* @param {Object} config 配置信息对象
* @param {Function} onSelect 选择列表项的回调函数
* @param {Function} onClose 取消按钮的回调函数
*/
const EditList = ({ config = {}, onSelect, onClose }) => {
return (
<div className={styles.root}>
{/* 列表项 */}
{config.items?.map((item, index) => (
<div
className="list-item"
key={index}
onClick={() => onSelect(config.name, item, index)}
>
{item.title}
</div>
))}
{/* 取消按钮 */}
<div className="list-item" onClick={onClose}>取消</div>
</div>
)
}
export default EditList
编辑个人详情:完成性别的修改
目标:在从点击性别进入的抽屉列表中选择一项后,将选中的数据提交到后端进行更新,并更新到 Redux
实现思路:
- 借助之前实现的更新个人详情的 Action 封装的魅力
const config = {
gender: [
{
title: '男',
onClick: () => {
onCommit('gender', 0)
},
},
{
title: '女',
onClick: () => {
onCommit('gender', 1)
},
},
],
}
编辑个人详情:完成头像的修改
目标:在从点击头像进入的抽屉列表中选择一项后,从弹出的文件选择器中选取一张图片上传到后端,并将新头像地址更新到 Redux
实现思路:
- 实现一个用于调用接口进行头像上传、及将上传后的新图片地址更新到 Redux 的 Action
- 使用 Hook 函数
useRef
操作文件输入框元素<input type="file">
,触发文件输入弹框 - 监听文件输入框的
onChange
事件,在文件变化时调用 Action 进行上传
操作步骤
- 在
store/actions/profile.js
中,实现用于上传头像的 Action Creator:
/**
* 更新头像
* @param {FormData} formData 上传头像信息的表单数据
* @returns thunk
*/
export const updateAvatar = (formData) => {
return async (dispatch) => {
// 调用接口进行上传
const res = await http.patch('/user/photo', formData)
// 如果后端更新成功,则再更新 Redux 中的数据
if (res.data.message === 'OK') {
dispatch(getUserProfile())
}
}
}
- 创建 ref 对象,并关联到文件输入框元素
import { useEffect, useRef, useState } from 'react'
const fileRef = useRef()
<input type="file" hidden ref={fileRef} />
- 修改config对象
const config = {
avatar: [
{
title: '拍照',
onClick: () => {
fileRef.current.click()
},
},
{
title: '本地选择',
onClick: () => {
fileRef.current.click()
},
},
],
gender: [
{
title: '男',
onClick: () => {
onCommit('gender', 0)
},
},
{
title: '女',
onClick: () => {
onCommit('gender', 1)
},
},
],
}
- 为文件输入框添加
onChange
监听函数,并在该函数中获取选中的文件后调用 Action 进行上传和更新
<input type="file" hidden ref={fileRef} onChange={onAvatarChange} />
import { getUserProfile, updateAvatar, updateProfile } from '@/store/actions/profile'
const onAvatarChange = (e) => {
// 获取选中的图片文件
const file = e.target.files[0]
// 生成表单数据
const formData = new FormData()
formData.append('photo', file)
// 调用 Action 进行上传和 Redux 数据更新
dispatch(updateAvatar(formData))
Toast.success('头像上传成功')
onClose()
}
编辑个人详情:完成生日的修改
目标:在从点击生日进入的日期选择器中选择新日期后,将选中数据提交到后端进行更新,并更新到 Redux
实现思路:
- 借助之前实现的更新个人详情的 Action
操作步骤
- 为日期选择器组件设置
onChange
回调函数,在该函数执行对应 Action
<DatePicker
// ...
onChange={onBirthdayChange}
>
const onBirthdayChange = async (value) => {
const year = value.getFullYear()
const month = value.getMonth() + 1
const day = value.getDate()
const dateStr = `${year}-${month}-${day}`
// 调用 Action 更新数据
await dispatch(updateProfile('birthday', dateStr))
Toast.success('修改生日成功')
}
退出登录
目标:点击 “退出登录” 按钮后返回到登录页面
<img src="极客园移动端1.assets/image-20210902104308053.png" alt="image-20210902104308053" />
实现思路:
- 点击 “退出登录” 后,需要弹信息框让用户确认
- 确认退出,则清空 Redux 和 LocalStorage 中的 Token 信息
- 清空 Token 后跳转页面到登录页
操作步骤
- 为“退出登录”按钮添加点击事件,并在监听函数中弹出确认框:
<button className="btn" onClick={onLogout}>退出登录</button>
import { DatePicker, List, Modal } from 'antd-mobile'
// 退出登录
const onLogout = () => {
// 弹出确认对话框
Modal.alert('温馨提示', '你确定退出吗?', [
// 取消按钮
{ text: '取消' },
// 确认按钮
{
text: '确认',
style: { color: '#FC6627' },
onPress: () => {
console.log('执行登出....')
}
}
])
}
- 在
store/reducers/login.js
中,添加删除 Token 信息的 Reducer 逻辑:
switch (type) {
case 'login/logout': return {}
// ...
}
- 在
store/action/login.js
中,添加用于从 Redux 和 LocalStorage 中删除 Token 信息的 Action Creator:
/**
* 退出
* @returns
*/
export const logout = () => {
return (dispatch) => {
removeTokenInfo()
dispatch({
type: 'login/logout',
})
}
}
- 在“退出登录”的弹框回调
onPress
中调用以上 Action 删除 Token 后,跳转到登录页:
import { logout } from '@/store/actions/login'
onPress: () => {
// 删除 Token 信息
dispatch(logout())
// 跳转到登录页
history.replace('/login')
}
小智同学
websocket
WebSocket 是一种数据通信协议,类似于我们常见的 http 协议。
为什么需要 WebSocket?
初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?
答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。http基于请求响应实现。
举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用"轮询":每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。
轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。
websocket简介
WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。
它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。
典型的websocket应用场景:
- 即时通讯,,,客服
- 聊天室 广播
- 点餐
websocket使用-原生
基本步骤
- 浏览器发出链接请求
- 服务器告知链接成功
- 双方进行双向通讯
- 关闭连接
核心api
// 打开websocket连接
// WebSocket 是浏览器的内置对象
var ws = new WebSocket('wss://echo.websocket.org') // 建立与服务端地址的连接
// 如果与服务器建立连接成功, 调用 websocket实例的 回调函数 onopen
ws.onopen = function () {
// 如果执行此函数 表示与服务器建立关系成功
}
// 发送消息
ws.send('消息')
// 接收消息
ws.onmessage = function (event) {
// event中的data就是服务器发过来的消息
}
ws.close()
// 关闭连接成功
ws.onclose = function () {
// 关闭连接成功
}
示例demo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>体验websocket</title>
<style>
#contanier {
width: 500px;
height: 400px;
border: 2px dashed #7575e7;
overflow-y: auto;
}
</style>
</head>
<body>
<div id="contanier"></div>
<!-- 1 建立连接 (拨号) -->
<!-- 2 发消息 接消息 -->
<!-- 3 关闭连接 -->
<input type="text" id="message" />
<button onclick="openWS()">建立连接</button>
<button onclick="sendMessage()">发送消息</button>
<button onclick="closeWS()">关闭连接</button>
<script>
var dom = document.getElementById('contanier')
var inputDom = document.getElementById('message')
var isOpen = false // 表示是否已经建立了拨号
var ws // 别的方法 也需要使用ws
// 打开websocket连接
var openWS = function() {
/// 网络上提供的一个测试websocket功能的服务器地址。
/// 它的效果是,你向服务器发什么消息 ,它就完全回复还给你。
ws = new WebSocket('wss://echo.websocket.org') // 建立与服务器的联系
// onopen是webSocket约定事件名
// 当本地客户端浏览器与服务器建立连接之后,就会执行onopen的回调
ws.onopen = function(event) {
isOpen = true
// 建立成功
dom.innerHTML = dom.innerHTML + `<p>与服务器成功建立连接</p>`
}
// 接收消息
// onmessage是webSocket约定事件名
// 如果从服务器上发过来了消息,则会进入onmessage的回调
ws.onmessage = function(event) {
// 由于 我们先给服务器发了消息 服务器给我们回了消息
dom.innerHTML =
dom.innerHTML + `<p style='color: blue'>服务器说:${event.data}</p>`
}
// onclose是webSocket约定事件名
ws.onclose = function() {
// 此函数表示 关闭连接成功
isOpen = false // 把状态关闭掉
dom.innerHTML = dom.innerHTML + `<p>与服务器连接关闭</p>`
}
}
// 发送消息 接收消息
var sendMessage = function() {
if (inputDom.value && isOpen) {
// 发消息 要等到 连接成功才能发 而且内容不为空
// 发消息就是send
ws.send(inputDom.value) // 发送消息
// 发完之后 添加到 当前视图上
dom.innerHTML =
dom.innerHTML + `<p style='color: red'>我说:${inputDom.value}</p>`
inputDom.value = ''
}
}
// 关闭连接
var closeWS = function() {
ws.close() // 关闭连接
}
</script>
</body>
</html>
聊天客服:小智同学页面的静态结构
目标:实现小智同学页面的静态结构和样式
页面布局结构分析:
<img src="极客园移动端1.assets/image-20210903174406559.png" alt="image-20210903174406559" />
操作步骤
- 将资源包中的样式文件拷贝到
pages/Profile/Chat/
目录下,然后在该目录中的index.js
里编写:
import Icon from '@/components/Icon'
import Input from '@/components/Input'
import NavBar from '@/components/NavBar'
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" onLeftClick={() => 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
- 配置路由规则
聊天客服:动态渲染聊天记录列表
目标:将聊天数据存在数组状态中,再动态渲染到界面上
操作步骤
- 声明一个数组状态
import { useEffect, useRef, useState } from 'react'
// 聊天记录
const [messageList, setMessageList] = useState([
// 放两条初始消息
{ type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
{ type: 'user', text: '你好' }
])
- 从 Redux 中获取当前用户基本信息
import { useSelector } from 'react-redux'
// 当前用户信息
const user = useSelector(state => state.profile.user)
- 根据数组数据,动态渲染聊天记录列表
{/* 聊天记录列表 */}
<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 || 'http://toutiao.itheima.net/images/user_head.jpg'} alt="" />
<div className="message">{msg.text}</div>
</div>
)
}
})}
</div>
效果:
<img src="极客园移动端1.assets/image-20210904085509862.png" alt="image-20210904085509862" />
聊天客服:建立与服务器的连接
目标:使用 socket.io 客户端与服务器建立 WebSocket 长连接
本项目聊天客服的后端接口,使用的是基于 WebSocket 协议的 socket.io 接口。我们可以使用专门的 socket.io 客户端库,就能轻松建立起连接并进行互相通信。
实现思路:
- 借助
useEffect
,在进入页面时调用客户端库建立 socket.io 连接
操作步骤
- 安装 socket.io 客户端库:
socket.io-client
npm i socket.io-client --save
- 在进入机器人客服页面时,创建 socket.io 客户端
import io from 'socket.io-client'
import { getTokenInfo } from '@/utils/storage'
// 用于缓存 socket.io 客户端实例
const clientRef = useRef(null)
useEffect(() => {
// 创建客户端实例
const client = io('http://toutiao.itheima.net', {
transports: ['websocket'],
// 在查询字符串参数中传递 token
query: {
token: getTokenInfo().token
}
})
// 监听连接成功的事件
client.on('connect', () => {
// 向聊天记录中添加一条消息
setMessageList(messageList => [
...messageList,
{ type: 'robot', text: '我现在恭候着您的提问。' }
])
})
// 监听收到消息的事件
client.on('message', data => {
console.log('>>>>收到 socket.io 消息:', data)
})
// 将客户端实例缓存到 ref 引用中
clientRef.current = client
// 在组件销毁时关闭 socket.io 的连接
return () => {
client.close()
}
}, [])
正常情况,一进入客服页面,就能在控制台看到连接成功的信息:
<img src="极客园移动端1.assets/image-20210903181934664.png" alt="image-20210903181934664" />
聊天客服:给机器人发消息
目标:将输入框内容通过 socket.io 发送到服务端
实现思路:
- 使用 socket.io 实例的
emit()
方法发送信息
操作步骤
- 声明一个状态,并绑定消息输入框
// 输入框中的内容
const [message, setMessage] = useState('')
<Input
className="no-border"
placeholder="请描述您的问题"
value={message}
onChange={e => setMessage(e.target.value)}
/>
- 为消息输入框添加键盘事件,在输入回车时发送消息
<Input
// ...
onKeyUp={onSendMessage}
/>
// 按回车发送消息
const onSendMessage = e => {
if (e.keyCode === 13) {
// 通过 socket.io 客户端向服务端发送消息
clientRef.current.emit('message', {
msg: message,
timestamp: Date.now()
})
// 向聊天记录中添加当前发送的消息
setMessageList(messageList => [
...messageList,
{ type: 'user', text: message }
])
// 发送后清空输入框
setMessage('')
}
}
聊天客服:接收机器人回复的消息
目标:
通过 socket.io 监听回复的消息,并添加到聊天列表中;
且当消息较多出现滚动条时,有后续新消息的话总将滚动条滚动到最底部。
实现思路:
- 使用 socket.io 实例的
message
事件接收信息 - 在聊天列表数据变化时,操作列表容器元素来设置滚动量
操作步骤
- 在 socket.io 实例的
message
事件中,将接收到的消息添加到聊天列表:
// 监听收到消息的事件
client.on('message', data => {
// 向聊天记录中添加机器人回复的消息
setMessageList(messageList => [
...messageList,
{ type: 'robot', text: data.msg }
])
})
- 声明一个 ref 并设置到聊天列表的容器元素上
// 用于操作聊天列表元素的引用
const chatListRef = useRef(null)
<div className="chat-list" ref={chatListRef}>
- 通过
useEffect
监听聊天数据变化,对聊天容器元素的 scrollTop 进行设置:
// 监听聊天数据的变化,改变聊天容器元素的 scrollTop 值让页面滚到最底部
useEffect(() => {
chatListRef.current.scrollTop = chatListRef.current.scrollHeight
}, [messageList])
权限控制
封装鉴权路由组件
目标:基于 Route 组件,封装一个判断存在 token 才能正常渲染指定 component 的路由组件
本项目中有些页面需要登录后才可访问,如:个人中心的所有页面
因此我们需要为 Route 组件添加额外的逻辑,使得在路由匹配后进行界面展示时,可以按条件决定如何渲染。
实现思路:
- 使用 Router 组件的
render-props
机制
操作步骤
- 创建
components/AuthRoute/index.js
,编写组件代码:
import { hasToken } from '@/utils/storage'
import { Redirect, Route } from 'react-router-dom'
/**
* 鉴权路由组件
* @param {*} component 本来 Route 组件上的 component 属性
* @param {Array} rest 其他属性
*/
const AuthRoute = ({ component: Component, ...rest }) => {
return (
<Route {...rest} render={props => {
// 如果有 token,则展示传入的组件
if (hasToken) {
return <Component />
}
// 否则调用 Redirect 组件跳转到登录页
return (
<Redirect to={{
pathname: '/login',
state: {
from: props.location.pathname
}
}} />
)
}} />
)
}
export default AuthRoute
- 在
App.js
和layouts/TabBarLayout.js
中,使用AuthRoute
组件替代某些Route
:
import AuthRoute from '@/components/AuthRoute'
<AuthRoute path="/profile/edit" component={ProfileEdit} />
<AuthRoute path="/profile/feedback" component={ProfileFeedback} />
<AuthRoute path="/profile/chat" component={Chat} />
<AuthRoute path="/home/profile" exact component={Profile} />
替代后,如果未经登录访问个人中心的页面,就会直接跳到登录页。
修改Router的history
- 新增文件 utils/history.js
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
export default history
- 修改App.js
import { Router, Route, Switch, Redirect } from 'react-router-dom'
import history from '@/utils/history'
export default function App() {
return (
<Router history={history}>
// ....
}
Token 的失效处理和无感刷新
目标:了解当请求后端接口时,如果发生了由于 Token 失效而产生的请求失败,应该如何进行处理
token: 访问令牌,通过这个token就能够访问项目
- 有效时间都不会很长,一般就是一个小时或者2个小时
- token过期的处理
- 重新登录(适合PC端的管理系统)
- 对于移动端资讯类的项目用户体验不好。
refresh_token: 刷新令牌,没有访问的功能,通过刷新令牌能够获取到一个新的访问令牌。
- 刷新令牌:有效时间会比较长
常用的处理流程:
思想总结:
- 无 Token,直接跳到登录页
- 有 Token,则用 Refresh Token 换新 Token:换成功则用新 Token 重发原先的请求,没换成功则跳到登录页
这一系列操作,可以在封装的 http 请求模块中完成。
操作步骤
// 配置响应拦截器
instance.interceptors.response.use(
(response) => {
// 对响应做点什么...
return response.data
},
async (err) => {
// 如果是网络错误
if (!err.response) {
Toast.info('网络繁忙,请稍后重试')
return Promise.reject(err)
}
// 如果有响应,但是不是401错误
if (err.response.status !== 401) {
Toast.info(err.response.data.message)
return Promise.reject(err)
}
const { token, refresh_token } = getTokenInfo()
// 如果是401错误
// 如果没有token或者刷新token
if (!token || !refresh_token) {
// 跳转到登录页,并携带上当前正在访问的页面,等登录成功后再跳回该页面
history.replace('/login', {
from: history.location.pathname || '/home',
})
return Promise.reject(err)
}
// 如果有token,且是401错误
try {
// 通过 Refresh Token 换取新 Token
// 特别说明:这个地方发请求的时候,不能使用新建的 http 实例去请求,要用默认实例 axios 去请求!
// 否则会因 http 实例的请求拦截器的作用,携带上老的 token 而不是 refresh_token
const res = await axios.put(`${err.config.baseURL}authorizations`, null, {
headers: {
Authorization: `Bearer ${refresh_token}`,
},
})
// 将新换到的 Token 信息保存到 Redux 和 LocalStorage 中
const tokenInfo = {
token: res.data.data.token,
refresh_token,
}
setTokenInfo(tokenInfo)
store.dispatch(saveToken(tokenInfo))
// 重新发送之前因 Token 无效而失败的请求
return instance(err.config)
} catch (error) {
// 如果换取token失败
store.dispatch(logout())
// 跳转到登录页,并携带上当前正在访问的页面,等登录成功后再跳回该页面
history.replace('/login', {
from: history.location,
})
Toast.info('登录信息失效')
return Promise.reject(error)
}
}
)
效果测试:
按下图修改 LocalStorage 中的 token,修改后刷新页面,成功执行的话,可以该token被替换成了新的 token
<img src="极客园移动端1.assets/image-20210903153923341.png" alt="image-20210903153923341" />
处理登录后的页面跳转
目标:当进行登录获取到 Token 后,应当将页面跳到合适的页面去
操作步骤
- 在登录页面表单对象的
onSubmit
方法中,在获取 Token 后添加页面跳转逻辑
import { useHistory, useLocation } from 'react-router-dom'
// 获取路由信息 location 对象
const location = useLocation()
// Formik 表单对象
const form = useFormik({
// ...
// 提交
onSubmit: async values => {
await dispatch(login(values))
// 登录后进行页面跳转
const { state } = location
if (!state) {
// 如果不是从其他页面跳到的登录页,则登录后默认进入首页
history.replace('/home/index')
} else {
// 否则跳回到之前访问的页面
history.replace(state.from)
}
}
})
404 错误页面
目标:实现当用户访问不存在的页面路径时,所要显示的错误提示页
<img src="极客园移动端1.assets/image-20210903172102078.png" alt="image-20210903172102078" />
实现思路:
- 使用一个数字类型的状态,记录当前倒计时的秒数
- 使用一个 ref 状态,引用延时器
- 在延时器中判断是否倒计时结束,未结束则秒数减一;结束则清理延时器并跳转页面
操作步骤
- 在
pages/NotFound/index.js
中,编写以下代码:
import React, { useEffect, useState } from 'react'
import { Link, useHistory } from 'react-router-dom'
export default function NotFound() {
const [time, setTime] = useState(3)
const history = useHistory()
useEffect(() => {
setTimeout(() => {
setTime(time - 1)
}, 1000)
if (time === 0) {
history.push('/home')
}
}, [time, history])
return (
<div>
<h1>对不起,你访问的内容不存在...</h1>
<p>
{time} 秒后,返回<Link to="/home">首页</Link>
</p>
</div>
)
}