react框架搭建

技術(shù)棧

目標:了解開發(fā)本項目所要使用的各類框架塑娇、庫

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

  • 組件編寫方式: 函數(shù)組件 + Hooks

  • 路由組件庫:react-router-dom

  • 全局狀態(tài)庫:redux + redux-thunk

  • 網(wǎng)絡(luò)請求庫:axios

  • UI組件庫:antd-mobile、以及一些用來實現(xiàn)特定功能的第三方組件(如:formik坤邪、react-content-loader嫩絮、react-window-infinite-loader 等)

項目準備

創(chuàng)建新項目

目標:使用腳手架命令創(chuàng)新項目

操作步驟

  1. 通過命令行創(chuàng)建項目
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        項目入口

公用樣式

目標:將本項目要用的公用樣式文件放入合適的目錄置尔,并調(diào)用

【重要說明】
在本課程發(fā)放的資料中,有個 `資源 > 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 插件實現(xiàn)按需加載

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

實現(xiàn)思路:

  • 使用 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. 在項目根目錄中創(chuàng)建 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. 在項目根目錄中創(chuàng)建 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. 在項目根目錄中創(chuàng)建 jsconfig.json飞傀,并編寫如下代碼,為了路徑有提示
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
      "@scss/*": ["src/assets/styles/*"]
    }
  }
}

配置視口單位插件

目標:通過 webpack 插件將 px 單位自動轉(zhuǎn)換成視口長度單位 vw/vh诬烹,實現(xiàn)頁面對不同屏幕的自動適配

實現(xiàn)思路:

使用 postcss-px-to-viewport 插件砸烦,可讓我們直接在代碼中按設(shè)計稿的 px 值來編寫元素尺寸,它們最終會自動轉(zhuǎn)換成 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 樣式轉(zhuǎn)換插件
const postcssPlugins = addPostcssPlugins([
  // 移動端布局 viewport 適配方案
  px2viewport({
    // 視口寬度:可以設(shè)置為設(shè)計稿的寬度
    viewportWidth: 375,
    // 白名單:不需對其中的 px 單位轉(zhuǎn)成 vw 的樣式類類名
    // selectorBlackList: ['.ignore', '.hairlines']
  })
])

// 導出要進行覆蓋的 webpack 配置
module.exports = override(alias, postcssPlugins)

配置路由管理器

目標:安裝 react-router-dom幢痘,創(chuàng)建 App 根組件并在該組件中配置路由

操作步驟

  1. 安裝 react-router-dom
yarn add react-router-dom
  1. 創(chuàng)建兩個組件
pages/Home/index.js
pages/Login/index.js
  1. 創(chuàng)建 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 相關(guān)的依賴包家破,并創(chuàng)建 Redux Store 實例后關(guān)聯(lián)到應(yīng)用上

所要用到的依賴包:

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

操作步驟

  1. 安裝依賴包
yarn add redux react-redux redux-thunk redux-devtools-extension
  1. 創(chuàng)建 store 目錄及它的子目錄 actions颜说、reducers购岗,專門存放 redux 相關(guān)代碼
  1. 創(chuàng)建 store/reducers/index.js,用來作為組合所有 reducers 的主入口:
import { combineReducers } from 'redux'

// 組合各個 reducer 函數(shù)门粪,成為一個根 reducer
const rootReducer = combineReducers({
  // 一個測試用的 reducer喊积,避免運行時因沒有 reducer 而報錯
  test: (state = 0, action) => (state)

  // 在這里配置有所的 reducer ...
})

// 導出根 reducer
export default rootReducer
  1. 創(chuàng)建 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'

// 創(chuàng)建 Store 實例
const store = createStore(
  // 參數(shù)一:根 reducer
  rootReducer,

  // 參數(shù)二:初始化時要加載的狀態(tài)
  {},

  // 參數(shù)三:增強器
  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="http://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

目標:實現(xiàn)一個用于在頁面上顯示 svg 小圖標的組件,方便后續(xù)開發(fā)中為界面添加小圖標


image-20210905204352596.png

實現(xiàn)思路:

  • 在組件中措近,輸出一段使用 <use> 標簽引用事先準備好的 SVG 圖片資源的 <svg> 代碼
  • 組件需要傳入 SVG 圖片的名字溶弟,用來顯示不同的圖標
  • 組件可以設(shè)置額外的樣式類名、及點擊事件監(jiān)聽

操作步驟

  1. 安裝 classnames 瞭郑,輔助組件的開發(fā)
yarn add classnames
  1. public/index.html 中引入 svg 圖標資源:
<script src="http://at.alicdn.com/t/font_2503709_f4q9dl3hktl.js"></script>
  1. 創(chuàng)建 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') }} 
  />

實現(xiàn)頂部導航欄組件

  • 基礎(chǔ)結(jié)構(gòu)
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>

      {/* 右側(cè)內(nèi)容 */}
      <div className="right">右側(cè)內(nèi)容</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 用來檢測設(shè)備的最小像素密度
    @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);

實現(xiàn)頂部導航欄組件-封裝

目標:封裝頂部導航欄組件擒权,可以用來顯示頁面標題、后退按鈕阁谆、及添加額外的功能區(qū)域

圖例一:

<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%;" />

實現(xiàn)思路:

  • 組件布局分為:左碳抄、中、右三個區(qū)域
  • 可通過組件屬性傳入內(nèi)容场绿,填充中間和右邊區(qū)域
  • 可為左邊的“后退”按鈕添加事件監(jiān)聽

操作步驟

  1. 創(chuàng)建 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. 路由提供了幾個和路由相關(guān)的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>

      {/* 右側(cè)內(nèi)容 */}
      <div className="right">{extra}</div>
    </div>
  )
}

export default NavBar

  1. 測試組件功能
<NavBar
  onLeftClick={() => alert(123)}
  rightContent={
    <span>右側(cè)內(nèi)容</span>
  }
  >
  標題內(nèi)容
</NavBar>

效果:

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


表單基本結(jié)構(gòu)

  • 結(jié)構(gòu)
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);
      }
    }
  }
}

實現(xiàn)能顯示額外內(nèi)容的Input組件

目標:將原生的 input 標簽進行封裝璧尸,使得該組件可在 input 右側(cè)放置額外內(nèi)容元素

<img src="極客園移動端1.assets/image-20210831213942878.png" alt="image-20210831213942878" style="zoom:50%;" />

實現(xiàn)思路:

  • 左右布局:左側(cè)<input> ,右側(cè)是一個可自定義的內(nèi)容區(qū)域
  • 將封裝的組件傳入的屬性熬拒,全部傳遞到 <input> 標簽上爷光,使得能充分利用原標簽的功能

操作步驟

  1. 創(chuàng)建 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>
  )
}


登錄頁面的靜態(tài)結(jié)構(gòu)

目標:實現(xiàn)登錄頁的頁面靜態(tài)結(jié)構(gòu)和樣式

登錄頁面布局分解:

<img src="極客園移動端1.assets/image-20210831220941555.png" alt="image-20210831220941555" style="zoom:50%;" />

【特別說明】
本案例中蛀序,表單盡量不使用 antd-mobile 組件庫里的表單組件來實現(xiàn),因為它的表單組件并不好用捌议,尤其是當要實現(xiàn)表單驗證時比較麻煩哼拔。

因此,我們會使用原生的表單標簽來實現(xiàn)瓣颅。

操作步驟

  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>
      {/* 內(nèi)容 */}
      <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>
  )
}

登錄表單的數(shù)據(jù)綁定

目標:為登錄表單中的輸入組件進行數(shù)據(jù)綁定,收集表單數(shù)據(jù)

實現(xiàn)思路:

  • 使用 formik 庫進行表單的數(shù)據(jù)綁定

操作步驟

  1. 安裝 formik
yarn add formik
  1. 使用 formik 庫中提供的 Hook 函數(shù)創(chuàng)建 formik 表單對象
import { useFormik } from 'formik'

// ...

// Formik 表單對象
const form = useFormik({
  // 設(shè)置表單字段的初始值
  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="發(fā)送驗證碼"
  maxLength={6}
  value={form.values.code}
  onChange={form.handleChange}
  />

登錄表單的數(shù)據(jù)驗證-基本

  • 給useFormik提供validate函數(shù)進行校驗
const formik = useFormik({
  initialValues: {
    mobile: '',
    code: '',
  },
  // 當表單提交的時候,會觸發(fā)
  onSubmit(values) {
    console.log(values)
  },
  validate(values) {
    const errors = {}
    if (!values.mobile) {
      errors.mobile = '手機號不能為空'
    }
    if (!values.code) {
      errors.code = '驗證碼不能為空'
    }
    return errors
  },
})
  • 需要給每一個表單元素綁定一個事件 onBlur,,,目的是為了區(qū)分那些輸入框是被點擊過的
<Input
  placeholder="請輸入手機號"
  value={mobile}
  name="mobile"
  autoComplete="off"
  onChange={handleChange}
  onBlur={handleBlur}
></Input>
  • 通過formik可以解構(gòu)出來兩個屬性 touched 和 errors,,,,控制錯誤信息的展示
{touched.mobile && errors.mobile ? (
  <div className="validate">{errors.mobile}</div>
) : null}

登錄表單的數(shù)據(jù)驗證

目標:驗證表單中輸入的內(nèi)容的合法性

<img src="極客園移動端1.assets/image-20210901091137594.png" alt="image-20210901091137594" style="zoom:50%;" />

實現(xiàn)思路:

  • 使用 formik 自帶的表單驗證功能
  • 使用 yup 輔助編寫數(shù)據(jù)的驗證規(guī)則
  • 驗證不通過時檬姥,在輸入項下顯示驗證后得到的實際錯誤信息
  • 驗證不通過時曾我,禁用提交按鈕

操作步驟

  1. 安裝 yup
npm i yup --save
  1. 在創(chuàng)建 formik 表單對象時,添加表單驗證相關(guān)參數(shù)
import * as Yup from 'yup'
// Formik 表單對象
const form = useFormik({
  
  // 表單驗證
  validationSchema: Yup.object().shape({
    // 手機號驗證規(guī)則
    mobile: Yup.string()
        .required('請輸入手機號')
        .matches(/^1[3456789]\d{9}$/, '手機號格式錯誤'),
    
    // 手機驗證碼驗證規(guī)則
    code: Yup.string()
        .required('請輸入驗證碼')
        .matches(/^\d{6}$/, '驗證碼6個數(shù)字')
  }),

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

初步封裝網(wǎng)絡(luò)請求模塊

目標:將 axios 封裝成公用的網(wǎng)絡(luò)請求模塊健民,方便后續(xù)調(diào)用后端接口

(本章節(jié)中暫不處理 token 和 token 續(xù)期)

操作步驟

  1. 安裝 axios
npm i axios --save
  1. 創(chuàng)建 utils/request.js抒巢,并編寫如下代碼
import axios from 'axios'

// 1. 創(chuàng)建新的 axios 實例
const http = axios.create({
  baseURL: 'http://geek.itheima.net/v1_0'
})

// 2. 設(shè)置請求攔截器和響應(yīng)攔截器
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

發(fā)送手機驗證碼

目標:點擊登錄界面中的發(fā)送驗證碼按鈕,調(diào)用后端接口進行驗證碼的發(fā)送

<img src="極客園移動端1.assets/image-20210901092838694.png" alt="image-20210901092838694" style="zoom:50%;" />

實現(xiàn)思路:

  • 實現(xiàn)一個 redux action 函數(shù)秉犹,請求發(fā)送驗證碼后端接口
  • 在驗證碼的 Input 組件的 onExtraClick 事件監(jiān)聽函數(shù)中調(diào)用 action

操作步驟

  1. 創(chuàng)建 store/actions/login.js蛉谜,并實現(xiàn)一個 Action 函數(shù)
import http from '@/utils/http'

/**
 * 發(fā)送短信驗證碼
 * @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 事件監(jiān)聽
<Input 
  {/* ... */} 
  
  onExtraClick={sendSMSCode} 
  />
  1. 實現(xiàn)事件監(jiān)聽函數(shù),調(diào)用 Action
import { useDispatch } from 'react-redux'
// 獲取 Redux 分發(fā)器
const dispatch = useDispatch()

// 發(fā)送短信驗證碼
const sendSMSCode = () => {
  try {
    // 手機號
    const mobile = form.values.mobile

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

    // 調(diào)用 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(() => {
      // 當我們每次都想要獲取到最新的狀態(tài)崇堵,需要寫成 箭頭函數(shù)的形式
      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('服務(wù)器繁忙型诚,請稍后重試')
    }
  }
}

函數(shù)組件的特性

React 中的函數(shù)組件是通過函數(shù)來實現(xiàn)的,函數(shù)組件的公式:f(state) => UI鸳劳,即:數(shù)據(jù)到視圖的映射狰贯。

函數(shù)組件本身很簡單,但因為是通過函數(shù)實現(xiàn)的赏廓,所以涵紊,在使用函數(shù)組件時,就會體現(xiàn)出函數(shù)所具有的特性來幔摸。

函數(shù)組件的特性說明:

  • 對于函數(shù)組件來說摸柄,每次狀態(tài)更新后,組件都會重新渲染既忆。
  • 并且塘幅,每次組件更新都像是在給組件拍照。每張照片就代表組件在某個特定時刻的狀態(tài)尿贫。快照
  • 或者說:組件的每次特定渲染踏揣,都有自己的 props/state/事件處理程序 等庆亡。
  • 這些照片記錄的狀態(tài),從代碼層面來說捞稿,是通過 JS 中函數(shù)的閉包機制來實現(xiàn)的又谋。

這就是 React 中函數(shù)組件的特性,更加的函數(shù)式(利用函數(shù)的特性)

import { useState } from 'react'
import ReactDOM from 'react-dom'

// 沒有 hooks 的函數(shù)組件:
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>計數(shù)器:{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

目標:點擊登錄按鈕后娱局,發(fā)送表單數(shù)據(jù)到后端登錄接口彰亥,獲取登錄憑證 Token

實現(xiàn)思路:

  • 實現(xiàn)一個 Action,去調(diào)用后端登錄接口
  • formik 的表單提交方法 onSubmit 中調(diào)用 Action

操作步驟

  1. store/actions/login.js中衰齐,添加一個 Action 函數(shù)
/**
 * 登錄
 * @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 方法中任斋,調(diào)用 Action
import { login, sendValidationCode } from '@/store/actions/login'
// Formik 表單對象
const form = useFormik({
  // ...

  // 提交
  onSubmit: async values => {
    await dispatch(login(values))
  }
})

如果能成功獲取 Token 信息,控制臺會打印出如下內(nèi)容:

<img src="極客園移動端1.assets/image-20210901101009155.png" alt="image-20210901101009155" style="zoom:50%;" />


保存 Token 到 Redux

目標:將調(diào)用后端接口獲取到的 Token 信息耻涛,放入 Redux 進行維護

實現(xiàn)思路:

  • 實現(xiàn)一個 Reducer废酷,用于在 Redux 中操作 Token 狀態(tài)
  • 實現(xiàn)一個 Action瘟檩,在該 Action 中調(diào)用 Reducer 來保存 Token 狀態(tài)
  • 在上一章節(jié)獲取 Token 的 Action 中,調(diào)用上面的 Action 來保存從后端剛獲取到的 Token

操作步驟

  1. 創(chuàng)建 store/reducers/login.js澈蟆,并編寫一個 Reducer 函數(shù)
// 初始狀態(tài)
const initialState = {
  token: '',
  refresh_token: ''
}

// 操作 Token 狀態(tài)信息的 reducer 函數(shù)
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 函數(shù)組合進根 Reducer
import { combineReducers } from 'redux'
import { login } from './login'

// 組合各個 reducer 函數(shù),成為一個根 reducer
const rootReducer = combineReducers({
  login
})

// 導出根 reducer
export default rootReducer
  1. store/actions/login.js 中趴俘,實現(xiàn)一個調(diào)用以上 Reducer 的 Action
/**
 * 將 Token 信息保存到 Redux 中
 * @param {*} tokens 
 * @returns 
 */
export const saveToken = tokenInfo => {
  return {
    type: 'login/token',
    payload: tokenInfo
  }
}
  1. 在原先調(diào)用后端接口獲取 Token 的 Action 中睹簇,調(diào)用 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%;" />


提示消息優(yōu)化

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 中

實現(xiàn)思路:

  • 實現(xiàn)一個工具模塊寥闪,在該模塊中專門操作 LocalStorage 中的 Token 信息
  • 在調(diào)用后端接口獲取 Token 的 Action 中太惠,調(diào)用該工具模塊中的方法來存儲 Token

操作步驟

  1. 創(chuàng)建 utils/storage.js,并編寫 Token 的設(shè)置橙垢、獲取垛叨、刪除等工具方法
// 用戶 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. 原先調(diào)用后端接口獲取 Token 的 Action 中,調(diào)用以上的本地緩存工具方法來保存 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 信息柜某,如果存在則設(shè)置為 Redux Store 的初始狀態(tài)

【如果不做本操作的話嗽元,會出現(xiàn)當頁面刷新后,緩存中有值而 Redux 中無值的情況】

操作步驟

  1. store/index.js 中喂击,調(diào)用緩存工具方法來讀取 Token 信息剂癌,并設(shè)置給 createStore 相關(guān)參數(shù):
import { getTokenInfo } from '@/utils/storage'
const store = createStore(
  // ...
  
  // 參數(shù)二:初始化時要加載的狀態(tài)
  {
    login: getTokenInfo()
  },
  
  // ...
)

Redux 在實際開發(fā)中的常用模式

目標:根據(jù)上面幾章的 redux 使用情況,總結(jié)實際開發(fā)時的最佳實踐模式

推薦的目錄結(jié)構(gòu)

<img src="極客園移動端1.assets/image-20210901150309264.png" alt="image-20210901150309264" style="zoom:50%;" />

目錄:store/actions

按功能模塊的不同翰绊,拆分若干獨立的文件佩谷,存放 Action Creator 函數(shù)。

  • Action Creator 返回函數(shù):用于含有異步行為的操作
export const test1 = params => {
  return async dispatch => {
    // 執(zhí)行異步業(yè)務(wù)邏輯 ...
    // 通過 dispatch 可以再調(diào)用其他 Action ...
  }
}
  • Action Creator 返回對象:用于同步行為的操作
export const test2 = params => {
  // 推薦返回的 action 對象中监嗜,只存放兩個屬性:type谐檀、payload
  return {
    // 注意命名規(guī)范,推薦規(guī)則為 domain/eventName桐猬。例如:login/token
    type: 'abc/hello',
    // 所有要傳遞給 reducer 的業(yè)務(wù)數(shù)據(jù)刽肠,都放到 payload 屬性上
    payload: {}
  }
}

目錄:store/reducers

按功能模塊的不同溃肪,拆分若干獨立文件,存放 Reducer 函數(shù)音五。

最后惫撰,將這些獨立的 Reducer 模塊通過該目錄中的 index.js 合并為根 Reducer。

// 根 Reducer
const rootReducer = combineReducers({
  login,
  profile,
  home,
  // ...
})

文件:store/index.js

用于創(chuàng)建和配置 Redux Store躺涝。

在組件中調(diào)用 Redux 的極簡流程

// 第一步:使用 useDispatch() 獲取分發(fā)器
const dispatch = useDispatch()

// 第二步:調(diào)用 Action Creator 獲取 Action
const action = someActionCreatorFuncion()

// 第三步:通過向分發(fā)器調(diào)用 Action 函數(shù)內(nèi)或 Reducer 函數(shù)內(nèi)的業(yè)務(wù)邏輯
dispatch(action)

為網(wǎng)絡(luò)請求添加 Token 請求頭

目標:在發(fā)送請求時在請求頭中攜帶上 Token 信息厨钻,以便在請求需要鑒權(quán)的后端接口時可以順利調(diào)用

實現(xiàn)思路:

  • 在 axios 請求攔截器中,讀取保存在 Redux 或 LocalStorage 中的 Token 信息,并設(shè)置到請求頭上

操作步驟

  1. utils/reqeust.js 中莉撇,改造請求攔截器:
import { getTokenInfo } from './storage'
// 2. 設(shè)置請求攔截器和響應(yīng)攔截器
http.interceptors.request.use((config) => {
  // 獲取緩存中的 Token 信息
  const token = getTokenInfo().token
  if (token) {
    // 設(shè)置請求頭的 Authorization 字段
    config.headers['Authorization'] = `Bearer ${token}`
  }
  return config
})

整體布局

實現(xiàn)底部 tab 布局

目標:實現(xiàn)一個帶有底部 tab 導航欄的頁面布局容器組件呢蛤,當點擊底部按鈕后,可切換顯示不同內(nèi)容

<img src="極客園移動端1.assets/image-20210830181305183.png" alt="image-20210830181305183" style="zoom:40%;" />

實現(xiàn)思路:

  • 在組件中存在兩個區(qū)域:頁面內(nèi)容區(qū)域棍郎、tab 按鈕區(qū)域
  • 定義一個數(shù)組來存放 tab 按鈕相關(guān)數(shù)據(jù)其障,這樣可以方便統(tǒng)一管理按鈕
  • 通過遍歷數(shù)組來渲染 tab 按鈕
  • 點擊按鈕時,根據(jù)當前訪問的頁面路徑和按鈕本身的路徑涂佃,判斷當前按鈕是否是選中狀態(tài)励翼,并添加高亮樣式
  • 點擊按鈕后,進行路由跳轉(zhuǎn)

操作步驟

  • 準備基本結(jié)構(gòu)
export default function Home() {
  return (
    <div className={styles.root}>
      {/* 區(qū)域一:點擊按鈕切換顯示內(nèi)容的區(qū)域 */}
      <div className="tab-content"></div>
      {/* 區(qū)域二:按鈕區(qū)域辜荠,會使用固定定位顯示在頁面底部 */}
      <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>
  )
}
  • 將發(fā)放資料中的 資源 > src代碼文件 > layouts > index.module.scss 拷貝到該目錄下

  • 在組件定義一個數(shù)組汽抚,代表 tab 按鈕的數(shù)據(jù)

// 將 tab 按鈕的數(shù)據(jù)放在一個數(shù)組中
// - 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' }
]
  • 動態(tài)渲染TabBar
import Icon from '@/components/Icon'
import classnames from 'classnames'
import { useHistory, useLocation } from 'react-router-dom'
import styles from './index.module.scss'

// 將 tab 按鈕的數(shù)據(jù)放在一個數(shù)組中
// ...

/**
 * 定義 tab 布局組件
 */
const TabBarLayout = () => {
  // 獲取路由歷史 history 對象
  const history = useHistory()

  // 獲取路由信息 location 對象
  const location = useLocation()

  return (
    <div className={styles.root}>
      {/* 區(qū)域一:點擊按鈕切換顯示內(nèi)容的區(qū)域 */}
      <div className="tab-content">
      </div>

      {/* 區(qū)域二:按鈕區(qū)域,會使用固定定位顯示在頁面底部 */}
      <div className="tabbar">
        {buttons.map(btn => {
          // 判斷當前頁面路徑和按鈕路徑是否一致伯病,如果一致則表示該按鈕處于選中狀態(tài)
          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%;" />


創(chuàng)建 tab 按鈕頁面并配置嵌套路由

目標:為 tab 布局組件中的 4 個按鈕創(chuàng)建對應(yīng)的頁面造烁;并配置路由,使按鈕點擊后能顯示對應(yīng)頁面

操作步驟

  1. 創(chuàng)建四個頁面組件:
- 首頁: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'))
// ...


{/* 區(qū)域一:點擊按鈕切換顯示內(nèi)容的區(qū)域 */}
<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%;" />

創(chuàng)建其他功能頁面并配置路由

目標:事先創(chuàng)建本項目中將要開發(fā)的各個頁面組件苟翻,并配置路由

【本章節(jié)所做的事滋饲,你也可以不一次性做完拳昌,可以一個一個頁面邊開發(fā)邊配置】

說明:這些頁面是除了 tab 底部導航欄上的4個頁面以外的其他功能頁

操作步驟

  1. 創(chuàng)建以下頁面組件:
- 登錄頁面:pages/Login/index.js
- 搜索頁面:pages/Search/index.js
- 搜索結(jié)果頁面: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>
  )
}

個人中心

個人中心主頁的靜態(tài)結(jié)構(gòu)

目標:實現(xiàn)個人中心主頁面的靜態(tài)結(jié)構(gòu)和樣式

頁面布局分解示意:

<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">
        {/* 頂部個人信息區(qū)域 */}
        <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>

        {/* 今日閱讀區(qū)域 */}
        <div className="read-info">
          <Icon type="iconbtn_readingtime" />
          今日閱讀 <span>10</span> 分鐘
        </div>

        {/* 統(tǒng)計信息區(qū)域 */}
        <div className="count-list">
          <div className="count-item">
            <p>{0}</p>
            <p>動態(tài)</p>
          </div>
          <div className="count-item">
            <p>{0}</p>
            <p>關(guān)注</p>
          </div>
          <div className="count-item">
            <p>{0}</p>
            <p>粉絲</p>
          </div>
          <div className="count-item">
            <p>{0}</p>
            <p>被贊</p>
          </div>
        </div>

        {/* 主功能菜單區(qū)域 */}
        <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>

      {/* 更多服務(wù)菜單區(qū)域 */}
      <div className="more-service">
        <h3>更多服務(wù)</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

請求個人基本信息

目標:進入個人中心頁面時氏仗,調(diào)用后端接口围辙,獲取個人基本信息數(shù)據(jù)

實現(xiàn)思路:

  • 使用 Hook 函數(shù) useEffect 我碟,在頁面進入時,通過調(diào)用 Action 來調(diào)用后端接口

操作步驟

  1. 創(chuàng)建 store/actions/profile.js姚建,并編寫 Action Creator 函數(shù):
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 在進入頁面時調(diào)用 Action:
import { getUser } from '@/store/actions/profile'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
const dispatch = useDispatch()

// 在進入頁面時執(zhí)行
useEffect(() => {
  dispatch(getUser())
}, [dispatch])

成功調(diào)用后,可在控制臺中查看打印的個人基本信息數(shù)據(jù):

<img src="極客園移動端1.assets/image-20210901175404937.png" alt="image-20210901175404937" style="zoom:40%;" />


將個人基本信息存入 Redux

目標:將從后端獲取到的個人基本信息存入 Redux桥胞,以備用于后續(xù)的個人中心主頁的界面渲染等

實現(xiàn)思路:

  • 實現(xiàn)一個 Reducer,用于操作 Store 中的個人基本信息狀態(tài)
  • 通過一個 Action 來調(diào)用 Reducer考婴,將個人基本信息保存到 Store 中

操作步驟

  1. 創(chuàng)建 store/reducers/profile.js贩虾,編寫操作個人基本信息的 Reducer 函數(shù)
// 初始狀態(tài)
const initialState = {
  // 基本信息
  user: {},
}

// 操作用戶個人信息狀態(tài)的 reducer 函數(shù)
export const profile = (state = initialState, action) => {
  const { type, payload } = action

  switch (type) {
    // 設(shè)置基本信息
    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 中,添加一個可用于調(diào)用以上 Reducer 中的 profile/user 邏輯的 Action Creator:
/**
 * 設(shè)置個人基本信息
 * @param {*} user 
 * @returns 
 */
export const setUser = user => {
  return {
    type: 'profile/user',
    payload: user
  }
}
  1. 在之前調(diào)用后端接口的 Action Creator 函數(shù) getUser 中沥阱,調(diào)用setUser 將數(shù)據(jù)保存到 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 中確認數(shù)據(jù)是否已正確設(shè)置:

<img src="極客園移動端1.assets/image-20210901183426325.png" alt="image-20210901183426325" style="zoom:50%;" />


將個人基本信息渲染到界面

目標:從 Redux Store 中獲取之前存入的用戶基本信息缎罢,并渲染到個人中心頁面的對應(yīng)位置

實現(xiàn)思路:

  • 使用 useSelector 從 Redux Store 中獲取狀態(tài)
  • 將獲取的狀態(tài)渲染到界面上

操作步驟

  1. pages/Profile/index.js 中,調(diào)用 react-redux 提供的 Hook 函數(shù) useSelector,從 Store 中獲取之前存儲的 user 狀態(tài):
import { useDispatch, useSelector } from 'react-redux'
// 獲取 Redux Store 中的個人基本信息
const user = useSelector(state => state.profile.user)
  1. 使用以上獲取到的數(shù)據(jù)策精,填充界面上的相關(guān)元素

用戶頭像和用戶名:

<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%;" />

統(tǒng)計信息:

<div className="count-list">
  <div className="count-item">
    <p>{art_count}</p>
    <p>動態(tài)</p>
  </div>
  <div className="count-item">
    <p>{follow_count}</p>
    <p>關(guān)注</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%;" />


個人詳情頁面的靜態(tài)結(jié)構(gòu)

目標:實現(xiàn)個人詳情頁的靜態(tài)結(jié)構(gòu)和樣式

頁面布局分解示意:

<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

請求個人詳情

目標:進入個人詳情頁面時询刹,調(diào)用后端接口谜嫉,獲取個人詳情數(shù)據(jù)

實現(xiàn)思路:

  • 使用 Hook 函數(shù) useEffect ,在頁面進入時凹联,通過調(diào)用 Action 來調(diào)用后端接口

操作步驟

  1. store/actions/profile.js 中編寫 Action Creator 函數(shù):
/**
 * 獲取用戶詳情
 * @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 在進入頁面時調(diào)用 Action:
import { getUserProfile } from '@/store/actions/profile'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
const dispatch = useDispatch()

useEffect(() => {
  dispatch(getUserProfile())
}, [dispatch])

成功調(diào)用后,可在控制臺中查看打印數(shù)據(jù):

<img src="極客園移動端1.assets/image-20210902093122663.png" alt="image-20210902093122663" style="zoom:40%;" />


將個人詳情存入 Redux

目標:將從后端獲取到的個人詳情存入 Redux蔽挠,以備用于后續(xù)的個人詳情頁面的界面渲染

實現(xiàn)思路:

  • 實現(xiàn)一個 Reducer住闯,用于操作 Store 中的個人詳情狀態(tài)
  • 通過一個 Action 來調(diào)用 Reducer,將個人詳情保存到 Store 中

操作步驟

  1. store/reducers/profile.js中澳淑,添加個人詳情狀態(tài)比原,以及設(shè)置個人詳情的 Reducer 邏輯:
// 初始狀態(tài)
const initialState = {
  // ...
  // 詳情信息
  userProfile: {}
}
// 操作用戶個人信息狀態(tài)的 reducer 函數(shù)
export const profile = (state = initialState, action) => {
  const { type, payload } = action

  switch (type) {
    // 設(shè)置詳情信息
    case 'profile/profile':
      return {
        ...state,
        userProfile: { ...payload }
      }

    // ...
  }
}
  1. store/actions/profile.js 中,添加一個可用于調(diào)用以上 Reducer 中的 profile/profile 邏輯的 Action Creator:
/**
 * 設(shè)置個人詳情
 * @param {*} profile 
 * @returns 
 */
export const setUserProfile = profile => ({
  type: 'profile/profile',
  payload: profile
})
  1. 在之前調(diào)用后端接口的 Action Creator 函數(shù) getUserProfile 中偶惠,調(diào)用setUserProfile 將數(shù)據(jù)保存到 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 中獲取之前保存的個人詳情春寿,并渲染到個人詳情頁的對應(yīng)位置

實現(xiàn)思路:

  • 使用 useSelector 從 Redux Store 中獲取狀態(tài)
  • 將獲取的狀態(tài)渲染到界面上

操作步驟

  1. pages/Profile/Edit/index.js 中,調(diào)用 react-redux 提供的 Hook 函數(shù) useSelector忽孽,從 Store 中獲取之前存儲的 userProfile 狀態(tài):
import { useDispatch, useSelector } from 'react-redux'
// 獲取 Redux Store 中個人詳情
const profile = useSelector(state => state.profile.userProfile)
  1. 使用以上獲取到的數(shù)據(jù)绑改,填充界面上的相關(guān)元素

頭像、昵稱兄一、簡介:

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

編輯個人詳情:介紹

目標:了解編輯個人詳情時,對于不同字段的編輯界面形式

當點擊個人信息項時會以滑動抽屜的形式展現(xiàn)輸入界面出革,主要有兩種:

一造壮、從屏幕右側(cè)滑入的:全屏表單抽屜

<img src="極客園移動端1.assets/image-20210902110852864.png" alt="image-20210902110852864" style="zoom:25%;" />

該界面的布局是固定的:頂部導航欄、要編輯的字段名稱骂束、一個內(nèi)容輸入框耳璧。

采用這種界面方式進行編輯的是:昵稱、簡介展箱。

二旨枯、從屏幕底部滑入的:菜單列表抽屜

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

該界面的布局是固定的:一個列表、一個取消按鈕混驰。

采用這種界面方式進行編輯的是:頭像攀隔、性別皂贩。

實現(xiàn)思路

  • 將這兩種界面封裝成2個組件
  • 向組件傳入配置信息,讓組件按配置信息顯示對應(yīng)的內(nèi)容

編輯個人詳情-抽屜組件基本使用

// 控制抽屜組件的顯示
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組件

  • 導入樣式
  • 準備結(jié)構(gòu)
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組件修改

編輯個人詳情-同時控制昵稱和簡介

  • 修改數(shù)據(jù)格式
// 控制抽屜組件的顯示
const [inputOpen, setInputOpen] = useState({
  // 抽屜顯示狀態(tài)
  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={''}
/>

編輯個人詳情-封裝包含字數(shù)統(tǒng)計的TextArea

目標:對 <textarea>進行封裝昆汹,使得在輸入內(nèi)容時可以顯示當前已輸入字數(shù)和允許輸入的總字數(shù)

<img src="極客園移動端1.assets/image-20210902154346207.png" alt="image-20210902154346207" style="zoom:40%;" />

實現(xiàn)思路:

  • 聲明一個狀態(tài)明刷,用于記錄輸入字數(shù)
  • <textarea>輸入內(nèi)容觸發(fā)change事件時,獲取當前最新內(nèi)容得到最新字數(shù)满粗,更新到狀態(tài)中

操作步驟

  1. 創(chuàng)建 components/Textarea/index.js辈末,并將資源包中的樣式文件拷貝過來,然后編寫以下代碼:
import classnames from 'classnames'
import { useState } from 'react'
import styles from './index.module.scss'

/**
 * 帶字數(shù)統(tǒng)計的多行文本
 * @param {String} className 樣式類 
 * @param {String} value 文本框的內(nèi)容
 * @param {String} placeholder 占位文本
 * @param {Function} onChange 輸入內(nèi)容變動事件 
 * @param {String} maxLength 允許最大輸入的字數(shù)(默認100個字符) 
 */
const Textarea = ({ className, value, placeholder, onChange, maxLength = 100 }) => {
  // 字數(shù)狀態(tài)
  const [count, setCount] = useState(value.length || 0)

  // 輸入框的 change 事件監(jiān)聽函數(shù)
  const onValueChange = e => {
    // 獲取最新的輸入內(nèi)容败潦,并將它的長度更新到 count 狀態(tài)
    const newValue = e.target.value
    setCount(newValue.length)
    
    // 調(diào)用外部傳入的事件回調(diào)函數(shù)
    onChange(e)
  }

  return (
    <div className={classnames(styles.root, className)}>
      {/* 文本輸入框 */}
      <textarea
        className="textarea"
        maxLength={maxLength}
        placeholder={placeholder}
        value={value}
        onChange={onValueChange}
      />

      {/* 當前字數(shù)/最大允許字數(shù) */}
      <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>
  )
}

  • 數(shù)據(jù)回顯
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

編輯個人詳情:完成昵稱和簡介的修改

目標:在抽屜表單中編輯昵稱或簡介后本冲,將表單返回的數(shù)據(jù)提交到后端進行更新,并更新到 Redux

實現(xiàn)思路:

  • 編寫用于在 Redux 中更新個人詳情字段的 Reducer
  • 編寫用于通過調(diào)用后端接口即 Reducer 來更新個人詳情字段的 Action
  • 在抽屜表單提交數(shù)據(jù)時調(diào)用 Action

操作步驟

  1. store/actions/profile.js中劫扒,編寫 Action Creator:

/**
 * 修改個人詳情:昵稱檬洞、簡介、生日沟饥、性別 (每次修改一個字段)
 * @param {String} name 要修改的字段名稱
 * @param {*} value 要修改的字段值
 * @returns thunk
 */
export const updateProfile = (name, value) => {
  return async dispatch => {
    // 調(diào)用接口將數(shù)據(jù)更新到后端
    const res = await http.patch('/user/profile', { [name]: value })

    // 如果后端更新成功添怔,則再更新 Redux 中的數(shù)據(jù)
    if (res.data.message === 'OK') {
      dispatch(getUserProfile())
    }
  }
}
  1. 為抽屜表單組件設(shè)置 onCommit 回調(diào)函數(shù),并在該函數(shù)中調(diào)用以上的 Action:
<EditInput
  // ...
  onCommit={onFormCommit}
  />
import { getUserProfile, updateProfile } from '@/store/actions/profile'
// 抽屜表單的數(shù)據(jù)提交
const onFormCommit = (name, value) => {
  // 調(diào)用 Action 更新數(shù)據(jù)
  dispatch(updateProfile(name, value))
  // 關(guān)閉抽屜
  toggleDrawer(false)
}

編輯個人詳情-準備性別和頭像的抽屜組件

// 關(guān)閉昵稱和簡介的顯示
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組件

  • 準備樣式
  • 準備結(jié)構(gòu)
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

編輯個人詳情-控制顯示

  • 父組件提供數(shù)據(jù)
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%;" />

實現(xiàn)思路:

  • 界面主要由一個列表和一個取消按鈕組成
  • 界面中的列表數(shù)據(jù)通過組件屬性傳入
  • “取消” 按鈕的監(jiān)聽函數(shù)通過組件屬性傳入

操作步驟

  1. 創(chuàng)建 pages/Profile/Edit/components/EditList/ 目錄幼驶,并將資源包中的樣式文件拷貝進來
  1. 創(chuàng)建 pages/Profile/Edit/components/EditList/index.js艾杏,編寫組件:
//【說明】:組件的 config 屬性是一個對象,包含以下內(nèi)容:
{
  "字段1": {
    name: '數(shù)組字段名',
    items: [
      {
        title: '選項一',
        value: '選項一的值'
      },
      {
        title: '選項二',
        value: '選項二的值'
      }
    ]
  },
  
  // 其他字段...
}

組件代碼:

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

/**
 * 個人信息項修改列表
 * @param {Object} config 配置信息對象
 * @param {Function} onSelect 選擇列表項的回調(diào)函數(shù)
 * @param {Function} onClose 取消按鈕的回調(diào)函數(shù)
 */
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

編輯個人詳情:完成性別的修改

目標:在從點擊性別進入的抽屜列表中選擇一項后盅藻,將選中的數(shù)據(jù)提交到后端進行更新购桑,并更新到 Redux

實現(xiàn)思路:

  • 借助之前實現(xiàn)的更新個人詳情的 Action 封裝的魅力
const config = {
  gender: [
    {
      title: '男',
      onClick: () => {
        onCommit('gender', 0)
      },
    },
    {
      title: '女',
      onClick: () => {
        onCommit('gender', 1)
      },
    },
  ],
}

編輯個人詳情:完成頭像的修改

目標:在從點擊頭像進入的抽屜列表中選擇一項后,從彈出的文件選擇器中選取一張圖片上傳到后端氏淑,并將新頭像地址更新到 Redux

實現(xiàn)思路:

  • 實現(xiàn)一個用于調(diào)用接口進行頭像上傳勃蜘、及將上傳后的新圖片地址更新到 Redux 的 Action
  • 使用 Hook 函數(shù) useRef 操作文件輸入框元素 <input type="file"> ,觸發(fā)文件輸入彈框
  • 監(jiān)聽文件輸入框的 onChange 事件假残,在文件變化時調(diào)用 Action 進行上傳

操作步驟

  • store/actions/profile.js中缭贡,實現(xiàn)用于上傳頭像的 Action Creator:
/**
 * 更新頭像
 * @param {FormData} formData 上傳頭像信息的表單數(shù)據(jù)
 * @returns thunk
 */
export const updateAvatar = (formData) => {
  return async (dispatch) => {
    // 調(diào)用接口進行上傳
    const res = await http.patch('/user/photo', formData)

    // 如果后端更新成功,則再更新 Redux 中的數(shù)據(jù)
    if (res.data.message === 'OK') {
      dispatch(getUserProfile())
    }
  }
}

  • 創(chuàng)建 ref 對象辉懒,并關(guān)聯(lián)到文件輸入框元素
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 監(jiān)聽函數(shù)阳惹,并在該函數(shù)中獲取選中的文件后調(diào)用 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]

  // 生成表單數(shù)據(jù)
  const formData = new FormData()
  formData.append('photo', file)

  // 調(diào)用 Action 進行上傳和 Redux 數(shù)據(jù)更新
  dispatch(updateAvatar(formData))

  Toast.success('頭像上傳成功')
  onClose()
}

編輯個人詳情:完成生日的修改

目標:在從點擊生日進入的日期選擇器中選擇新日期后,將選中數(shù)據(jù)提交到后端進行更新眶俩,并更新到 Redux

實現(xiàn)思路:

  • 借助之前實現(xiàn)的更新個人詳情的 Action

操作步驟

  1. 為日期選擇器組件設(shè)置 onChange 回調(diào)函數(shù)穆端,在該函數(shù)執(zhí)行對應(yīng) 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}`

  // 調(diào)用 Action 更新數(shù)據(jù)
  await dispatch(updateProfile('birthday', dateStr))
  Toast.success('修改生日成功')
}

退出登錄

目標:點擊 “退出登錄” 按鈕后返回到登錄頁面

<img src="極客園移動端1.assets/image-20210902104308053.png" alt="image-20210902104308053" style="zoom:40%;" />

實現(xiàn)思路:

  • 點擊 “退出登錄” 后,需要彈信息框讓用戶確認
  • 確認退出仿便,則清空 Redux 和 LocalStorage 中的 Token 信息
  • 清空 Token 后跳轉(zhuǎn)頁面到登錄頁

操作步驟

  1. 為“退出登錄”按鈕添加點擊事件体啰,并在監(jiān)聽函數(shù)中彈出確認框:
<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('執(zhí)行登出....')
      }
    }
  ])
}
  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. 在“退出登錄”的彈框回調(diào) onPress 中調(diào)用以上 Action 刪除 Token 后,跳轉(zhuǎn)到登錄頁:
import { logout } from '@/store/actions/login'
onPress: () => {
  // 刪除 Token 信息
  dispatch(logout())
  // 跳轉(zhuǎn)到登錄頁
  history.replace('/login')
}

小智同學

websocket

WebSocket 是一種數(shù)據(jù)通信協(xié)議闻坚,類似于我們常見的 http 協(xié)議沽翔。

為什么需要 WebSocket?

初次接觸 WebSocket 的人窿凤,都會問同樣的問題:我們已經(jīng)有了 HTTP 協(xié)議仅偎,為什么還需要另一個協(xié)議?它能帶來什么好處雳殊?

答案很簡單橘沥,因為 HTTP 協(xié)議有一個缺陷:通信只能由客戶端發(fā)起。http基于請求響應(yīng)實現(xiàn)夯秃。

舉例來說座咆,我們想了解今天的天氣,只能是客戶端向服務(wù)器發(fā)出請求仓洼,服務(wù)器返回查詢結(jié)果介陶。HTTP 協(xié)議做不到服務(wù)器主動向客戶端推送信息。

這種單向請求的特點色建,注定了如果服務(wù)器有連續(xù)的狀態(tài)變化哺呜,客戶端要獲知就非常麻煩。我們只能使用"輪詢":每隔一段時候箕戳,就發(fā)出一個詢問某残,了解服務(wù)器有沒有新的信息。最典型的場景就是聊天室漂羊。

輪詢的效率低驾锰,非常浪費資源(因為必須不停連接,或者 HTTP 連接始終打開)走越。因此椭豫,工程師們一直在思考,有沒有更好的方法旨指。WebSocket 就是這樣發(fā)明的赏酥。

websocket簡介

WebSocket 協(xié)議在2008年誕生,2011年成為國際標準谆构。所有瀏覽器都已經(jīng)支持了裸扶。

它的最大特點就是,服務(wù)器可以主動向客戶端推送信息搬素,客戶端也可以主動向服務(wù)器發(fā)送信息呵晨,是真正的雙向平等對話魏保,屬于服務(wù)器推送技術(shù)的一種。

典型的websocket應(yīng)用場景:

  • 即時通訊摸屠,谓罗,,客服
  • 聊天室 廣播
  • 點餐
image-20201121170006970.png

websocket使用-原生

image-20201121170200163.png

基本步驟

  1. 瀏覽器發(fā)出鏈接請求
  2. 服務(wù)器告知鏈接成功
  3. 雙方進行雙向通訊
  4. 關(guān)閉連接

核心api

// 打開websocket連接
// WebSocket 是瀏覽器的內(nèi)置對象
var ws = new WebSocket('wss://echo.websocket.org') // 建立與服務(wù)端地址的連接

// 如果與服務(wù)器建立連接成功, 調(diào)用 websocket實例的 回調(diào)函數(shù) onopen
ws.onopen = function () {
    // 如果執(zhí)行此函數(shù) 表示與服務(wù)器建立關(guān)系成功
}

// 發(fā)送消息
ws.send('消息')

// 接收消息
ws.onmessage = function (event) {
    // event中的data就是服務(wù)器發(fā)過來的消息
}

ws.close()
// 關(guān)閉連接成功
ws.onclose = function () {
    // 關(guān)閉連接成功
}

示例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  發(fā)消息 接消息 -->
    <!-- 3  關(guān)閉連接 -->
    <input type="text" id="message" />
    <button onclick="openWS()">建立連接</button>
    <button onclick="sendMessage()">發(fā)送消息</button>
    <button onclick="closeWS()">關(guān)閉連接</button>

    <script>
      var dom = document.getElementById('contanier')
      var inputDom = document.getElementById('message')
      var isOpen = false // 表示是否已經(jīng)建立了撥號
      var ws // 別的方法 也需要使用ws
      // 打開websocket連接
      var openWS = function() {
        /// 網(wǎng)絡(luò)上提供的一個測試websocket功能的服務(wù)器地址季二。
        /// 它的效果是檩咱,你向服務(wù)器發(fā)什么消息 ,它就完全回復還給你胯舷。
        ws = new WebSocket('wss://echo.websocket.org') // 建立與服務(wù)器的聯(lián)系

        // onopen是webSocket約定事件名
        // 當本地客戶端瀏覽器與服務(wù)器建立連接之后刻蚯,就會執(zhí)行onopen的回調(diào)
        ws.onopen = function(event) {
          isOpen = true
          // 建立成功
          dom.innerHTML = dom.innerHTML + `<p>與服務(wù)器成功建立連接</p>`
        }
        //   接收消息
        // onmessage是webSocket約定事件名
        // 如果從服務(wù)器上發(fā)過來了消息,則會進入onmessage的回調(diào)
        ws.onmessage = function(event) {
          // 由于 我們先給服務(wù)器發(fā)了消息 服務(wù)器給我們回了消息
          dom.innerHTML =
            dom.innerHTML + `<p style='color: blue'>服務(wù)器說:${event.data}</p>`
        }
        // onclose是webSocket約定事件名
        ws.onclose = function() {
          // 此函數(shù)表示 關(guān)閉連接成功
          isOpen = false // 把狀態(tài)關(guān)閉掉
          dom.innerHTML = dom.innerHTML + `<p>與服務(wù)器連接關(guān)閉</p>`
        }
      }
      //   發(fā)送消息 接收消息
      var sendMessage = function() {
        if (inputDom.value && isOpen) {
          // 發(fā)消息 要等到 連接成功才能發(fā) 而且內(nèi)容不為空

          // 發(fā)消息就是send
          ws.send(inputDom.value) // 發(fā)送消息
          //   發(fā)完之后 添加到 當前視圖上
          dom.innerHTML =
            dom.innerHTML + `<p style='color: red'>我說:${inputDom.value}</p>`
          inputDom.value = ''
        }
      }
      // 關(guān)閉連接
      var closeWS = function() {
        ws.close() // 關(guān)閉連接
      }
    </script>
  </body>
</html>

聊天客服:小智同學頁面的靜態(tài)結(jié)構(gòu)

目標:實現(xiàn)小智同學頁面的靜態(tài)結(jié)構(gòu)和樣式

頁面布局結(jié)構(gòu)分析:

<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
  • 配置路由規(guī)則

聊天客服:動態(tài)渲染聊天記錄列表

目標:將聊天數(shù)據(jù)存在數(shù)組狀態(tài)中不翩,再動態(tài)渲染到界面上

操作步驟

  1. 聲明一個數(shù)組狀態(tài)
import { useEffect, useRef, useState } from 'react'
// 聊天記錄
const [messageList, setMessageList] = useState([
  // 放兩條初始消息
  { type: 'robot', text: '親愛的用戶您好兵扬,小智同學為您服務(wù)。' },
  { type: 'user', text: '你好' }
])
  1. 從 Redux 中獲取當前用戶基本信息
import { useSelector } from 'react-redux'
// 當前用戶信息
const user = useSelector(state => state.profile.user)
  1. 根據(jù)數(shù)組數(shù)據(jù)口蝠,動態(tài)渲染聊天記錄列表
{/* 聊天記錄列表 */}
<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%;" />


聊天客服:建立與服務(wù)器的連接

目標:使用 socket.io 客戶端與服務(wù)器建立 WebSocket 長連接

本項目聊天客服的后端接口器钟,使用的是基于 WebSocket 協(xié)議的 socket.io 接口。我們可以使用專門的 socket.io 客戶端庫妙蔗,就能輕松建立起連接并進行互相通信傲霸。

實現(xiàn)思路:

  • 借助 useEffect,在進入頁面時調(diào)用客戶端庫建立 socket.io 連接

操作步驟

  1. 安裝 socket.io 客戶端庫:socket.io-client
npm i socket.io-client --save
  1. 在進入機器人客服頁面時眉反,創(chuàng)建 socket.io 客戶端
import io from 'socket.io-client'
import { getTokenInfo } from '@/utils/storage'
// 用于緩存 socket.io 客戶端實例
const clientRef = useRef(null)

useEffect(() => {
  // 創(chuàng)建客戶端實例
  const client = io('http://toutiao.itheima.net', {
    transports: ['websocket'],
    // 在查詢字符串參數(shù)中傳遞 token
    query: {
      token: getTokenInfo().token
    }
  })

  // 監(jiān)聽連接成功的事件
  client.on('connect', () => {
    // 向聊天記錄中添加一條消息
    setMessageList(messageList => [
      ...messageList,
      { type: 'robot', text: '我現(xiàn)在恭候著您的提問昙啄。' }
    ])
  })

  // 監(jiān)聽收到消息的事件
  client.on('message', data => {
    console.log('>>>>收到 socket.io 消息:', data)
  })

  // 將客戶端實例緩存到 ref 引用中
  clientRef.current = client

  // 在組件銷毀時關(guān)閉 socket.io 的連接
  return () => {
    client.close()
  }
}, [])

正常情況,一進入客服頁面寸五,就能在控制臺看到連接成功的信息:

<img src="極客園移動端1.assets/image-20210903181934664.png" alt="image-20210903181934664" style="zoom:40%;" />


聊天客服:給機器人發(fā)消息

目標:將輸入框內(nèi)容通過 socket.io 發(fā)送到服務(wù)端

實現(xiàn)思路:

  • 使用 socket.io 實例的 emit() 方法發(fā)送信息

操作步驟

  1. 聲明一個狀態(tài)梳凛,并綁定消息輸入框
// 輸入框中的內(nèi)容
const [message, setMessage] = useState('')
<Input
  className="no-border"
  placeholder="請描述您的問題"
  value={message}
  onChange={e => setMessage(e.target.value)}
  />
  1. 為消息輸入框添加鍵盤事件,在輸入回車時發(fā)送消息
<Input
    // ...
  onKeyUp={onSendMessage}
  />
// 按回車發(fā)送消息
const onSendMessage = e => {
  if (e.keyCode === 13) {
    // 通過 socket.io 客戶端向服務(wù)端發(fā)送消息
    clientRef.current.emit('message', {
      msg: message,
      timestamp: Date.now()
    })

    // 向聊天記錄中添加當前發(fā)送的消息
    setMessageList(messageList => [
      ...messageList,
      { type: 'user', text: message }
    ])

    // 發(fā)送后清空輸入框
    setMessage('')
  }
}

聊天客服:接收機器人回復的消息

目標:

  1. 通過 socket.io 監(jiān)聽回復的消息梳杏,并添加到聊天列表中韧拒;

  2. 且當消息較多出現(xiàn)滾動條時,有后續(xù)新消息的話總將滾動條滾動到最底部十性。

實現(xiàn)思路:

  • 使用 socket.io 實例的 message 事件接收信息
  • 在聊天列表數(shù)據(jù)變化時叛溢,操作列表容器元素來設(shè)置滾動量

操作步驟

  1. 在 socket.io 實例的 message 事件中,將接收到的消息添加到聊天列表:
// 監(jiān)聽收到消息的事件
client.on('message', data => {
  // 向聊天記錄中添加機器人回復的消息
  setMessageList(messageList => [
    ...messageList,
    { type: 'robot', text: data.msg }
  ])
})
  1. 聲明一個 ref 并設(shè)置到聊天列表的容器元素上
// 用于操作聊天列表元素的引用
const chatListRef = useRef(null)
<div className="chat-list" ref={chatListRef}>
  1. 通過 useEffect 監(jiān)聽聊天數(shù)據(jù)變化劲适,對聊天容器元素的 scrollTop 進行設(shè)置:
// 監(jiān)聽聊天數(shù)據(jù)的變化楷掉,改變聊天容器元素的 scrollTop 值讓頁面滾到最底部
useEffect(() => {
  chatListRef.current.scrollTop = chatListRef.current.scrollHeight
}, [messageList])

權(quán)限控制

封裝鑒權(quán)路由組件

目標:基于 Route 組件,封裝一個判斷存在 token 才能正常渲染指定 component 的路由組件

本項目中有些頁面需要登錄后才可訪問霞势,如:個人中心的所有頁面

因此我們需要為 Route 組件添加額外的邏輯烹植,使得在路由匹配后進行界面展示時斑鸦,可以按條件決定如何渲染。

實現(xiàn)思路:

  • 使用 Router 組件的 render-props 機制

操作步驟

  1. 創(chuàng)建components/AuthRoute/index.js草雕,編寫組件代碼:
import { hasToken } from '@/utils/storage'
import { Redirect, Route } from 'react-router-dom'

/**
 * 鑒權(quán)路由組件
 * @param {*} component 本來 Route 組件上的 component 屬性
 * @param {Array} rest 其他屬性
 */
const AuthRoute = ({ component: Component, ...rest }) => {
  return (
    <Route {...rest} render={props => {
      // 如果有 token鄙才,則展示傳入的組件
      if (hasToken) {
        return <Component />
      }

      // 否則調(diào)用 Redirect 組件跳轉(zhuǎn)到登錄頁
      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} />

替代后促绵,如果未經(jīng)登錄訪問個人中心的頁面,就會直接跳到登錄頁嘴纺。

修改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 的失效處理和無感刷新

目標:了解當請求后端接口時败晴,如果發(fā)生了由于 Token 失效而產(chǎn)生的請求失敗,應(yīng)該如何進行處理

token: 訪問令牌栽渴,通過這個token就能夠訪問項目

  • 有效時間都不會很長尖坤,一般就是一個小時或者2個小時
  • token過期的處理
    • 重新登錄(適合PC端的管理系統(tǒng))
    • 對于移動端資訊類的項目用戶體驗不好。

refresh_token: 刷新令牌闲擦,沒有訪問的功能慢味,通過刷新令牌能夠獲取到一個新的訪問令牌。

  • 刷新令牌:有效時間會比較長

常用的處理流程:

思想總結(jié):

  • 無 Token墅冷,直接跳到登錄頁
  • 有 Token纯路,則用 Refresh Token 換新 Token:換成功則用新 Token 重發(fā)原先的請求,沒換成功則跳到登錄頁

這一系列操作寞忿,可以在封裝的 http 請求模塊中完成驰唬。

操作步驟

// 配置響應(yīng)攔截器
instance.interceptors.response.use(
  (response) => {
    // 對響應(yīng)做點什么...
    return response.data
  },
  async (err) => {
    // 如果是網(wǎng)絡(luò)錯誤
    if (!err.response) {
      Toast.info('網(wǎng)絡(luò)繁忙,請稍后重試')
      return Promise.reject(err)
    }
    // 如果有響應(yīng),但是不是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) {
      // 跳轉(zhuǎn)到登錄頁腔彰,并攜帶上當前正在訪問的頁面叫编,等登錄成功后再跳回該頁面
      history.replace('/login', {
        from: history.location.pathname || '/home',
      })
      return Promise.reject(err)
    }

    // 如果有token,且是401錯誤
    try {
      // 通過 Refresh Token 換取新 Token
      // 特別說明:這個地方發(fā)請求的時候霹抛,不能使用新建的 http 實例去請求搓逾,要用默認實例 axios 去請求!
      // 否則會因 http 實例的請求攔截器的作用杯拐,攜帶上老的 token 而不是 refresh_token
      const res = await axios.put(`${err.config.baseURL}authorizations`, null, {
        headers: {
          Authorization: `Bearer ${refresh_token}`,
        },
      })

      // 將新?lián)Q到的 Token 信息保存到 Redux 和 LocalStorage 中
      const tokenInfo = {
        token: res.data.data.token,
        refresh_token,
      }
      setTokenInfo(tokenInfo)
      store.dispatch(saveToken(tokenInfo))

      // 重新發(fā)送之前因 Token 無效而失敗的請求
      return instance(err.config)
    } catch (error) {
      // 如果換取token失敗
      store.dispatch(logout())
      // 跳轉(zhuǎn)到登錄頁霞篡,并攜帶上當前正在訪問的頁面,等登錄成功后再跳回該頁面
      history.replace('/login', {
        from: history.location,
      })
      Toast.info('登錄信息失效')
      return Promise.reject(error)
    }
  }
)

效果測試:

按下圖修改 LocalStorage 中的 token藕施,修改后刷新頁面寇损,成功執(zhí)行的話,可以該token被替換成了新的 token

<img src="極客園移動端1.assets/image-20210903153923341.png" alt="image-20210903153923341" style="zoom:50%;" />


處理登錄后的頁面跳轉(zhuǎn)

目標:當進行登錄獲取到 Token 后裳食,應(yīng)當將頁面跳到合適的頁面去

操作步驟

  1. 在登錄頁面表單對象的 onSubmit 方法中矛市,在獲取 Token 后添加頁面跳轉(zhuǎn)邏輯
import { useHistory, useLocation } from 'react-router-dom'
// 獲取路由信息 location 對象
const location = useLocation()

// Formik 表單對象
const form = useFormik({
  // ...

  // 提交
  onSubmit: async values => {
    await dispatch(login(values))

    // 登錄后進行頁面跳轉(zhuǎn)
    const { state } = location
    if (!state) {
      // 如果不是從其他頁面跳到的登錄頁,則登錄后默認進入首頁
      history.replace('/home/index')
    } else {
      // 否則跳回到之前訪問的頁面
      history.replace(state.from)
    }
  }
})

404 錯誤頁面

目標:實現(xiàn)當用戶訪問不存在的頁面路徑時诲祸,所要顯示的錯誤提示頁

<img src="極客園移動端1.assets/image-20210903172102078.png" alt="image-20210903172102078" style="zoom:40%;" />

實現(xiàn)思路:

  • 使用一個數(shù)字類型的狀態(tài)浊吏,記錄當前倒計時的秒數(shù)
  • 使用一個 ref 狀態(tài)而昨,引用延時器
  • 在延時器中判斷是否倒計時結(jié)束,未結(jié)束則秒數(shù)減一找田;結(jié)束則清理延時器并跳轉(zhuǎn)頁面

操作步驟

  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>對不起,你訪問的內(nèi)容不存在...</h1>
      <p>
        {time} 秒后墩衙,返回<Link to="/home">首頁</Link>
      </p>
    </div>
  )
}

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末务嫡,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子漆改,更是在濱河造成了極大的恐慌心铃,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,657評論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件挫剑,死亡現(xiàn)場離奇詭異去扣,居然都是意外死亡,警方通過查閱死者的電腦和手機樊破,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,889評論 3 394
  • 文/潘曉璐 我一進店門愉棱,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人哲戚,你說我怎么就攤上這事奔滑。” “怎么了惫恼?”我有些...
    開封第一講書人閱讀 164,057評論 0 354
  • 文/不壞的土叔 我叫張陵档押,是天一觀的道長。 經(jīng)常有香客問我祈纯,道長令宿,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,509評論 1 293
  • 正文 為了忘掉前任腕窥,我火速辦了婚禮粒没,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘簇爆。我一直安慰自己癞松,他們只是感情好,可當我...
    茶點故事閱讀 67,562評論 6 392
  • 文/花漫 我一把揭開白布入蛆。 她就那樣靜靜地躺著响蓉,像睡著了一般。 火紅的嫁衣襯著肌膚如雪哨毁。 梳的紋絲不亂的頭發(fā)上枫甲,一...
    開封第一講書人閱讀 51,443評論 1 302
  • 那天,我揣著相機與錄音,去河邊找鬼想幻。 笑死粱栖,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的脏毯。 我是一名探鬼主播闹究,決...
    沈念sama閱讀 40,251評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼食店!你這毒婦竟也來了渣淤?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,129評論 0 276
  • 序言:老撾萬榮一對情侶失蹤吉嫩,失蹤者是張志新(化名)和其女友劉穎砂代,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體率挣,經(jīng)...
    沈念sama閱讀 45,561評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,779評論 3 335
  • 正文 我和宋清朗相戀三年露戒,在試婚紗的時候發(fā)現(xiàn)自己被綠了椒功。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,902評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡智什,死狀恐怖动漾,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情荠锭,我是刑警寧澤旱眯,帶...
    沈念sama閱讀 35,621評論 5 345
  • 正文 年R本政府宣布程奠,位于F島的核電站痪伦,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏允扇。R本人自食惡果不足惜愧怜,卻給世界環(huán)境...
    茶點故事閱讀 41,220評論 3 328
  • 文/蒙蒙 一呀页、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧拥坛,春花似錦蓬蝶、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,838評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至著摔,卻和暖如春缓窜,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,971評論 1 269
  • 我被黑心中介騙來泰國打工雹洗, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留香罐,地道東北人。 一個月前我還...
    沈念sama閱讀 48,025評論 2 370
  • 正文 我出身青樓时肿,卻偏偏與公主長得像庇茫,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子螃成,可洞房花燭夜當晚...
    茶點故事閱讀 44,843評論 2 354

推薦閱讀更多精彩內(nèi)容