react框架搭建

技术栈

目标:了解开发本项目所要使用的各类框架、库

  • 脚手架工具:create-react-app

  • 组件编写方式: 函数组件 + Hooks

  • 路由组件库:react-router-dom

  • 全局状态库:redux + redux-thunk

  • 网络请求库:axios

  • UI组件库:antd-mobile、以及一些用来实现特定功能的第三方组件(如:formikreact-content-loaderreact-window-infinite-loader 等)

项目准备

创建新项目

目标:使用脚手架命令创新项目

操作步骤

  1. 通过命令行创建项目
create-react-app geek-park
  1. 修改页面模板 public/index.html 中的页面标题
<title>极客园 App</title>
  1. 删除 src 目录中的所有文件
  2. 新增文件
/src
  /assets         项目资源文件,比如,图片 等
  /components     通用组件
  /pages          页面
  /utils          工具,比如,token、axios 的封装等
  App.js          根组件
  index.scss      全局样式
  index.js        项目入口

公用样式

目标:将本项目要用的公用样式文件放入合适的目录,并调用

【重要说明】
在本课程发放的资料中,有个 `资源 > src代码文件 > assets` 目录,里面存放着公用样式文件和图片资源,可直接复制到你的代码中使用。

操作步骤

  1. 将上面提到的assets目录,直接拷贝到新项目的 src 目录下
  1. 在主入口 index.js 中导入公用样式文件
import './assets/styles/index.scss'

配置 SASS 支持

目标:让项目样式支持使用 SASS/SCSS 语法编写

操作步骤

  1. 安装 sass
yarn add sass --save-dev

配置 UI 组件库

目标:安装本项目使用的 UI 组件库 Ant Design Mobile,并通过 Babel 插件实现按需加载

https://mobile.ant.design/index-cn

操作步骤

  1. 安装 antd-mobile
yarn add antd-mobile
  1. 导入样式
import 'antd-mobile/dist/antd-mobile.css'
  1. 使用组件
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 插件

操作步骤

  1. 安装 customize-crareact-app-rewired
yarn add customize-cra react-app-rewired babel-plugin-import -D
  1. 在项目根目录中创建 config-overrides.js,并编写如下代码:
const { override, fixBabelImports } = require('customize-cra')

// 导出要进行覆盖的 webpack 配置
module.exports = override(
  fixBabelImports('import', {
    libraryName: 'antd-mobile',
    style: 'css',
  })
)

  1. 修改启动命令

  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-scripts eject"
  },
  1. 删除index.js的样式导入
- import 'antd-mobile/dist/antd-mobile.css'
  1. 重启项目测试

配置快捷路径 @

目标:让代码中支持以 @/xxxx 形式的路径来导入文件

操作步骤

  1. 在项目根目录中创建 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)

  1. 在项目根目录中创建 jsconfig.json,并编写如下代码,为了路径有提示
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
      "@scss/*": ["src/assets/styles/*"]
    }
  }
}

配置视口单位插件

目标:通过 webpack 插件将 px 单位自动转换成视口长度单位 vw/vh,实现页面对不同屏幕的自动适配

实现思路:

使用 postcss-px-to-viewport 插件,可让我们直接在代码中按设计稿的 px 值来编写元素尺寸,它们最终会自动转换成 vw/vh 长度单位。

操作步骤

  1. 安装 postcss-px-to-viewport
yarn add postcss-px-to-viewport -D
  1. 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 根组件并在该组件中配置路由

操作步骤

  1. 安装 react-router-dom
yarn add react-router-dom
  1. 创建两个组件
pages/Home/index.js
pages/Login/index.js
  1. 创建 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

目标:安装 reduxredux-thunk 相关的依赖包,并创建 Redux Store 实例后关联到应用上

所要用到的依赖包:

  • redux
  • react-redux
  • redux-thunk
  • redux-devtools-extension

操作步骤

  1. 安装依赖包
yarn add redux react-redux redux-thunk redux-devtools-extension
  1. 创建 store 目录及它的子目录 actionsreducers,专门存放 redux 相关代码
  1. 创建 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
  1. 创建 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
  1. 在主入口 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')
)

页面

字体图标的基本使用

  1. 如果使用class类名的方式,彩色图标无法生效
  2. 可以通过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 小图标的组件,方便后续开发中为界面添加小图标


image-20210905204352596.png

实现思路:

  • 在组件中,输出一段使用 <use> 标签引用事先准备好的 SVG 图片资源的 <svg> 代码
  • 组件需要传入 SVG 图片的名字,用来显示不同的图标
  • 组件可以设置额外的样式类名、及点击事件监听

操作步骤

  1. 安装 classnames ,辅助组件的开发
yarn add classnames
  1. public/index.html 中引入 svg 图标资源:
<script src="//at.alicdn.com/t/font_2503709_f4q9dl3hktl.js"></script>
  1. 创建 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

  1. 测试组件,确认能否正确显示出图标
<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 像素边框

// 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" style="zoom:30%;" />

图例二:

<img src="极客园移动端1.assets/image-20210831163126729.png" alt="image-20210831163126729" style="zoom:30%;" />

图例三:

<img src="极客园移动端1.assets/image-20210831205954290.png" alt="image-20210831205954290" style="zoom:30%;" />

实现思路:

  • 组件布局分为:左、中、右三个区域
  • 可通过组件属性传入内容,填充中间和右边区域
  • 可为左边的“后退”按钮添加事件监听

操作步骤

  1. 创建 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

  1. 测试组件功能
<NavBar
  onLeftClick={() => alert(123)}
  rightContent={
    <span>右侧内容</span>
  }
  >
  标题内容
</NavBar>

效果:

<img src="极客园移动端1.assets/image-20210831212932784.png" alt="image-20210831212932784" style="zoom:50%;" />


表单基本结构

  • 结构
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" style="zoom:50%;" />

实现思路:

  • 左右布局:左侧<input> ,右侧是一个可自定义的内容区域
  • 将封装的组件传入的属性,全部传递到 <input> 标签上,使得能充分利用原标签的功能

操作步骤

  1. 创建 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" style="zoom:50%;" />

【特别说明】
本案例中,表单尽量不使用 antd-mobile 组件库里的表单组件来实现,因为它的表单组件并不好用,尤其是当要实现表单验证时比较麻烦。

因此,我们会使用原生的表单标签来实现。

操作步骤

  1. 将资源包中登录页面的样式文件拷贝到 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 库进行表单的数据绑定

操作步骤

  1. 安装 formik
yarn add formik
  1. 使用 formik 库中提供的 Hook 函数创建 formik 表单对象
import { useFormik } from 'formik'

// ...

// Formik 表单对象
const form = useFormik({
  // 设置表单字段的初始值
  initialValues: {
    mobile: '13900001111',
    code: '246810'
  },
  // 提交
  onSubmit: values => {
    console.log(values)
  }
})
  1. 绑定表单元素和 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" style="zoom:50%;" />

实现思路:

  • 使用 formik 自带的表单验证功能
  • 使用 yup 辅助编写数据的验证规则
  • 验证不通过时,在输入项下显示验证后得到的实际错误信息
  • 验证不通过时,禁用提交按钮

操作步骤

  1. 安装 yup
npm i yup --save
  1. 在创建 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个数字')
  }),

  // ...
})
  1. 处理验证错误信息
// 原先的两处错误信息代码
<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>
)}
  1. 验证出错时禁用登录按钮
import classnames from 'classnames'
<button
  type="submit"
  className={classnames('login-btn', form.isValid ? '' : 'disabled')}
  disabled={!form.isValid}
  >
  登录
</button>

初步封装网络请求模块

目标:将 axios 封装成公用的网络请求模块,方便后续调用后端接口

(本章节中暂不处理 token 和 token 续期)

操作步骤

  1. 安装 axios
npm i axios --save
  1. 创建 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" style="zoom:50%;" />

实现思路:

  • 实现一个 redux action 函数,请求发送验证码后端接口
  • 在验证码的 Input 组件的 onExtraClick 事件监听函数中调用 action

操作步骤

  1. 创建 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)
  }
}

  1. 为验证码输入框组件添加 onExtraClick 事件监听
<Input 
  {/* ... */} 
  
  onExtraClick={sendSMSCode} 
  />
  1. 实现事件监听函数,调用 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高级用法

image-20210907203744867.png
image-20210907203811908.png

登录并获取 Token

目标:点击登录按钮后,发送表单数据到后端登录接口,获取登录凭证 Token

实现思路:

  • 实现一个 Action,去调用后端登录接口
  • formik 的表单提交方法 onSubmit 中调用 Action

操作步骤

  1. 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)
  }
}
  1. 在登录页面组件中的 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" style="zoom:50%;" />


保存 Token 到 Redux

目标:将调用后端接口获取到的 Token 信息,放入 Redux 进行维护

实现思路:

  • 实现一个 Reducer,用于在 Redux 中操作 Token 状态
  • 实现一个 Action,在该 Action 中调用 Reducer 来保存 Token 状态
  • 在上一章节获取 Token 的 Action 中,调用上面的 Action 来保存从后端刚获取到的 Token

操作步骤

  1. 创建 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
  }
}
  1. store/reducers/index.js中,将以上的 Reducer 函数组合进根 Reducer
import { combineReducers } from 'redux'
import { login } from './login'

// 组合各个 reducer 函数,成为一个根 reducer
const rootReducer = combineReducers({
  login
})

// 导出根 reducer
export default rootReducer
  1. store/actions/login.js 中,实现一个调用以上 Reducer 的 Action
/**
 * 将 Token 信息保存到 Redux 中
 * @param {*} tokens 
 * @returns 
 */
export const saveToken = tokenInfo => {
  return {
    type: 'login/token',
    payload: tokenInfo
  }
}
  1. 在原先调用后端接口获取 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" style="zoom:50%;" />


提示消息优化

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

操作步骤

  1. 创建 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
}
  1. 原先调用后端接口获取 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" style="zoom:50%;" />


加载缓存的 Token 来初始化 Redux

目标:从缓存中读取 token 信息,如果存在则设置为 Redux Store 的初始状态

【如果不做本操作的话,会出现当页面刷新后,缓存中有值而 Redux 中无值的情况】

操作步骤

  1. 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" style="zoom:50%;" />

目录: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 信息,并设置到请求头上

操作步骤

  1. 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" style="zoom:40%;" />

实现思路:

  • 在组件中存在两个区域:页面内容区域、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" style="zoom:40%;" />


创建 tab 按钮页面并配置嵌套路由

目标:为 tab 布局组件中的 4 个按钮创建对应的页面;并配置路由,使按钮点击后能显示对应页面

操作步骤

  1. 创建四个页面组件:
- 首页:pages/Home/index.js
- 问答:pages/Question/index.js
- 视频:pages/Video/index.js
- 我的:pages/Profile/index.js

当前,这些组件的代码使用最简单的即可,如:

const Home = () => {
  return (
    <div>首页</div>
  )
}

export default Home
  1. 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" style="zoom:40%;" />

创建其他功能页面并配置路由

目标:事先创建本项目中将要开发的各个页面组件,并配置路由

【本章节所做的事,你也可以不一次性做完,可以一个一个页面边开发边配置】

说明:这些页面是除了 tab 底部导航栏上的4个页面以外的其他功能页

操作步骤

  1. 创建以下页面组件:
- 登录页面: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
  1. 在根组件 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" style="zoom:50%;" />

操作步骤

  1. 将资源包中个人中心页面的样式文件拷贝到 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 来调用后端接口

操作步骤

  1. 创建 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);
  }
}
  1. 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" style="zoom:40%;" />


将个人基本信息存入 Redux

目标:将从后端获取到的个人基本信息存入 Redux,以备用于后续的个人中心主页的界面渲染等

实现思路:

  • 实现一个 Reducer,用于操作 Store 中的个人基本信息状态
  • 通过一个 Action 来调用 Reducer,将个人基本信息保存到 Store 中

操作步骤

  1. 创建 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
  }
}

  1. store/index.js 中配置以上新增的 Reducer
import { profile } from './profile'
const rootReducer = combineReducers({
  login,
  profile
})
  1. store/actions/profile.js 中,添加一个可用于调用以上 Reducer 中的 profile/user 逻辑的 Action Creator:
/**
 * 设置个人基本信息
 * @param {*} user 
 * @returns 
 */
export const setUser = user => {
  return {
    type: 'profile/user',
    payload: user
  }
}
  1. 在之前调用后端接口的 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" style="zoom:50%;" />


将个人基本信息渲染到界面

目标:从 Redux Store 中获取之前存入的用户基本信息,并渲染到个人中心页面的对应位置

实现思路:

  • 使用 useSelector 从 Redux Store 中获取状态
  • 将获取的状态渲染到界面上

操作步骤

  1. 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)
  1. 使用以上获取到的数据,填充界面上的相关元素

用户头像和用户名:

<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" style="zoom:50%;" />

统计信息:

<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" style="zoom:50%;" />


个人详情页面的静态结构

目标:实现个人详情页的静态结构和样式

页面布局分解示意:

<img src="极客园移动端1.assets/image-20210902090634094.png" alt="image-20210902090634094" style="zoom:40%;" />

操作步骤

  1. 将资源包中个人详情页面的样式文件拷贝到 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 来调用后端接口

操作步骤

  1. store/actions/profile.js 中编写 Action Creator 函数:
/**
 * 获取用户详情
 * @returns thunk
 */
export const getUserProfile = () => {
  return async dispatch => {
    const res = await http.get('/user/profile')
    console.log(res)
  }
}
  1. 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" style="zoom:40%;" />


将个人详情存入 Redux

目标:将从后端获取到的个人详情存入 Redux,以备用于后续的个人详情页面的界面渲染

实现思路:

  • 实现一个 Reducer,用于操作 Store 中的个人详情状态
  • 通过一个 Action 来调用 Reducer,将个人详情保存到 Store 中

操作步骤

  1. 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 }
      }

    // ...
  }
}
  1. store/actions/profile.js 中,添加一个可用于调用以上 Reducer 中的 profile/profile 逻辑的 Action Creator:
/**
 * 设置个人详情
 * @param {*} profile 
 * @returns 
 */
export const setUserProfile = profile => ({
  type: 'profile/profile',
  payload: profile
})
  1. 在之前调用后端接口的 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 中获取状态
  • 将获取的状态渲染到界面上

操作步骤

  1. 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)
  1. 使用以上获取到的数据,填充界面上的相关元素

头像、昵称、简介:

<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" style="zoom:25%;" />

该界面的布局是固定的:顶部导航栏、要编辑的字段名称、一个内容输入框。

采用这种界面方式进行编辑的是:昵称、简介。

二、从屏幕底部滑入的:菜单列表抽屉

<img src="极客园移动端1.assets/image-20210902111141816.png" alt="image-20210902111141816" style="zoom:25%;" />

该界面的布局是固定的:一个列表、一个取消按钮。

采用这种界面方式进行编辑的是:头像、性别。

实现思路

  • 将这两种界面封装成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" style="zoom:40%;" />

实现思路:

  • 声明一个状态,用于记录输入字数
  • <textarea>输入内容触发change事件时,获取当前最新内容得到最新字数,更新到状态中

操作步骤

  1. 创建 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

操作步骤

  1. 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())
    }
  }
}
  1. 为抽屉表单组件设置 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" style="zoom:30%;" />

<img src="极客园移动端1.assets/image-20210902171524407.png" alt="image-20210902171524407" style="zoom:30%;" />

实现思路:

  • 界面主要由一个列表和一个取消按钮组成
  • 界面中的列表数据通过组件属性传入
  • “取消” 按钮的监听函数通过组件属性传入

操作步骤

  1. 创建 pages/Profile/Edit/components/EditList/ 目录,并将资源包中的样式文件拷贝进来
  1. 创建 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

操作步骤

  1. 为日期选择器组件设置 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" style="zoom:40%;" />

实现思路:

  • 点击 “退出登录” 后,需要弹信息框让用户确认
  • 确认退出,则清空 Redux 和 LocalStorage 中的 Token 信息
  • 清空 Token 后跳转页面到登录页

操作步骤

  1. 为“退出登录”按钮添加点击事件,并在监听函数中弹出确认框:
<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('执行登出....')
      }
    }
  ])
}
  1. store/reducers/login.js中,添加删除 Token 信息的 Reducer 逻辑:
switch (type) {
  case 'login/logout': return {}
  
  // ...
}
  1. store/action/login.js中,添加用于从 Redux 和 LocalStorage 中删除 Token 信息的 Action Creator:
/**
 * 退出
 * @returns
 */
export const logout = () => {
  return (dispatch) => {
    removeTokenInfo()
    dispatch({
      type: 'login/logout',
    })
  }
}

  1. 在“退出登录”的弹框回调 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应用场景:

  • 即时通讯,,,客服
  • 聊天室 广播
  • 点餐
image-20201121170006970.png

websocket使用-原生

image-20201121170200163.png

基本步骤

  1. 浏览器发出链接请求
  2. 服务器告知链接成功
  3. 双方进行双向通讯
  4. 关闭连接

核心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" style="zoom:40%;" />

操作步骤

  1. 将资源包中的样式文件拷贝到 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
  • 配置路由规则

聊天客服:动态渲染聊天记录列表

目标:将聊天数据存在数组状态中,再动态渲染到界面上

操作步骤

  1. 声明一个数组状态
import { useEffect, useRef, useState } from 'react'
// 聊天记录
const [messageList, setMessageList] = useState([
  // 放两条初始消息
  { type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
  { type: 'user', text: '你好' }
])
  1. 从 Redux 中获取当前用户基本信息
import { useSelector } from 'react-redux'
// 当前用户信息
const user = useSelector(state => state.profile.user)
  1. 根据数组数据,动态渲染聊天记录列表
{/* 聊天记录列表 */}
<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" style="zoom:50%;" />


聊天客服:建立与服务器的连接

目标:使用 socket.io 客户端与服务器建立 WebSocket 长连接

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

实现思路:

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

操作步骤

  1. 安装 socket.io 客户端库:socket.io-client
npm i socket.io-client --save
  1. 在进入机器人客服页面时,创建 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" style="zoom:40%;" />


聊天客服:给机器人发消息

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

实现思路:

  • 使用 socket.io 实例的 emit() 方法发送信息

操作步骤

  1. 声明一个状态,并绑定消息输入框
// 输入框中的内容
const [message, setMessage] = useState('')
<Input
  className="no-border"
  placeholder="请描述您的问题"
  value={message}
  onChange={e => setMessage(e.target.value)}
  />
  1. 为消息输入框添加键盘事件,在输入回车时发送消息
<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('')
  }
}

聊天客服:接收机器人回复的消息

目标:

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

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

实现思路:

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

操作步骤

  1. 在 socket.io 实例的 message 事件中,将接收到的消息添加到聊天列表:
// 监听收到消息的事件
client.on('message', data => {
  // 向聊天记录中添加机器人回复的消息
  setMessageList(messageList => [
    ...messageList,
    { type: 'robot', text: data.msg }
  ])
})
  1. 声明一个 ref 并设置到聊天列表的容器元素上
// 用于操作聊天列表元素的引用
const chatListRef = useRef(null)
<div className="chat-list" ref={chatListRef}>
  1. 通过 useEffect 监听聊天数据变化,对聊天容器元素的 scrollTop 进行设置:
// 监听聊天数据的变化,改变聊天容器元素的 scrollTop 值让页面滚到最底部
useEffect(() => {
  chatListRef.current.scrollTop = chatListRef.current.scrollHeight
}, [messageList])

权限控制

封装鉴权路由组件

目标:基于 Route 组件,封装一个判断存在 token 才能正常渲染指定 component 的路由组件

本项目中有些页面需要登录后才可访问,如:个人中心的所有页面

因此我们需要为 Route 组件添加额外的逻辑,使得在路由匹配后进行界面展示时,可以按条件决定如何渲染。

实现思路:

  • 使用 Router 组件的 render-props 机制

操作步骤

  1. 创建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
  1. App.jslayouts/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" style="zoom:50%;" />


处理登录后的页面跳转

目标:当进行登录获取到 Token 后,应当将页面跳到合适的页面去

操作步骤

  1. 在登录页面表单对象的 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" style="zoom:40%;" />

实现思路:

  • 使用一个数字类型的状态,记录当前倒计时的秒数
  • 使用一个 ref 状态,引用延时器
  • 在延时器中判断是否倒计时结束,未结束则秒数减一;结束则清理延时器并跳转页面

操作步骤

  1. 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>
  )
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容