《极客园移动端项目》

# 《极客园移动端项目》

# 项目概述

# 项目介绍

「极客园」对标CSDN博客园等竞品,致力成为更加贴近年轻 IT 从业者(学员)的科技资讯类应用
产品关键词:IT、极客、活力、科技、技术分享、前沿动态、内容社交
用户特点:年轻有活力,对IT领域前言科技信息充满探索欲和学习热情


# 功能演示

目标:了解极客园移动端项目的主要界面布局和本课程中需要实现的功能点

完整的可演示代码,在发放资料中的 资源 > ready > geek-park 目录下。

代码启动方式如下:

# 安装依赖包
npm install

# 启动
npm start
1
2
3
4
5
image-20210830114836045

# 项目技术栈

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

  • 脚手架工具: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
  1. 修改页面模板 public/index.html 中的页面标题
<title>极客园 App</title>
1
  1. 删除 src 目录中的所有文件
  2. 新增文件
/src
  /assets         项目资源文件,比如,图片 等
  /components     通用组件
  /pages          页面
  /utils          工具,比如,token、axios 的封装等
  App.js          根组件
  index.scss      全局样式
  index.js        项目入口
1
2
3
4
5
6
7
8

# 公用样式

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

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

操作步骤

  1. 将上面提到的assets目录,直接拷贝到新项目的 src 目录下

  2. 在主入口 index.js 中导入公用样式文件

import './assets/styles/index.scss'
1

# 配置 SASS 支持

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

操作步骤

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

# 配置 UI 组件库

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

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

操作步骤

  1. 安装 antd-mobile
yarn add antd-mobile
1
  1. 导入样式
//新版本不需要引入此文件 (直接引入组件即可,antd-mobile 会自动为你加载 css 样式文件)
import 'antd-mobile/dist/antd-mobile.css'
1
2
  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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# antd-按需加载

https://mobile.ant.design/docs/react/use-with-create-react-app-cn

craco

实现思路:

操作步骤

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

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

1
2
3
4
5
6
7
8
9
10
  1. 修改启动命令

  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-scripts eject"
  },
1
2
3
4
5
6
7
  1. 删除index.js的样式导入
- import 'antd-mobile/dist/antd-mobile.css'
1
  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 在项目根目录中创建 jsconfig.json,并编写如下代码,为了路径有提示
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
      "@scss/*": ["src/assets/styles/*"]
    }
  }
}
1
2
3
4
5
6
7
8
9

# 配置视口单位插件

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

实现思路:

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

操作步骤

  1. 安装 postcss-px-to-viewport
yarn add postcss-px-to-viewport -D
1
  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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 配置路由管理器

目标:安装 react-router-dom,创建 App 根组件并在该组件中配置路由

操作步骤

  1. 安装 react-router-dom
yarn add react-router-dom
1
  1. 创建两个组件
pages/Home/index.js
pages/Login/index.js
1
2
  1. 创建 App.js,编写根组件: 链接:https://blog.csdn.net/weixin_43778556/article/details/123895873 Switch===》Routes Redirect===>Navigate
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>
  )
}

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

# 配置 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
  1. 创建 store 目录及它的子目录 actionsreducers,专门存放 redux 相关代码

  2. 创建 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
2
3
4
5
6
7
8
9
10
11
12
  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  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
2
3
4
5
6
7
8
9
10
11
12
13

# 登录页面

image-20210906163210003

# 字体图标的基本使用

  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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 封装 svg 图标小组件

//at.alicdn.com/t/font_2503709_f4q9dl3hktl.js

目标:实现一个用于在页面上显示 svg 小图标的组件,方便后续开发中为界面添加小图标

image-20210905204352596

实现思路:

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

操作步骤

  1. 安装 classnames ,辅助组件的开发
yarn add classnames
1
  1. public/index.html 中引入 svg 图标资源:
<script src="//at.alicdn.com/t/font_2503709_f4q9dl3hktl.js"></script>
1
  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  1. 测试组件,确认能否正确显示出图标
<Icon 
  type="iconbtn_share" 
  className="test-icon" 
  onClick={() => { alert('clicked') }} 
  />
1
2
3
4
5

# 实现顶部导航栏组件

  • 基础结构
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>
  )
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  • 样式
.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;
    }
  }
}

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

# 移动端 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;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
  • 需要导入这个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);

1
2
3
4
5
6
7
8
9
10
11
12
13

# 实现顶部导航栏组件-封装

目标:封装顶部导航栏组件,可以用来显示页面标题、后退按钮、及添加额外的功能区域

图例一:

image-20210831163053705

图例二:

image-20210831163126729

图例三:

image-20210831205954290

实现思路:

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

操作步骤

  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
  1. 测试组件功能
<NavBar
  onLeftClick={() => alert(123)}
  rightContent={
    <span>右侧内容</span>
  }
  >
  标题内容
</NavBar>
1
2
3
4
5
6
7
8

效果:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
  • 样式
@import '@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);
      }
    }
  }
}

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

# 实现能显示额外内容的Input组件

目标:将原生的 input 标签进行封装,使得该组件可在 input 右侧放置额外内容元素

image-20210831213942878

实现思路:

  • 左右布局:左侧<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>
  )
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 登录页面的静态结构

目标:实现登录页的页面静态结构和样式

登录页面布局分解:

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

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

操作步骤

  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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 登录表单的数据绑定

目标:为登录表单中的输入组件进行数据绑定,收集表单数据

实现思路:

  • 使用 formik 库进行表单的数据绑定

操作步骤

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

// ...

// Formik 表单对象
const form = useFormik({
  // 设置表单字段的初始值
  initialValues: {
    mobile: '13900001111',
    code: '246810'
  },
  // 提交
  onSubmit: values => {
    console.log(values)
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. 绑定表单元素和 formik 表单对象
<form onSubmit={form.handleSubmit}>
1
<Input
  name="mobile"
  placeholder="请输入手机号"
  value={form.values.mobile}
  onChange={form.handleChange}
  />
1
2
3
4
5
6
<Input
  name="code"
  placeholder="请输入验证码"
  extra="发送验证码"
  maxLength={6}
  value={form.values.code}
  onChange={form.handleChange}
  />
1
2
3
4
5
6
7
8

# 登录表单的数据验证-基本

  • 给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
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 需要给每一个表单元素绑定一个事件 onBlur,,,目的是为了区分那些输入框是被点击过的
<Input
  placeholder="请输入手机号"
  value={mobile}
  name="mobile"
  autoComplete="off"
  onChange={handleChange}
  onBlur={handleBlur}
></Input>
1
2
3
4
5
6
7
8
  • 通过formik可以解构出来两个属性 touched 和 errors,,,,控制错误信息的展示
{touched.mobile && errors.mobile ? (
  <div className="validate">{errors.mobile}</div>
) : null}
1
2
3

# 登录表单的数据验证

目标:验证表单中输入的内容的合法性

image-20210901091137594

实现思路:

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

操作步骤

  1. 安装 yup
npm i yup --save
1
  1. 在创建 formik 表单对象时,添加表单验证相关参数
import * as Yup from 'yup'
1
// 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  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
2
3
4
5
6
7
8
9
10
11
12
  1. 验证出错时禁用登录按钮
import classnames from 'classnames'
1
<button
  type="submit"
  className={classnames('login-btn', form.isValid ? '' : 'disabled')}
  disabled={!form.isValid}
  >
  登录
</button>
1
2
3
4
5
6
7

# 初步封装网络请求模块

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

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

操作步骤

  1. 安装 axios
npm i axios --save
1
  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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 发送手机验证码

目标:点击登录界面中的发送验证码按钮,调用后端接口进行验证码的发送

image-20210901092838694

实现思路:

  • 实现一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
  1. 为验证码输入框组件添加 onExtraClick 事件监听
<Input 
  {/* ... */} 
  
  onExtraClick={sendSMSCode} 
  />
1
2
3
4
5
  1. 实现事件监听函数,调用 Action
import { useDispatch } from 'react-redux'
1
// 获取 Redux 分发器
const dispatch = useDispatch()

// 发送短信验证码
const sendSMSCode = () => {
  try {
    // 手机号
    const mobile = form.values.mobile

    // 获取 Action 
    const action = sendValidationCode(mobile)

    // 调用 Action
    dispatch(action)
  } catch (e) { }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 验证码倒计时功能

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('服务器繁忙,请稍后重试')
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 函数组件的特性

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'))

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

# useRef高级用法

image-20210907203744867

image-20210907203811908

# 登录并获取 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
2
3
4
5
6
7
8
9
10
11
12
  1. 在登录页面组件中的 formik 表单对象的 onSubmit 方法中,调用 Action
import { login, sendValidationCode } from '@/store/actions/login'
1
// Formik 表单对象
const form = useFormik({
  // ...

  // 提交
  onSubmit: async values => {
    await dispatch(login(values))
  }
})
1
2
3
4
5
6
7
8
9

如果能成功获取 Token 信息,控制台会打印出如下内容:

image-20210901101009155

# 保存 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
2
3
4
5
6
7
8
9
10
11
12
13
14
  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
2
3
4
5
6
7
8
9
10
  1. store/actions/login.js 中,实现一个调用以上 Reducer 的 Action
/**
 * 将 Token 信息保存到 Redux 中
 * @param {*} tokens 
 * @returns 
 */
export const saveToken = tokenInfo => {
  return {
    type: 'login/token',
    payload: tokenInfo
  }
}
1
2
3
4
5
6
7
8
9
10
11
  1. 在原先调用后端接口获取 Token 的 Action 中,调用 saveToken Action
// 提交
onSubmit: async (values) => {
  try {
    await dispatch(login(values))
    console.log('登陆成功')
  } catch (e) {
    console.log(e.response.data.message)
  }
},
1
2
3
4
5
6
7
8
9

可以通过 Redux DevTools 插件,查看保存后的值:

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)
  }
},
1
2
3
4
5
6
7
8
9
10

# 保存 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
  1. 原先调用后端接口获取 Token 的 Action 中,调用以上的本地缓存工具方法来保存 Token 信息
import { http, removeTokens, setTokens } from '@/utils'
1
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)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

效果:

image-20210901104851802

# 加载缓存的 Token 来初始化 Redux

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

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

操作步骤

  1. store/index.js 中,调用缓存工具方法来读取 Token 信息,并设置给 createStore 相关参数:
import { getTokenInfo } from '@/utils/storage'
1
const store = createStore(
  // ...
  
  // 参数二:初始化时要加载的状态
  {
    login: getTokenInfo()
  },
  
  // ...
)
1
2
3
4
5
6
7
8
9
10

# Redux 在实际开发中的常用模式

目标:根据上面几章的 redux 使用情况,总结实际开发时的最佳实践模式

推荐的目录结构

image-20210901150309264

目录:store/actions

按功能模块的不同,拆分若干独立的文件,存放 Action Creator 函数。

  • Action Creator 返回函数:用于含有异步行为的操作
export const test1 = params => {
  return async dispatch => {
    // 执行异步业务逻辑 ...
    // 通过 dispatch 可以再调用其他 Action ...
  }
}
1
2
3
4
5
6
  • Action Creator 返回对象:用于同步行为的操作
export const test2 = params => {
  // 推荐返回的 action 对象中,只存放两个属性:type、payload
  return {
    // 注意命名规范,推荐规则为 domain/eventName。例如:login/token
    type: 'abc/hello',
    // 所有要传递给 reducer 的业务数据,都放到 payload 属性上
    payload: {}
  }
}
1
2
3
4
5
6
7
8
9

目录:store/reducers

按功能模块的不同,拆分若干独立文件,存放 Reducer 函数。

最后,将这些独立的 Reducer 模块通过该目录中的 index.js 合并为根 Reducer。

// 根 Reducer
const rootReducer = combineReducers({
  login,
  profile,
  home,
  // ...
})
1
2
3
4
5
6
7

文件:store/index.js

用于创建和配置 Redux Store。

在组件中调用 Redux 的极简流程

// 第一步:使用 useDispatch() 获取分发器
const dispatch = useDispatch()

// 第二步:调用 Action Creator 获取 Action
const action = someActionCreatorFuncion()

// 第三步:通过向分发器调用 Action 函数内或 Reducer 函数内的业务逻辑
dispatch(action)
1
2
3
4
5
6
7
8

# 为网络请求添加 Token 请求头

目标:在发送请求时在请求头中携带上 Token 信息,以便在请求需要鉴权的后端接口时可以顺利调用

实现思路:

  • 在 axios 请求拦截器中,读取保存在 Redux 或 LocalStorage 中的 Token 信息,并设置到请求头上

操作步骤

  1. utils/http.js 中,改造请求拦截器:
import { getTokenInfo } from './storage'
1
// 2. 设置请求拦截器和响应拦截器
http.interceptors.request.use((config) => {
  // 获取缓存中的 Token 信息
  const token = getTokenInfo().token
  if (token) {
    // 设置请求头的 Authorization 字段
    config.headers['Authorization'] = `Bearer ${token}`
  }
  return config
})

1
2
3
4
5
6
7
8
9
10
11

# 整体布局

# 实现底部 tab 布局

目标:实现一个带有底部 tab 导航栏的页面布局容器组件,当点击底部按钮后,可切换显示不同内容

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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  • 将发放资料中的 资源 > 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' }
]
1
2
3
4
5
6
7
8
9
10
11
  • 动态渲染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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

效果:

image-20210831131915002

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

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

操作步骤

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

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

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

export default Home
1
2
3
4
5
6
7
  1. layouts/TabBarLayout.js 中配置4个页面的路由
import Home from '@/pages/Home'
import Profile from '@/pages/Profile'
import Question from '@/pages/Question'
import Video from '@/pages/Video'

// ...


{/* 区域一:点击按钮切换显示内容的区域 */}
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

效果:

image-20210831161825392

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

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

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

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

操作步骤

  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
1
2
3
4
5
6
7
8

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

const Login = () => {
  return (
    <div>登录</div>
  )
}

export default Login
1
2
3
4
5
6
7
  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>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 个人中心

# 个人中心主页的静态结构

目标:实现个人中心主页面的静态结构和样式

页面布局分解示意:

image-20210901112241300

操作步骤

  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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

# 请求个人基本信息

目标:进入个人中心页面时,调用后端接口,获取个人基本信息数据

实现思路:

  • 使用 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
2
3
4
5
6
7
8
9
10
11
12
  1. pages/Profile/index.js 中,使用 useEffect 在进入页面时调用 Action:
import { getUser } from '@/store/actions/profile'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
1
2
3
const dispatch = useDispatch()

// 在进入页面时执行
useEffect(() => {
  dispatch(getUser())
}, [dispatch])
1
2
3
4
5
6

成功调用后,可在控制台中查看打印的个人基本信息数据:

image-20210901175404937

# 将个人基本信息存入 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  1. store/index.js 中配置以上新增的 Reducer
import { profile } from './profile'
1
const rootReducer = combineReducers({
  login,
  profile
})
1
2
3
4
  1. store/actions/profile.js 中,添加一个可用于调用以上 Reducer 中的 profile/user 逻辑的 Action Creator:
/**
 * 设置个人基本信息
 * @param {*} user 
 * @returns 
 */
export const setUser = user => {
  return {
    type: 'profile/user',
    payload: user
  }
}
1
2
3
4
5
6
7
8
9
10
11
  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))
  }
}
1
2
3
4
5
6
7
8
9

在 Redux DevTools 中确认数据是否已正确设置:

image-20210901183426325

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

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

实现思路:

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

操作步骤

  1. pages/Profile/index.js 中,调用 react-redux 提供的 Hook 函数 useSelector,从 Store 中获取之前存储的 user 状态:
import { useDispatch, useSelector } from 'react-redux'
1
// 获取 Redux Store 中的个人基本信息
const user = useSelector(state => state.profile.user)
1
2
  1. 使用以上获取到的数据,填充界面上的相关元素

用户头像和用户名:

<div className="avatar">
  <img src={user.photo} alt="" />
</div>
<div className="user-name">{user.name}</div>
1
2
3
4
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
image-20210902083434060

# 个人详情页面的静态结构

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

页面布局分解示意:

image-20210902090634094

操作步骤

  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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

# 请求个人详情

目标:进入个人详情页面时,调用后端接口,获取个人详情数据

实现思路:

  • 使用 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
2
3
4
5
6
7
8
9
10
  1. pages/Profile/Edit/index.js 中,使用 useEffect 在进入页面时调用 Action:
import { getUserProfile } from '@/store/actions/profile'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
1
2
3
const dispatch = useDispatch()

useEffect(() => {
  dispatch(getUserProfile())
}, [dispatch])
1
2
3
4
5

成功调用后,可在控制台中查看打印数据:

image-20210902093122663

# 将个人详情存入 Redux

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

实现思路:

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

操作步骤

  1. store/reducers/profile.js中,添加个人详情状态,以及设置个人详情的 Reducer 逻辑:
// 初始状态
const initialState = {
  // ...
  // 详情信息
  userProfile: {}
}
1
2
3
4
5
6
// 操作用户个人信息状态的 reducer 函数
export const profile = (state = initialState, action) => {
  const { type, payload } = action

  switch (type) {
    // 设置详情信息
    case 'profile/profile':
      return {
        ...state,
        userProfile: { ...payload }
      }

    // ...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. store/actions/profile.js 中,添加一个可用于调用以上 Reducer 中的 profile/profile 逻辑的 Action Creator:
/**
 * 设置个人详情
 * @param {*} profile 
 * @returns 
 */
export const setUserProfile = profile => ({
  type: 'profile/profile',
  payload: profile
})
1
2
3
4
5
6
7
8
9
  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))
  }
}
1
2
3
4
5
6
7
8
9

# 将个人详情渲染到界面

目标:从 Redux Store 中获取之前保存的个人详情,并渲染到个人详情页的对应位置

实现思路:

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

操作步骤

  1. pages/Profile/Edit/index.js 中,调用 react-redux 提供的 Hook 函数 useSelector,从 Store 中获取之前存储的 userProfile 状态:
import { useDispatch, useSelector } from 'react-redux'
1
// 获取 Redux Store 中个人详情
const profile = useSelector(state => state.profile.userProfile)
1
2
  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>
1
2
3
4
5
6
7
8
9
10
11
12
13

性别、生日:

<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>
1
2
3
4
5
6
7
8
9
10
11
12

# 编辑个人详情:介绍

目标:了解编辑个人详情时,对于不同字段的编辑界面形式

当点击个人信息项时会以滑动抽屉的形式展现输入界面,主要有两种:

一、从屏幕右侧滑入的:全屏表单抽屉

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

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

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

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

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

实现思路

  • 将这两种界面封装成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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 编辑个人详情-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>
  )
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 父组件渲染
{/* 全屏表单抽屉 */}
<Drawer
  position="right"
  className="drawer"
  style={{ minHeight: document.documentElement.clientHeight }}
  sidebar={<EditInput onClose={onClose}></EditInput>}
  open={inputOpen}
  children={''}
/>
1
2
3
4
5
6
7
8
9

# 编辑个人详情-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={''}
/>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

# 编辑个人详情-封装包含字数统计的TextArea

目标:对 <textarea>进行封装,使得在输入内容时可以显示当前已输入字数和允许输入的总字数

image-20210902154346207

实现思路:

  • 声明一个状态,用于记录输入字数
  • <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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 编辑个人详情-昵称和简介的回显

  • 控制显示昵称和简介
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>
  )
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  • 数据回显
import 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

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

# 编辑个人详情:完成昵称和简介的修改

目标:在抽屉表单中编辑昵称或简介后,将表单返回的数据提交到后端进行更新,并更新到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  1. 为抽屉表单组件设置 onCommit 回调函数,并在该函数中调用以上的 Action:
<EditInput
  // ...
  onCommit={onFormCommit}
  />
1
2
3
4
import { getUserProfile, updateProfile } from '@/store/actions/profile'
1
// 抽屉表单的数据提交
const onFormCommit = (name, value) => {
  // 调用 Action 更新数据
  dispatch(updateProfile(name, value))
  // 关闭抽屉
  toggleDrawer(false)
}
1
2
3
4
5
6
7

# 编辑个人详情-准备性别和头像的抽屉组件

// 关闭昵称和简介的显示
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>

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

# 编辑个人详情-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

1
2
3
4
5
6
7
8
9
10
11
12
13

# 编辑个人详情-控制显示

  • 父组件提供数据
const config = {
  avatar: [
    {
      title: '拍照',
      onClick: () => {},
    },
    {
      title: '本地选择',
      onClick: () => {},
    },
  ],
  gender: [
    {
      title: '男',
      onClick: () => {},
    },
    {
      title: '女',
      onClick: () => {},
    },
  ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  • 传递给子组件
{/* 头像、性别 */}
<Drawer
  className="drawer-list"
  position="bottom"
  sidebar={<EditList config={config} type={listOpen.type}></EditList>}
  open={listOpen.visible}
  onOpenChange={onClose}
>
  {''}
</Drawer>
1
2
3
4
5
6
7
8
9
10
  • 子组件渲染
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

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

# 编辑个人详情:抽屉上的列表组件

目标:封装用于显示在列表抽屉中的列表组件,它可通过配置的方式显示不同列表项

image-20210902171500980 image-20210902171524407

实现思路:

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

操作步骤

  1. 创建 pages/Profile/Edit/components/EditList/ 目录,并将资源包中的样式文件拷贝进来

  2. 创建 pages/Profile/Edit/components/EditList/index.js,编写组件:

//【说明】:组件的 config 属性是一个对象,包含以下内容:
{
  "字段1": {
    name: '数组字段名',
    items: [
      {
        title: '选项一',
        value: '选项一的值'
      },
      {
        title: '选项二',
        value: '选项二的值'
      }
    ]
  },
  
  // 其他字段...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

组件代码:

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 编辑个人详情:完成性别的修改

目标:在从点击性别进入的抽屉列表中选择一项后,将选中的数据提交到后端进行更新,并更新到 Redux

实现思路:

  • 借助之前实现的更新个人详情的 Action 封装的魅力
const config = {
  gender: [
    {
      title: '男',
      onClick: () => {
        onCommit('gender', 0)
      },
    },
    {
      title: '女',
      onClick: () => {
        onCommit('gender', 1)
      },
    },
  ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 编辑个人详情:完成头像的修改

目标:在从点击头像进入的抽屉列表中选择一项后,从弹出的文件选择器中选取一张图片上传到后端,并将新头像地址更新到 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())
    }
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 创建 ref 对象,并关联到文件输入框元素
import { useEffect, useRef, useState } from 'react'
1
const fileRef = useRef()
1
<input type="file" hidden ref={fileRef} />
1
  • 修改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)
      },
    },
  ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  • 为文件输入框添加 onChange 监听函数,并在该函数中获取选中的文件后调用 Action 进行上传和更新
<input type="file" hidden ref={fileRef} onChange={onAvatarChange} />
1
import { getUserProfile, updateAvatar, updateProfile } from '@/store/actions/profile'
1
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()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 编辑个人详情:完成生日的修改

目标:在从点击生日进入的日期选择器中选择新日期后,将选中数据提交到后端进行更新,并更新到 Redux

实现思路:

  • 借助之前实现的更新个人详情的 Action

操作步骤

  1. 为日期选择器组件设置 onChange 回调函数,在该函数执行对应 Action
<DatePicker
  // ...
  onChange={onBirthdayChange}
  >
1
2
3
4
// 修改生日
const onBirthdayChange = value => {
  // 将从 DatePicker 组件获取到的 Date 对象,转成字符串的形式
  const year = value.getFullYear()
  const month = value.getMonth() + 1
  const day = value.getDate()
  const dateStr = `${year}-${month}-${day}`

  // 调用 Action 更新数据
  dispatch(updateProfile('birthday', dateStr))
}
1
2
3
4
5
6
7
8
9
10
11


# 聊天客服:小智同学页面的静态结构

目标:实现小智同学页面的静态结构和样式

页面布局结构分析:

image-20210903174406559

操作步骤

  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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

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

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

操作步骤

  1. 声明一个数组状态
import { useEffect, useRef, useState } from 'react'
1
// 聊天记录
const [messageList, setMessageList] = useState([
  // 放两条初始消息
  { type: 'robot', text: '亲爱的用户您好,小智同学为您服务。' },
  { type: 'user', text: '你好' }
])
1
2
3
4
5
6
  1. 从 Redux 中获取当前用户基本信息
import { useSelector } from 'react-redux'
1
// 当前用户信息
const user = useSelector(state => state.profile.user)
1
2
  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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

效果:

image-20210904085509862

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

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

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

实现思路:

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

操作步骤

  1. 安装 socket.io 客户端库:socket.io-client
npm i socket.io-client --save
1
  1. 在进入机器人客服页面时,创建 socket.io 客户端
import io from 'socket.io-client'
import { getTokenInfo } from '@/utils/storage'
1
2
// 用于缓存 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()
  }
}, [])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

正常情况,一进入客服页面,就能在控制台看到连接成功的信息:

image-20210903181934664

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

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

实现思路:

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

操作步骤

  1. 声明一个状态,并绑定消息输入框
// 输入框中的内容
const [message, setMessage] = useState('')
1
2
<Input
  className="no-border"
  placeholder="请描述您的问题"
  value={message}
  onChange={e => setMessage(e.target.value)}
  />
1
2
3
4
5
6
  1. 为消息输入框添加键盘事件,在输入回车时发送消息
<Input
	// ...
  onKeyUp={onSendMessage}
  />
1
2
3
4
// 按回车发送消息
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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

目标:

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

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

实现思路:

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

操作步骤

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

# 退出登录

目标:点击 “退出登录” 按钮后返回到登录页面

image-20210902104308053

实现思路:

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

操作步骤

  1. 为“退出登录”按钮添加点击事件,并在监听函数中弹出确认框:
<button className="btn" onClick={onLogout}>退出登录</button>
1
import { DatePicker, List, Modal } from 'antd-mobile'
1
// 退出登录
const onLogout = () => {
  // 弹出确认对话框
  Modal.alert('温馨提示', '你确定退出吗?', [
    // 取消按钮
    { text: '取消' },
    // 确认按钮
    {
      text: '确认',
      style: { color: '#FC6627' },
      onPress: () => {
        console.log('执行登出....')
      }
    }
  ])
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. store/reducers/login.js中,添加删除 Token 信息的 Reducer 逻辑:
switch (type) {
  case 'login/clear': return {}
  
  // ...
}
1
2
3
4
5
  1. store/action/login.js中,添加用于从 Redux 和 LocalStorage 中删除 Token 信息的 Action Creator:
/**
 * 将 Token 信息从 Redux 中删除
 */
export const clearToken = () => {
  return {
    type: 'login/clear'
  }
}

/**
 * 登出
 * @returns thunk
 */
export const logout = () => {
  return dispatch => {
    // 删除 LocalStorage 中的 Token 信息
    removeTokenInfo()
    
    // 删除 Redux 中的 Token 信息
    dispatch(clearToken())
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  1. 在“退出登录”的弹框回调 onPress 中调用以上 Action 删除 Token 后,跳转到登录页:
import { logout } from '@/store/actions/login'
1
onPress: () => {
  // 删除 Token 信息
  dispatch(logout())
  // 跳转到登录页
  history.replace('/login')
}
1
2
3
4
5
6

# 权限控制

# 处理登录后的页面跳转

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

操作步骤

  1. 在登录页面表单对象的 onSubmit 方法中,在获取 Token 后添加页面跳转逻辑
import { useHistory, useLocation } from 'react-router-dom'
1
// 获取路由信息 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)
    }
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# Token 的失效处理和无感刷新

目标:了解当请求后端接口时,如果发生了由于 Token 失效而产生的请求失败,应该如何进行处理

常用的处理流程:

image-20210903111508949

思想总结:

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

这一系列操作,可以在封装的 http 请求模块中完成。

操作步骤

  1. utils/http.js模块里,响应拦截器的错误回调函数中,处理 401 错误:
import { getTokenInfo, removeTokenInfo, setTokenInfo } from './storage'
import store from '@/store'
1
2
http.interceptors.response.use(response => {
  return response
}, async error => {
  // 获取错误信息中包含的请求配置信息和响应数据
  const { config, response } = error

  // 判断 HTTP 状态码是否为 401,即 token 不正确造成的授权问题
  if (response.status === 401) {
     
    // ... 【我们的主要处理逻辑在这里】 ...
  }

  // 其他错误
  return Promise.reject(error)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. 在上面 401 判断语句下:先获取当前 Token 信息,并判断 token 和 refresh_token 是否存在,如果不存在则跳到登录页
const { token, refresh_token } = getTokenInfo()

// 如果是没有 Token 或 Refresh Token
if (!token || !refresh_token) {
  // 跳转到登录页,并携带上当前正在访问的页面,等登录成功后再跳回该页面
  history.replace('/login', {
    from: history.location.pathname || '/home'
  })
  return Promise.reject(error)
}
1
2
3
4
5
6
7
8
9
10
  1. 接上面的逻辑:如果不是没有 Token 信息的情况,则就是 Token 失效了,所以我们接下来用 refresh token 去尝试换取新 token,获取到后存储,并重发请求
try {
  // 通过 Refresh Token 换取新 Token
  // 特别说明:这个地方发请求的时候,不能使用新建的 http 实例去请求,要用默认实例 axios 去请求!
  // 否则会因 http 实例的请求拦截器的作用,携带上老的 token 而不是 refresh_token
  const res = await axios.put(`${config.baseURL}/authorizations`, null, {
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Bearer ${refresh_token}`
    }
  })

  // 将新换到的 Token 信息保存到 Redux 和 LocalStorage 中
  const tokenInfo = {
    token: res.data.data.token,
    refresh_token,
  }
  setTokenInfo(tokenInfo)
  store.dispatch(saveToken(tokenInfo))

  // 重新发送之前因 Token 无效而失败的请求
  return http(config)
} catch (error) {
  
  // ... 这里后续编写 Token 换取失败的逻辑 ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  1. 如果换取新 token 失败,则清空本地已有的 Token 信息后,跳转到登录页
try {
  // ...
} catch (error) {
  // 清除 Redux 和 LocalStorage 中 Token 信息
  removeTokenInfo()
  store.dispatch(clearToken())

  // 跳转到登录页,并携带上当前正在访问的页面,等登录成功后再跳回该页面
  history.push('/login', {
    from: history.location.pathname || '/home'
  })

  return Promise.reject(error)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

效果测试:

按下图修改 LocalStorage 中的 token,修改后刷新页面,成功执行的话,可以该token被替换成了新的 token

image-20210903153923341

# 封装鉴权路由组件

目标:基于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  1. App.jslayouts/TabBarLayout.js 中,使用 AuthRoute 组件替代某些 Route
import AuthRoute from '@/components/AuthRoute'
1
<AuthRoute path="/profile/edit" component={ProfileEdit} />
<AuthRoute path="/profile/feedback" component={ProfileFeedback} />
<AuthRoute path="/profile/chat" component={Chat} />
1
2
3
<AuthRoute path="/home/profile" exact component={Profile} />
1

替代后,如果未经登录访问个人中心的页面,就会直接跳到登录页。


# 404 错误页面

目标:实现当用户访问不存在的页面路径时,所要显示的错误提示页

image-20210903172102078

实现思路:

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

操作步骤

  1. pages/NotFound/index.js中,编写以下代码:
import { useEffect, useRef, useState } from 'react'
import { Link, useHistory } from 'react-router-dom'

const NotFound = () => {
  const history = useHistory()
  
  // 倒计时秒数
  const [second, setSecond] = useState(3)
  
  // 延时器引用
  const timerRef = useRef(-1)
  
  // 在倒计时秒数变化时执行
  useEffect(() => {
    // 1. 创建延时器
    timerRef.current = setTimeout(() => {
      if (second <= 1) {
        // 倒计时结束:关闭定时器,并跳转到首页
        clearTimeout(timerRef.current)
        history.push('/home/index')
      } else {
        // 到计时未结束:秒数减一
        setSecond(second - 1)
      }
    }, 1000)

    // 2. 组件销毁时要清理延时器
    return () => {
      clearTimeout(timerRef.current)
    }
  }, [second, history])

  return (
    <div>
      <h1>对不起,你访问的内容不存在...</h1>
      <p>
        {second} 秒后,返回<Link to="/home/index">首页</Link>
      </p>
    </div>
  )
}

export default NotFound
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Last Updated: 2023/3/13 02:28:42