1.项目介绍 #

1.1 用例图 #

uescase

1.2 流程图 #

1.2.1 注册登录 #

注册登录

1.2.2 购买课程 #

gou_mai

2. 搭建开发环境 #

2.1 本节目录 #

.
├── package.json
├── public
│   └── index.html
├── src
│   └── index.js
├── static
│   └── setRemUnit.js
└── webpack.config.js

2.2 初始化项目 #

mkdir zhufengketangclient
cd zhufengketangclient
npm init -y

2.3 安装依赖 #

npm install react react-dom antd-mobile @ant-design/icons react-router-dom redux redux-logger redux-promise redux-thunk react-redux redux-first-history axios redux-persist redux-immer immer --save
npm install webpack webpack-cli webpack-dev-server babel-loader @babel/preset-env @babel/preset-react style-loader css-loader less-loader less  copy-webpack-plugin  html-webpack-plugin px2rem-loader --save-dev

Dependencies (主要依赖) #

Package Description
react React 核心库,用于构建用户界面。
react-dom React 库,用于与 DOM 交互。
antd-mobile 一个基于 React 的移动端组件库。
@ant-design/icons Ant Design 的图标库。
react-router-dom React 路由库,用于在 SPA 中处理路由。
redux JavaScript 状态管理库。
redux-logger Redux 的中间件,用于在控制台输出日志。
redux-promise Redux 中间件,用于处理异步 actions。
redux-thunk Redux 中间件,允许在 action creators 中写返回函数。
react-redux React 的 Redux 绑定,连接 React 和 Redux。
redux-first-history 使用 history 库与 Redux 一起工作的工具。
axios 用于发起 HTTP 请求的库。
redux-persist 用于持久化 Redux 存储的工具。
redux-immer 用于在 Redux reducers 中使用 immer。
immer 用于处理不可变状态的 JavaScript 库。

Dev Dependencies (开发依赖) #

Package Description
webpack 打包 JavaScript 代码的模块化打包工具。
webpack-cli 用于从命令行运行 Webpack。
webpack-dev-server 一个快速的开发服务器,自动刷新。
babel-loader Webpack 的 loader,用于转换 ES6+ 和 JSX 代码。
@babel/preset-env Babel 预设,转换 ES6+ 代码。
@babel/preset-react Babel 预设,转换 JSX 代码。
style-loader 将 CSS 添加到 DOM 的 Webpack loader。
css-loader 解析 CSS 的 Webpack loader。
less-loader 转换 Less 为 CSS 的 Webpack loader。
less Less 是一个 CSS 预处理器。
copy-webpack-plugin Webpack 插件,用于将单个文件或整个目录复制到构建目录。
html-webpack-plugin Webpack 插件,用于简化创建服务于 webpack 的 HTML 文件。
px2rem-loader Webpack loader,用于将 px 单位转换为 rem 单位。

2.4 编写 webpack 配置文件 #

webpack.config.js

// 导入 HtmlWebpackPlugin,用于生成HTML文件
const HtmlWebpackPlugin = require("html-webpack-plugin");
// 导入 Node.js 路径模块
const path = require("path");
// 导入 CopyWebpackPlugin,用于复制文件或目录
const CopyWebpackPlugin = require("copy-webpack-plugin");
// 导出 webpack 配置
module.exports = {
    // 设置构建模式,根据 NODE_ENV 环境变量决定
    mode: process.env.NODE_ENV == "production" ? "production" : "development",
    // 设置入口文件
    entry: {
        main: "./src/index.js"
    },
    // 设置输出配置
    output: {
        // 输出目录
        path: path.join(__dirname, "dist"),
        // 输出文件名
        filename: "[name].js",
        // 设置公共路径
        publicPath: "/"
    },
    // 使用 source-map 方便调试
    devtool: "source-map",
    // 开发服务器配置
    devServer: {
        // 启用模块热替换
        hot: true,
        // 静态文件目录
        static: path.join(__dirname, "static"),
        // 使用 HTML5 History API 时,任意的 404 响应都可能需要被替代为 index.html
        historyApiFallback: true
    },
    // 解析配置
    resolve: {
        // 设置别名
        alias: {
            "@": path.resolve(__dirname, "src")
        }
    },
    // 配置 loader 规则
    module: {
        rules: [
            {
                // 匹配 JS 文件
                test: /\.js$/,
                loader: "babel-loader",
                options: {
                    presets: [
                        '@babel/preset-env',
                        '@babel/preset-react'
                    ]
                },
                // 只转换 src 目录下的 js 文件
                include: path.resolve('src'),
                // 排除 node_modules 目录下的文件
                exclude: /node_modules/
            },
            {
                // 匹配 CSS 文件
                test: /\.css$/,
                use: [
                    "style-loader",
                    {
                        loader: "css-loader"
                    }
                ]
            },
            {
                // 匹配 LESS 文件
                test: /\.less$/,
                use: [
                    "style-loader",
                    {
                        loader: "css-loader"
                    },
                    "less-loader",
                ],
            },
            {
                // 匹配图片文件
                test: /\.(jpg|png|gif|svg|jpeg)$/,
                type: 'asset'
            },
        ],
    },
    // 配置插件
    plugins: [
        new HtmlWebpackPlugin({
            template: "./public/index.html",
        }),
        new CopyWebpackPlugin({
            patterns: [
                { from: path.resolve(__dirname, 'static'), to: path.resolve(__dirname, 'dist') }
            ]
        })
    ],
};

2.5 src\index.js #

src\index.js

// 导入 React 库
import React from 'react';
// 从 react-dom/client 导入 ReactDOM
import ReactDOM from 'react-dom/client';
// 创建一个 root 容器并关联到 HTML 中的 'root' 元素
const root = ReactDOM.createRoot(document.getElementById('root'));
// 在 root 容器中渲染一个 div 元素
root.render(
    <div>hello</div>
);

2.6 public\index.html #

public\index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>珠峰课堂</title>
  </head>
  <body>
    <div id="root"></div>
   <script src="/setRemUnit.js"></script>
  </body>
</html>

2.7 setRemUnit.js #

static\setRemUnit.js

// 获取文档的根元素
let docEle = document.documentElement;
// 定义一个设置REM单位的函数
function setRemUnit() {
  // 根据当前视口宽度设置根元素的字体大小,使其总是视口宽度的十分之一
  docEle.style.fontSize = docEle.clientWidth / 10 + "px";
}
// 初始设置REM单位
setRemUnit();
// 当窗口大小改变时,重新设置REM单位
window.addEventListener("resize", setRemUnit);

2.8 package.json #

{
  "name": "zhufengketangclient",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "dev": "webpack serve",
    "build": "webpack"
  },
  "license": "MIT",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/preset-env": "^7.22.9",
    "@babel/preset-react": "^7.22.5",
    "babel-loader": "^9.1.3",
    "copy-webpack-plugin": "^11.0.0",
    "css-loader": "^6.8.1",
    "html-webpack-plugin": "^5.5.3",
    "less": "^4.1.3",
    "less-loader": "^11.1.3",
    "style-loader": "^3.3.3",
    "webpack": "^5.88.1",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^4.15.1"
  }
}

3.实现底部路由 #

3.1 参考 #

3.1.1 介绍 #

3.1.2 目录 #

.
├── package.json
├── public
│   └── index.html
├── src
│   ├── components
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── index.js
│   ├── store
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   └── index.js
│       └── Profile
│           └── index.js
├── static
│   └── setRemUnit.js
└── webpack.config.js

3.1.3 效果预览 #

day1

3.1.4 页面布局 #

tabs

3.2 src\index.js #

src\index.js

// 引入React库
import React from 'react';
// 引入ReactDOM库中的client
import ReactDOM from 'react-dom/client';
// 引入react-router-dom库中的Routes和Route
import { Routes, Route } from "react-router-dom";
// 引入react-redux库中的Provider组件
import { Provider } from "react-redux";
// 引入store和history
import { store, history } from "./store";
// 引入全局样式
import "./styles/global.less";
// 引入Tabs组件
import Tabs from "./components/Tabs";
// 引入Home、Cart和Profile视图
import Home from "./views/Home";
import Cart from "./views/Cart";
import Profile from "./views/Profile";
// 引入redux-first-history库中的HistoryRouter组件
import { HistoryRouter } from "redux-first-history/rr6";
// 创建React根节点
const root = ReactDOM.createRoot(document.getElementById('root'));
// 渲染应用的主要结构
root.render(
    // 使用Provider组件将Redux store提供给应用中的其他组件
    <Provider store={store}>
        // 使用HistoryRouter组件处理路由历史
        <HistoryRouter history={history}>
            // 主要内容区域
            <main className="main-container">
                // 定义应用的路由
                <Routes>
                    // 主页路由
                    <Route path="/" element={<Home />} />
                    // 购物车路由
                    <Route path="/Cart" element={<Cart />} />
                    // 个人资料路由
                    <Route path="/profile" element={<Profile />} />
                </Routes>
            </main>
            // 引入底部导航Tabs
            <Tabs />
        </HistoryRouter>
    </Provider>
);

3.3 global.less #

src\styles\global.less

/* 为所有元素重置内边距和外边距 */
*{
    padding: 0;
    margin: 0;
}
/* 去除ul和li的列表样式 */
ul,li{
    list-style: none;
}
/* 设置root元素的最大宽度,并居中显示 */
#root{
    margin:0 auto;
    max-width: 750px;
    box-sizing: border-box;
}
/* 为主容器设置上下的内边距 */
.main-container{
    padding:100px 0 120px 0;
}

3.4 Tabs\index.js #

src\components\Tabs\index.js

// 引入React库
import React from "react";
// 引入react-router-dom库中的NavLink组件
import { NavLink } from "react-router-dom";
// 引入Ant Design的图标组件
import { HomeOutlined, ShoppingCartOutlined, UserOutlined } from "@ant-design/icons";
// 引入对应的样式文件
import "./index.less";
// 定义Tabs组件
function Tabs() {
    return (
        // 底部导航栏容器
        <footer>
            // 首页导航链接
            <NavLink to="/" >
                <HomeOutlined />
                <span>首页</span>
            </NavLink>
            // 购物车导航链接
            <NavLink to="/cart">
                <ShoppingCartOutlined />
                <span>购物车</span>
            </NavLink>
            // 个人中心导航链接
            <NavLink to="/profile">
                <UserOutlined />
                <span>个人中心</span>
            </NavLink>
        </footer>
    );
}
// 导出Tabs组件
export default Tabs;

3.5 Tabs\index.less #

src\components\Tabs\index.less

/* 设置footer的基本样式,固定在屏幕底部,并设置背景、边框等样式 */
footer {
    position: fixed;
    left: 0;
    bottom: 0;
    width: 100%;
    height: 120px;
    z-index: 1000;
    background-color: #fff;
    border-top: 1px solid #d5d5d5;
    /* 设置footer为flex布局,居中显示其子元素 */
    display: flex;
    justify-content: center;
    align-items: center;
    /* 设置链接的样式 */
    a {
        /* 设置链接为flex布局,使其子元素垂直居中 */
        display: flex;
        flex: 1;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        color: #000;
        /* 设置链接文本的样式 */
        span {
            font-size: 30px;
            line-height: 50px;

            /* 当span为anticon时,设置其字体大小 */
            &.anticon {
                font-size: 50px;
            }
        }
        /* 当链接处于激活状态时,设置其颜色和字体粗细 */
        &.active {
            color: orangered;
            font-weight: bold;
        }
    }
}

3.6 history.js #

src\store\history.js

// 从`history`库中引入`createBrowserHistory`方法
import { createBrowserHistory } from 'history';
// 从`redux-first-history`库中引入`createReduxHistoryContext`方法
import { createReduxHistoryContext } from "redux-first-history";
// 创建一个浏览器历史对象
const history = createBrowserHistory();
// 使用创建的历史对象,初始化Redux与历史的上下文,并获取相关工具和中间件
const { routerReducer, routerMiddleware, createReduxHistory } = createReduxHistoryContext({ history });
// 导出相关工具和中间件
export {
    routerReducer,
    routerMiddleware,
    createReduxHistory
}

3.7 action-types.js #

src\store\action-types.js


3.8 reducers\home.js #

src\store\reducers\home.js

let initialState = {};
export default function (state = initialState, action) {
    switch (action.type) {
        default:
            return state;
    }
}

3.9 reducers\cart.js #

src\store\reducers\cart.js

let initialState = {};
export default function (state = initialState, action) {
    switch (action.type) {
        default:
            return state;
    }
}

3.10 reducers\profile.js #

src\store\reducers\profile.js

let initialState = {};
export default function (state = initialState, action) {
    switch (action.type) {
        default:
            return state;
    }
}

3.11 reducers\index.js #

src\store\reducers\index.js

// 从'redux'库中引入`combineReducers`方法,用于合并多个reducer
import { combineReducers } from 'redux';
// 从'../history'中引入`routerReducer`,用于处理与路由相关的action
import { routerReducer } from '../history';
// 引入home、cart、profile三个模块的reducer
import home from './home';
import cart from './cart';
import profile from './profile';
// 使用`combineReducers`方法合并所有reducer,并指定各个模块的reducer
const rootReducer = combineReducers({
    router: routerReducer,
    home,
    cart,
    profile
});
// 导出合并后的总reducer
export default rootReducer;

3.12 store\index.js #

src\store\index.js

// 从'redux'库中引入`legacy_createStore`(并重命名为`createStore`)和`applyMiddleware`方法
import { legacy_createStore as createStore, applyMiddleware } from 'redux';
// 引入总的reducers
import reducers from './reducers';
// 引入`redux-logger`中间件,用于在控制台输出redux的日志
import logger from 'redux-logger';
// 引入`redux-thunk`中间件,用于处理异步action
import thunk from 'redux-thunk';
// 引入`redux-promise`中间件,用于处理返回promise的action
import promise from 'redux-promise';
// 从'./history'中引入`routerMiddleware`和`createReduxHistory`,与路由相关
import { routerMiddleware, createReduxHistory } from './history';
// 使用applyMiddleware方法创建一个包含多个中间件的store
export const store = applyMiddleware(thunk, routerMiddleware, promise, logger)(createStore)(reducers);
// 使用`createReduxHistory`方法创建一个与redux关联的history对象
export const history = createReduxHistory(store);

3.13 Home\index.js #

src\views\Home\index.js

import React from "react";
function Home() {
    return <div>Home</div>;
}
export default Home;

3.14 Cart\index.js #

src\views\Cart\index.js

import React from "react";
function Cart() {
    return <div>Cart</div>;
}
export default Cart;

3.15 Profile\index.js #

src\views\Profile\index.js

import React from "react";
function Profile() {
    return <div>Profile</div>;
}
export default Profile;

4. 实现首页头部导航 #

4.1 参考 #

4.1.1 文档 #

4.1.2 logo图 #

logo

4.1.3 本章代码 #

.
├── package.json
├── public
│   └── index.html
├── src
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   └── home.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   └── HomeHeader
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       └── Profile
│           └── index.js
├── static
│   └── setRemUnit.js

4.1.4 效果预览 #

homenavigation

4.1.5 本章布局 #

home-header.png

4.2 HomeHeader\index.js #

src\views\Home\components\HomeHeader\index.js

// 从'react'库中引入React和useState
import React, { useState } from 'react';
// 从'@ant-design/icons'库中引入BarsOutlined图标
import { BarsOutlined } from '@ant-design/icons';
// 引入classnames库,用于处理class字符串
import classnames from 'classnames';
// 从'react-transition-group'库中引入Transition组件,用于处理动画
import { Transition } from 'react-transition-group';
// 引入logo图片
import logo from '@/assets/images/logo.png';
// 引入样式文件
import './index.less';
// 设置动画时长
const duration = 1000;
// 设置默认样式
const defaultStyle = {
    transition: `opacity ${duration}ms ease-in-out`,
    opacity: 0,
}
// 设置动画各个阶段的样式
const transitionStyles = {
    entering: { opacity: 1 },
    entered: { opacity: 1 },
    exiting: { opacity: 0 },
    exited: { opacity: 0 }
};
// 定义HomeHeader组件
function HomeHeader(props) {
    // 定义状态:菜单是否可见
    let [isMenuVisible, setIsMenuVisible] = useState(false);
    // 设置当前分类的方法
    const setCurrentCategory = (event) => {
        let { target } = event;
        let category = target.dataset.category;
        props.setCurrentCategory(category);
        setIsMenuVisible(false);
    }
    // 返回JSX
    return (
        <header className="home-header">
            <div className="logo-header">
                <img src={logo} />
                <BarsOutlined onClick={() => setIsMenuVisible(!isMenuVisible)} />
            </div>
            <Transition in={isMenuVisible} timeout={duration}>
                {
                    (state) => (
                        <ul
                            className="category"
                            onClick={setCurrentCategory}
                            style={{
                                ...defaultStyle,
                                ...transitionStyles[state]
                            }}
                        >
                            <li data-category="all" className={classnames({ active: props.currentCategory === 'all' })}>全部课程</li>
                            <li data-category="react" className={classnames({ active: props.currentCategory === 'react' })}>React课程</li>
                            <li data-category="vue" className={classnames({ active: props.currentCategory === 'vue' })}>Vue课程</li>
                        </ul>
                    )
                }
            </Transition>
        </header>
    )
}
// 导出HomeHeader组件
export default HomeHeader;

4.3 HomeHeader\index.less #

src\views\Home\components\HomeHeader\index.less

// 设置背景颜色变量
@BG: #2a2a2a;
// 定义.home-header的样式
.home-header {
    // 固定位置在顶部
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    z-index: 999;
    // 定义.logo-header的样式
    .logo-header {
        // 设置高度和背景颜色
        height: 100px;
        background: @BG;
        color: #fff;
        // 设置flex布局,使内容水平居中且两侧对齐
        display: flex;
        justify-content: space-between;
        align-items: center;
        // 定义logo图片的样式
        img {
            width: 200px;
            margin-left: 20px;
        }
        // 定义Bars图标的样式
        span.anticon.anticon-bars {
            font-size: 60px;
            margin-right: 20px;
        }
    }
    // 定义.category的样式
    .category {
        // 绝对定位
        position: absolute;
        width: 100%;
        top: 100px;
        left: 0;
        background: @BG;
        // 定义li的样式
        li {
            line-height: 60px;
            text-align: center;
            color: #fff;
            font-size: 30px;
            // 边框颜色为@BG加亮20%
            border-top: 1px solid lighten(@BG, 20%);
            // 当li有.active类时,颜色变为红色
            &.active {
                color: red;
            }
        }
    }
}

4.4 action-types.js #

src\store\action-types.js

+export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';

4.5 reducers\home.js #

src\store\reducers\home.js

+import * as actionTypes from "../action-types";
// 引入所有的action类型
let initialState = {
+   currentCategory: 'all'
};
// 定义初始状态,其中初始的分类设置为'all'
export default function (state = initialState, action) {
// 定义一个默认的reducer函数
    switch (action.type) {
    // 根据传入的action的类型来决定如何更新state
+       case actionTypes.SET_CURRENT_CATEGORY:
        // 当action的类型是设置当前分类时
+           return { ...state, currentCategory: action.payload };
        // 返回新的state,并更新currentCategory的值
        default:
            // 默认情况下返回原始state
            return state;
    }
}

4.6 actions\home.js #

src\store\actions\home.js

import * as actionTypes from "../action-types";
export default {
  setCurrentCategory(currentCategory) {
    return { type: actionTypes.SET_CURRENT_CATEGORY, payload: currentCategory };
  },
};

4.7 Home\index.js #

src\views\Home\index.js

import React from "react";
+import actions from '@/store/actions/home';
+import HomeHeader from './components/HomeHeader';
+import { connect } from 'react-redux';
+import './index.less';
function Home(props) {
    return (
+       <>
+           <HomeHeader
+               currentCategory={props.currentCategory}
+               setCurrentCategory={props.setCurrentCategory}
+           />
+       </>
    )
}
+let mapStateToProps = (state) => state.home;
+export default connect(
+    mapStateToProps,
+    actions
+)(Home);

4.8 Home\index.less #

src\views\Home\index.less


5. 个人中心 #

5.1 参考 #

5.1.1 本章目录 #

├── package.json
├── public
│   └── index.html
├── src
│   ├── api
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   └── HomeHeader
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       └── Profile
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

5.1.2 本章效果 #

profileroute

5.2 Profile\index.js #

src\views\Profile\index.js

// 导入React和useEffect
import React, { useEffect } from "react";
// 导入antd-mobile中的组件
import { Button, List, Toast, Result,Mask } from "antd-mobile";
// 从react-redux中导入connect方法
import { connect } from "react-redux";
// 从react-router-dom中导入useNavigate方法
import { useNavigate } from 'react-router-dom';
// 导入与个人中心相关的actions
import actions from "@/store/actions/profile";
// 导入导航头部组件
import NavHeader from "@/components/NavHeader";
// 导入登录状态常量
import { LOGIN_TYPES } from '@/constants';
// 导入对应的样式
import "./index.less";
// 定义Profile组件
function Profile(props) {
    // 使用navigate方法来导航
    const navigate = useNavigate();

    // 使用useEffect来处理登录验证
    useEffect(() => {
        // 当用户未验证时,进行验证
        if (props.loginState == LOGIN_TYPES.UN_VALIDATE)
            props.validate().catch(() => Toast.show({
                icon:'fail',
                content:`验证失败`
            }));
    }, []);
    // 定义content,根据不同的登录状态显示不同内容
    let content=null;
    switch (props.loginState) {
        // 用户未验证时显示遮罩层
        case LOGIN_TYPES.UN_VALIDATE:
            content =  <Mask visible={true} />;
            break;
        // 用户已登录时显示用户信息
        case LOGIN_TYPES.LOGINED:
            content = (
                <div className="user-info">
                    <List renderHeader={() => '当前登录用户'}>
                        <List.Item extra="珠峰架构">用户名</List.Item>
                        <List.Item extra="15718856132">手机号</List.Item>
                        <List.Item extra="zhangsan@qq.com">邮箱</List.Item>
                    </List>
                    <Button type="primary">退出登录</Button>
                </div>
            );
            break;
        // 用户未登录时显示登录和注册按钮
        case LOGIN_TYPES.UNLOGIN:
            content = (
                <Result
                status='warning'
                title='亲爱的用户你好,你当前尚未登录,请你选择注册或者登录'
                description={
                    <div style={{ textAlign: "center", padding: "50px" }}>
                        <Button type="ghost" onClick={() => navigate("/login")}>登录</Button>
                        <Button
                            type="ghost"
                            style={{ marginLeft: "50px" }}
                            onClick={() => navigate("/register")}
                        >注册</Button>
                    </div>
                }
              />
            )
    }
    // 返回组件渲染结果
    return (
        <section>
            <NavHeader >个人中心</NavHeader>
            {content}
        </section>
    );
}
// 将state映射到props
let mapStateToProps = (state) => state.profile;
// 导出连接后的Profile组件
export default connect(mapStateToProps, actions)(Profile);

5.3 Profile\index.less #

src\views\Profile\index.less

.user-info {
    padding: 20px;
}

5.4 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
+export const VALIDATE = 'VALIDATE';

5.5 constants.js #

src\constants.js

export const LOGIN_TYPES = {
  UN_VALIDATE: "UN_VALIDATE",
  LOGINED: "LOGINED",
  UNLOGIN: "UNLOGIN"
};

5.6 profile.js #

src\store\reducers\profile.js

// 导入action类型常量
import * as actionTypes from "../action-types";
// 导入登录状态常量
import { LOGIN_TYPES } from "@/constants";
// 设置初始状态,包括登录状态、用户信息和错误信息
let initialState = {
  loginState: LOGIN_TYPES.UN_VALIDATE,
  user: null,
  error: null
};
// 定义默认导出的reducer函数,处理状态更新逻辑
export default function (state = initialState, action) {
  // 根据action的类型来决定如何更新状态
  switch (action.type) {
    // 当action类型为验证时
    case actionTypes.VALIDATE:
      // 如果验证成功
      if (action.payload.success) {
        return { 
          ...state,
          loginState: LOGIN_TYPES.LOGINED, // 更新登录状态为已登录
          user: action.payload.data,      // 更新用户信息
          error: null                      // 清除错误信息
        };
      } else {
        // 如果验证失败
        return { 
          ...state,
          loginState: LOGIN_TYPES.UNLOGIN, // 更新登录状态为未登录
          user: null,                      // 清除用户信息
          error: action.payload            // 设置错误信息
        };
      }
    // 默认情况下,返回原始状态
    default:
      return state;
  }
}

5.7 profile.js #

src\store\actions\profile.js

import * as actionTypes from "../action-types";
import { validate } from "@/api/profile";
export default {
  validate() {
    return {
      type: actionTypes.VALIDATE,
      payload: validate()
    };
  },
};

5.8 api\index.js #

src\api\index.js

// 导入axios库
import axios from "axios";
// 设置axios默认的请求基准路径
axios.defaults.baseURL = "http://ketang.zhufengpeixun.com";
// 设置axios默认的post请求头的内容类型
axios.defaults.headers.post["Content-Type"] = "application/json;charset=UTF-8";
// 使用请求拦截器,在发送请求前进行一些操作
axios.interceptors.request.use(
  (config) => {
    // 从会话存储中获取令牌
    let access_token = sessionStorage.getItem("access_token");
    // 设置请求头的Authorization为Bearer令牌形式
    config.headers = {
      Authorization: `Bearer ${access_token}`,
    };
    // 返回配置对象
    return config;
  },
  // 如果有错误,则返回一个拒绝的Promise
  (error) => {
    return Promise.reject(error);
  }
);
// 使用响应拦截器,对响应数据进行一些操作
axios.interceptors.response.use(
  // 如果响应成功,则返回响应的数据部分
  (response) => response.data,
  // 如果有错误,则返回一个拒绝的Promise
  (error) => Promise.reject(error)
);
// 默认导出axios实例
export default axios;

5.10 api\profile.js #

src\api\profile.js

import axios from "./";
export function validate() {
  return axios.get("/user/validate");
}

src\components\NavHeader\index.js

// 导入React库
import React from "react";
// 导入Ant Design的左向箭头图标
import { LeftOutlined } from "@ant-design/icons";
// 从'react-router-dom'中导入不安全的导航上下文
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom';
// 导入当前组件的样式
import "./index.less";
// 定义并导出NavHeader组件
export default function NavHeader(props) {
    // 使用React的上下文钩子来获取导航器对象
    const { navigator } = React.useContext(NavigationContext);
    // 渲染组件内容
    return (
        // 设定外层div的样式为"nav-header"
        <div className="nav-header">
            // 添加一个点击事件,当点击时调用navigator的back方法返回上一层
            <LeftOutlined onClick={() => navigator.back()} />
            // 插入传入的子组件或文本
            {props.children}
        </div>
    );
}

src\components\NavHeader\index.less

/* 定义.nav-header的样式 */
.nav-header {
  /* 设置定位为固定 */
  position: fixed;
  /* 从左边0距离开始 */
  left: 0;
  /* 从顶部0距离开始 */
  top: 0;
  /* 设置高度为100px */
  height: 100px;
  /* 设置层级为1000 */
  z-index: 1000;
  /* 宽度设置为100% */
  width: 100%;
  /* 设置盒子模型为border-box,使内边距和边框不增加宽高 */
  box-sizing: border-box;
  /* 文字居中显示 */
  text-align: center;
  /* 设置行高为100px */
  line-height: 100px;
  /* 设置背景颜色为#2a2a2a */
  background-color: #2a2a2a;
  /* 设置文本颜色为白色 */
  color: #fff;
  /* 设置字体大小为35px */
  font-size: 35px;
  /* 为.nav-header下的span元素定义样式 */
  span {
    /* 设置定位为绝对 */
    position: absolute;
    /* 从左边20px的位置开始 */
    left: 20px;
    /* 设置行高为100px */
    line-height: 100px;
  }
}

6. 注册登陆 #

6.1 参考 #

6.1.1 目录结构 #

.
├── package.json
├── public
│   └── index.html
├── src
│   ├── api
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   └── HomeHeader
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

6.1.2 本章效果 #

registerlogin

6.2 src\index.js #

src\index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { Provider } from "react-redux";
import { store, history } from "./store";
import "./styles/global.less";
import Tabs from "./components/Tabs";
import Home from "./views/Home";
import Cart from "./views/Cart";
+import Profile from "./views/Profile";
+import Register from "./views/Register";
import Login from "./views/Login";
import { HistoryRouter } from "redux-first-history/rr6";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
        <HistoryRouter history={history}>
            <main className="main-container">
                <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/cart" element={<Cart />} />
                    <Route path="/profile" element={<Profile />} />
+                    <Route path="/register" element={<Register/>} />
+          <Route path="/login" element={<Login/>} />
                </Routes>
            </main>
            <Tabs />
        </HistoryRouter>
    </Provider>
);

6.3 api\profile.js #

src\api\profile.js

import axios from "./";
export function validate() {
  return axios.get("/user/validate");
}
+export function register(values) {
+        return axios.post('/user/register', values);
+}
+export function login(values) {
+    return axios.post('/user/login', values);
+}

6.4 Profile\index.js #

src\views\Profile\index.js

import React, { useEffect } from "react";
import { Button, List, Toast, Result,Mask } from "antd-mobile";
import { connect } from "react-redux";
import { useNavigate } from 'react-router-dom';
import actions from "@/store/actions/profile";
import NavHeader from "@/components/NavHeader";
import { LOGIN_TYPES } from '@/constants';
import "./index.less";
function Profile(props) {
    const navigate = useNavigate();
    useEffect(() => {
+       props.validate();
    }, []);
    let content=null;
    switch (props.loginState) {
        case LOGIN_TYPES.UN_VALIDATE:
            content =  <Mask visible={true} />;
            break;
        case LOGIN_TYPES.LOGINED:
            content = (
                <div className="user-info">
+                   <List renderHeader={() => '当前登录用户'}>
+                       <List.Item extra={props.user.username}>用户名</List.Item>
+                       <List.Item extra={props.user.email}>邮箱</List.Item>
+                   </List>
+                   <Button type="primary" onClick={() => props.logout() }>退出登录</Button>
                </div>
            );
            break;
        case LOGIN_TYPES.UNLOGIN:
            content = (
                <Result
                status='warning'
                title='亲爱的用户你好,你当前尚未登录,请你选择注册或者登录'
                description={
                    <div style={{ textAlign: "center", padding: "50px" }}>
                        <Button type="ghost" onClick={() => navigate("/login")}>登录</Button>
                        <Button
                            type="ghost"
                            style={{ marginLeft: "50px" }}
                            onClick={() => navigate("/register")}
                        >注册</Button>
                    </div>
                }
              />
            )
    }
    return (
        <section>
            <NavHeader >个人中心</NavHeader>
            {content}
        </section>
    );
}
let mapStateToProps = (state) => state.profile;
export default connect(mapStateToProps, actions)(Profile);

6.5 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
+export const LOGOUT = 'LOGOUT';

6.6 actions\profile.js #

src\store\actions\profile.js

import * as actionTypes from "../action-types";
+import { validate, register, login } from '@/api/profile';
+import { push } from 'redux-first-history';
+import { Toast } from "antd-mobile";
export default {
  validate() {
    return {
      type: actionTypes.VALIDATE,
      payload: validate()
    };
  },
+ register(values) {
+   return function (dispatch) {
+     (async function () {
+       try {
+         let result = await register(values);
+         if (result.success) {
+           dispatch(push('/login'));
+           Toast.show({
+             icon: 'success',
+             content: `注册成功`
+           })
+         } else {
+           Toast.show({
+             icon: 'fail',
+             content: result.message
+           })
+         }
+       } catch (error) {
+         Toast.show({
+           icon: 'fail',
+           content: `注册失败`
+         })
+       }
+     })();
+   }
+ },
+ login(values) {
+   return function (dispatch) {
+     (async function () {
+       try {
+         let result = await login(values);
+         if (result.success) {
+           sessionStorage.setItem('access_token', result.data.token);
+           Toast.show({
+             icon: 'success',
+             content: `登录成功`
+           })
+           dispatch(push('/profile'));
+         } else {
+           Toast.show({
+             icon: 'fail',
+             content: result.message
+           })
+         }
+       } catch (error) {
+         Toast.show({
+           icon: 'fail',
+           content: `登录失败`
+         })
+       }
+     })();
+   }
+ },
+ logout() {
+   return function (dispatch) {
+     sessionStorage.removeItem('access_token');
+     dispatch({ type: actionTypes.LOGOUT });
+     dispatch(push('/login'));
+   }
+ }
};

6.7 profile.js #

src\store\reducers\profile.js

import * as actionTypes from "../action-types";
import { LOGIN_TYPES } from "@/constants";
let initialState = {
  loginState: LOGIN_TYPES.UN_VALIDATE,
  user: null,
  error: null
};
export default function (state = initialState, action) {
  switch (action.type) {
    case actionTypes.VALIDATE:
      if (action.payload.success) {
        return {
          ...state,
          loginState: LOGIN_TYPES.LOGINED,
          user: action.payload.data,
          error: null
        };
      } else {
        return {
          ...state,
          loginState: LOGIN_TYPES.UNLOGIN,
          user: null,
          error: action.payload
        };
      }
+   case actionTypes.LOGOUT:
+     return {
+       ...state,
+       loginState: LOGIN_TYPES.UN_VALIDATE,
+       user: null,
+       error: null,
+     };
    default:
      return state;
  }
}

6.8 Register\index.js #

src\views\Register\index.js

// 导入React库
import React from "react";
// 导入react-redux的connect函数
import { connect } from "react-redux";
// 导入profile相关的actions
import actions from "../../store/actions/profile";
// 导入react-router-dom的Link组件
import { Link } from "react-router-dom";
// 导入NavHeader组件
import NavHeader from "../../components/NavHeader";
// 导入antd-mobile的Form, Input, Button和Toast组件
import { Form, Input, Button, Toast } from "antd-mobile";
// 导入@ant-design/icons的相关图标
import { UserAddOutlined, LockOutlined, MailOutlined } from "@ant-design/icons";
// 导入当前组件的样式文件
import "./index.less";

// 定义Register函数组件
function Register(props) {
    // 定义表单提交成功后的回调函数
    const onFinish = (values) => {
        props.register(values);
    };
    // 定义表单提交失败后的回调函数
    const onFinishFailed = (errorInfo) => {
        Toast.show({
            icon: "fail",
            content: "表单验证失败! " + errorInfo,
        });
    };
    // 渲染组件的UI部分
    return (
        <>
            // 导航头部,标题为“用户注册”
            <NavHeader>用户注册</NavHeader>
            // 定义表单,并设置表单的提交成功和失败的回调函数
            <Form
                onFinish={onFinish}
                onFinishFailed={onFinishFailed}
                className="register-form"
            >
                // 定义用户名输入框,并设置相关的验证规则
                <Form.Item
                    label="用户名"
                    name="username"
                    rules={[{ required: true, message: "请输入你的用户名!" }]}
                >
                    <Input prefix={<UserAddOutlined />} placeholder="用户名" />
                </Form.Item>
                // 定义密码输入框,并设置相关的验证规则
                <Form.Item
                    label="密码"
                    name="password"
                    rules={[{ required: true, message: "请输入你的密码!" }]}
                >
                    <Input prefix={<LockOutlined />} type="password" placeholder="密码" />
                </Form.Item>
                // 定义确认密码输入框,并设置相关的验证规则
                <Form.Item
                    label="确认密码"
                    name="confirmPassword"
                    rules={[{ required: true, message: "请输入你的确认密码!" }]}
                >
                    <Input
                        prefix={<LockOutlined />}
                        type="password"
                        placeholder="确认密码"
                    />
                </Form.Item>
                // 定义邮箱输入框,并设置相关的验证规则
                <Form.Item
                    label="邮箱"
                    name="email"
                    rules={[{ required: true, message: "请输入你的邮箱!" }]}
                >
                    <Input prefix={<MailOutlined />} type="email" placeholder="邮箱" />
                </Form.Item>
                // 定义注册按钮和一个跳转到登录页面的链接
                <Form.Item>
                    <Button
                        type="primary"
                        htmlType="submit"
                    >
                        注册
                    </Button>
                    或者 <Link to="/login">立刻登录!</Link>
                </Form.Item>
            </Form>
        </>
    );
}
// 定义mapStateToProps函数,用于将store中的state映射到组件的props
let mapStateToProps = (state) => state.profile;
// 导出经过connect高阶组件处理过的Register组件
export default connect(mapStateToProps, actions)(Register);

6.9 Register\index.less #

views\Register\index.less

.register-form {
    padding: 20px;
}

6.10 Login\index.js #

src\views\Login\index.js

// 导入React库
import React from "react";
// 导入react-redux的connect函数
import { connect } from "react-redux";
// 导入antd-mobile的Form, Input, Button和Toast组件
import { Form, Input, Button, Toast } from "antd-mobile";
// 导入@ant-design/icons的相关图标
import { UserAddOutlined, LockOutlined } from "@ant-design/icons";
// 导入profile相关的actions
import actions from "@/store/actions/profile";
// 导入react-router-dom的Link组件
import { Link } from "react-router-dom";
// 导入NavHeader组件
import NavHeader from "@/components/NavHeader";
// 导入当前组件的样式文件
import "./index.less";
// 定义Login函数组件
function Login(props) {
    // 定义表单提交成功后的回调函数
    const onFinish = (values) => {
        props.login(values);
    };
    // 定义表单提交失败后的回调函数
    const onFinishFailed = (errorInfo) => {
        Toast.show({
            icon: "fail",
            content: "表单验证失败! " + errorInfo,
        });
    };
    // 渲染组件的UI部分
    return (
        <>
            // 导航头部,标题为“用户登录”
            <NavHeader>用户登录</NavHeader>
            // 定义表单,并设置表单的提交成功和失败的回调函数
            <Form
                onFinish={onFinish}
                onFinishFailed={onFinishFailed}
                className="login-form"
            >
                // 定义用户名输入框,并设置相关的验证规则
                <Form.Item
                    label="用户名"
                    name="username"
                    rules={[{ required: true, message: "请输入你的用户名!" }]}
                >
                    <Input prefix={<UserAddOutlined />} placeholder="用户名" />
                </Form.Item>
                // 定义密码输入框,并设置相关的验证规则
                <Form.Item
                    label="密码"
                    name="password"
                    rules={[{ required: true, message: "请输入你的密码!" }]}
                >
                    <Input prefix={<LockOutlined />} type="password" placeholder="密码" />
                </Form.Item>
                // 定义登录按钮和一个跳转到注册页面的链接
                <Form.Item>
                    <Button
                        type="primary"
                        htmlType="submit"
                    >
                        登录
                    </Button>
                    或者 <Link to="/register">立刻注册!</Link>
                </Form.Item>
            </Form>
        </>
    );
}
// 定义mapStateToProps函数,用于将store中的state映射到组件的props
const mapStateToProps = (state) => state.profile;
// 导出经过connect高阶组件处理过的Login组件
export default connect(mapStateToProps, actions)(Login);

6.11 Login\index.less #

src\views\Login\index.less

.login-form {
    padding: 20px;
}

7.上传头像 #

7.1 参考 #

7.1.1 本章目录 #

.
├── package.json
├── public
│   └── index.html
├── src
│   ├── api
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   └── HomeHeader
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

7.1.2 本章效果 #

uploadavatar.gif

7.2 Profile\index.js #

src\views\Profile\index.js

+import React, { useEffect,useState } from "react";
+import { Button, List, Toast, Result, Mask,ImageUploader } from "antd-mobile";
import { connect } from "react-redux";
import { useNavigate } from 'react-router-dom';
import actions from "@/store/actions/profile";
import NavHeader from "@/components/NavHeader";
import { LOGIN_TYPES } from '@/constants';
import "./index.less";
function Profile(props) {
    const navigate = useNavigate();
+   const [fileList, setFileList] = useState(()=>{
+       return props.user?.avatar ? [{url:props.user.avatar}] : [];
+   })
    useEffect(() => {
+       props.validate().then((action)=>{
+          if(action?.payload?.data?.avatar){
+               setFileList([{url:action.payload.data.avatar}]);
+          }
+          return action;
+       },()=>{Toast.show({
+           icon: 'fail',
+           content: `未登录`
+       })})
    }, []);
    let content = null;
    switch (props.loginState) {
        case LOGIN_TYPES.UN_VALIDATE:
            content = <Mask visible={true} />;
            break;
        case LOGIN_TYPES.LOGINED:
+           const uploadImage = async (file) => {
+               let result = await props.uploadAvatar(props.user.id,file);
+               return {url: result.data};
+           };
            content = (
                <div className="user-info">
                    <List renderHeader={() => '当前登录用户'}>
                        <List.Item extra={props.user.username}>用户名</List.Item>
                        <List.Item extra={props.user.email}>邮箱</List.Item>
+                       <List.Item extra={
+                           <ImageUploader
+                               maxCount={1}
+                               onDelete={() => setFileList([])}
+                               accept="image/jpg,image/jpeg,image/png,image/gif"
+                               value={fileList}
+                               upload={uploadImage}
+                               beforeUpload={beforeUpload}
+                               imageFit="fit"
+                           />
+                       }>头像</List.Item>
                    </List>
                    <Button type="primary" onClick={() => props.logout()}>退出登录</Button>
                </div>
            );
            break;
        case LOGIN_TYPES.UNLOGIN:
            content = (
                <Result
                    status='warning'
                    title='亲爱的用户你好,你当前尚未登录,请你选择注册或者登录'
                    description={
                        <div style={{ textAlign: "center", padding: "50px" }}>
                            <Button type="ghost" onClick={() => navigate("/login")}>登录</Button>
                            <Button
                                type="ghost"
                                style={{ marginLeft: "50px" }}
                                onClick={() => navigate("/register")}
                            >注册</Button>
                        </div>
                    }
                />
            )
    }
    return (
        <section>
            <NavHeader >个人中心</NavHeader>
            {content}
        </section>
    );
}
+function beforeUpload(file) {
+  const isLessThan2M = file.size / 1024 / 1024 < 2;
+  if (!isLessThan2M) {
+    Toast.show({
+        icon: 'fail',
+        content: "图片必须小于2MB!"
+    })
+    return false;
+  }
+  return file;
+}
let mapStateToProps = (state) => state.profile;
export default connect(mapStateToProps, actions)(Profile);

7.3 Profile\index.less #

src\views\Profile\index.less

.user-info {
    padding: 20px;
}
+.adm-image-img{
+    height:100%;
+}

7.4 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
export const LOGOUT = 'LOGOUT';
+export const CHANGE_AVATAR = "CHANGE_AVATAR";

7.5 reducers\profile.js #

src\store\reducers\profile.js

import * as actionTypes from "../action-types";
import { LOGIN_TYPES } from "@/constants";
let initialState = {
  loginState: LOGIN_TYPES.UN_VALIDATE,
  user: null,
  error: null
};
export default function (state = initialState, action) {
  switch (action.type) {
    case actionTypes.VALIDATE:
      if (action.payload.success) {
        return {
          ...state,
          loginState: LOGIN_TYPES.LOGINED,
          user: action.payload.data,
          error: null
        };
      } else {
        return {
          ...state,
          loginState: LOGIN_TYPES.UNLOGIN,
          user: null,
          error: action.payload
        };
      }
    case actionTypes.LOGOUT:
      return {
        ...state,
        loginState: LOGIN_TYPES.UN_VALIDATE,
        user: null,
        error: null,
      };
+   case actionTypes.CHANGE_AVATAR:
+     return { ...state, user: { ...state.user, avatar: action.payload } };
    default:
      return state;
  }
}

7.6 actions\profile.js #

src\store\actions\profile.js

import * as actionTypes from "../action-types";
+import { validate, register, login,uploadAvatar } from '@/api/profile';
import { push } from 'redux-first-history';
import { Toast } from "antd-mobile";
export default {
  validate() {
    return {
      type: actionTypes.VALIDATE,
      payload: validate()
    };
  },
  register(values) {
    return function (dispatch) {
      (async function () {
        try {
          let result = await register(values);
          if (result.success) {
            dispatch(push('/login'));
            Toast.show({
              icon: 'success',
              content: `注册成功`
            })
          } else {
            Toast.show({
              icon: 'fail',
              content: result.message
            })
          }
        } catch (error) {
          Toast.show({
            icon: 'fail',
            content: `注册失败`
          })
        }
      })();
    }
  },
  login(values) {
    return function (dispatch) {
      (async function () {
        try {
          let result = await login(values);
          if (result.success) {
            sessionStorage.setItem('access_token', result.data.token);
            Toast.show({
              icon: 'success',
              content: `登录成功`
            })
            dispatch(push('/profile'));
          } else {
            Toast.show({
              icon: 'fail',
              content: result.message
            })
          }
        } catch (error) {
          Toast.show({
            icon: 'fail',
            content: `登录失败`
          })
        }
      })();
    }
  },
  logout() {
    return function (dispatch) {
      sessionStorage.removeItem('access_token');
      dispatch({ type: actionTypes.LOGOUT });
      dispatch(push('/login'));
    }
  },
+ uploadAvatar(userId,avatar){
+   return function (dispatch) {
+     return (async function () {
+       try {
+         let result = await uploadAvatar(userId,avatar);
+         if (result.success) {
+           dispatch({
+             type: actionTypes.CHANGE_AVATAR,
+             payload: result.data
+           })
+           Toast.show({
+             icon: 'success',
+             content: `上传成功`
+           })
+         } else {
+           Toast.show({
+             icon: 'fail',
+             content: result.message
+           })
+         }
+         return result
+       } catch (error) {
+         Toast.show({
+           icon: 'fail',
+           content: `上传失败`
+         })
+       }
+     })();
+   }
+ }
};

7.7 api\profile.js #

src\api\profile.js

import axios from "./";
export function validate() {
  return axios.get("/user/validate");
}
export function register(values) {
        return axios.post('/user/register', values);
}
export function login(values) {
    return axios.post('/user/login', values);
}
+export function uploadAvatar(userId,avatar) {
+  const formData = new FormData();
+  formData.append('userId', userId);
+  formData.append('avatar', avatar);
+  return axios.post('/user/uploadAvatar', formData, {
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  });
+}

8. 前台轮播图 #

8.1 参考 #

8.1.1 本章目录 #

.
├── package.json
├── public
│   └── index.html
├── README.md
├── src
│   ├── api
│   │   ├── home.js
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   ├── HomeHeader
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   └── HomeSwiper
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

8.1.2 本章效果 #

homesliders

8.2 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
export const LOGOUT = 'LOGOUT';
export const CHANGE_AVATAR = "CHANGE_AVATAR";
+export const GET_SLIDERS = "GET_SLIDERS";

8.3 actions\home.js #

src\store\actions\home.js

import * as actionTypes from "../action-types";
+import { getSliders } from "@/api/home";
export default {
  setCurrentCategory(currentCategory) {
    return { type: actionTypes.SET_CURRENT_CATEGORY, payload: currentCategory };
  },
+ getSliders() {
+   return {
+     type: actionTypes.GET_SLIDERS,
+     payload: getSliders(),
+   };
+ }
};

8.4 reducers\home.js #

src\store\reducers\home.js

import * as actionTypes from "../action-types";
let initialState = {
    currentCategory: 'all',
+   sliders:[]
};
export default function (state = initialState, action) {
    switch (action.type) {
        case actionTypes.SET_CURRENT_CATEGORY:
            return { ...state, currentCategory: action.payload };
+       case actionTypes.GET_SLIDERS:
+            return { ...state, sliders: action.payload.data };
        default:
            return state;
    }
}

8.5 api\home.js #

src\api\home.js

import axios from "./";
export function getSliders() {
  return axios.get("/slider/list");
}

8.6 HomeSwiper.js #

src\views\Home\components\HomeSwiper.js

// 导入React库和useEffect Hook
import React, { useEffect } from "react";
// 导入antd-mobile的Swiper和Image组件
import { Swiper, Image } from "antd-mobile";
// 导入当前组件的样式文件
import "./index.less";
// 定义HomeSwiper函数组件
function HomeSwiper(props) {
    // 使用useEffect进行副作用操作
    useEffect(() => {
        // 当sliders数组为空且存在getSliders方法时,执行getSliders
        if (props.sliders && props.sliders.length === 0 && props.getSliders) {
            props.getSliders();
        }
    }, []);
    // 返回Swiper组件进行渲染
    return (
        // 配置Swiper组件的autoplay和loop属性
        <Swiper autoplay={true} loop={true}>
            {
                // 对props.sliders数组进行map遍历,返回多个Swiper.Item组件
                props.sliders.map((slider) => (
                    // 为每个Swiper.Item组件设置key属性,并渲染Image组件显示图片
                    <Swiper.Item key={slider._id}>
                        <Image src={slider.url} lazy />
                    </Swiper.Item>
                ))
            }
        </Swiper>
    );
}
// 导出HomeSwiper组件
export default HomeSwiper;

8.7 HomeSwiper\index.less #

src\views\Home\components\HomeSwiper\index.less

.adm-image-img{
  height:320px;
}

8.8 Home\index.js #

src\views\Home\index.js

import React from "react";
import actionCreators from '@/store/actions/home';
import HomeHeader from './components/HomeHeader';
import { connect } from 'react-redux';
+import HomeSwiper from "./components/HomeSwiper";
import './index.less';
function Home(props) {
    return (
        <>
            <HomeHeader
                currentCategory={props.currentCategory}
                setCurrentCategory={props.setCurrentCategory}
            />
+           <div className="home-container">
+               <HomeSwiper sliders={props.sliders} getSliders={props.getSliders} />
+           </div>
        </>
    )
}
let mapStateToProps = (state) => state.home;
export default connect(
    mapStateToProps,
    actionCreators
)(Home);

9. 课程列表 #

9.1 参考 #

9.1.1 目录结构 #

.
├── package.json
├── public
│   └── index.html
├── README.md
├── src
│   ├── api
│   │   ├── home.js
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   ├── utils.js
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   ├── HomeHeader
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   ├── HomeSwiper
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   └── LessonList
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

9.1.2 页面效果 #

lessonlist2

9.2 api\home.js #

src\api\home.js

import axios from "./";
export function getSliders() {
  return axios.get("/slider/list");
}
+export function getLessons(currentCategory = "all",offset,limit) {
+  return axios.get(
+    `/lesson/list?category=${currentCategory}&offset=${offset}&limit=${limit}`
+  );
+}

9.3 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
export const LOGOUT = 'LOGOUT';
export const CHANGE_AVATAR = "CHANGE_AVATAR";
export const GET_SLIDERS = "GET_SLIDERS";
+export const GET_LESSONS = "GET_LESSONS";
+export const SET_LESSONS_LOADING = "SET_LESSONS_LOADING";
+export const SET_LESSONS = "SET_LESSONS";

9.4 reducers\home.js #

src\store\reducers\home.js

import * as actionTypes from "../action-types";
let initialState = {
    currentCategory: 'all',
    sliders: [],
+   lessons: {
+       loading: false,
+       list: [],
+       hasMore: true,
+       offset: 0,
+       limit: 5
+   },
};
export default function (state = initialState, action) {
    switch (action.type) {
        case actionTypes.SET_CURRENT_CATEGORY:
            return { ...state, currentCategory: action.payload };
        case actionTypes.GET_SLIDERS:
            return { ...state, sliders: action.payload.data };
+       case actionTypes.SET_LESSONS_LOADING:
+           return {
+               ...state,
+               lessons: { ...state.lessons, loading: action.payload },
+           };
+       case actionTypes.SET_LESSONS:
+           return {
+               ...state,
+               lessons: {
+                   ...state.lessons,
+                   loading: false,
+                   hasMore: action.payload.hasMore,
+                   list: [...state.lessons.list, ...action.payload.list],
+                   offset: state.lessons.offset + action.payload.list.length
+               },
+           };
        default:
            return state;
    }
}

9.5 actions\home.js #

src\store\actions\home.js

import * as actionTypes from "../action-types";
+import { getSliders, getLessons } from "@/api/home";
export default {
  setCurrentCategory(currentCategory) {
    return { type: actionTypes.SET_CURRENT_CATEGORY, payload: currentCategory };
  },
  getSliders() {
    return {
      type: actionTypes.GET_SLIDERS,
      payload: getSliders(),
    };
  },
+ getLessons() {
+   return (dispatch, getState) => {
+     (async function () {
+       let {
+         currentCategory,
+         lessons: { hasMore, offset, limit, loading },
+       } = getState().home;
+       if (hasMore && !loading) {
+         dispatch({ type: actionTypes.SET_LESSONS_LOADING, payload: true });
+         let result = await getLessons(currentCategory, offset, limit);
+         dispatch({ type: actionTypes.SET_LESSONS, payload: result.data });
+       }
+     })();
+   };
+ }
};

9.6 src\utils.js #

src\utils.js

// 定义一个loadMore函数,用于滚动加载更多内容
export function loadMore(element, callback) {
    // 定义一个内部_loadMore函数,用于检查滚动状态
    function _loadMore() {
        // 获取元素的可视区域高度
        let clientHeight = element.clientHeight;
        // 获取元素滚动的高度
        let scrollTop = element.scrollTop;
        // 获取元素的整体高度
        let scrollHeight = element.scrollHeight;
        // 当滚动接近底部时(这里设置的是距离底部10px),执行回调函数
        if (clientHeight + scrollTop + 10 >= scrollHeight) {
            callback();
        }
    }
    // 给元素添加一个带防抖功能的滚动监听事件
    element.addEventListener("scroll", debounce(_loadMore, 300));
}
// 定义一个debounce函数,用于延迟执行函数并且防止频繁触发
export function debounce(fn, wait) {
    // 定义一个timeout变量,用于存储定时器的返回值
    var timeout = null;
    // 返回一个新的函数
    return function () {
        // 如果timeout有值,说明定时器已经被设置,所以需要清除定时器
        if (timeout !== null) clearTimeout(timeout);
        // 设置新的定时器,当wait时间后执行传入的函数fn
        timeout = setTimeout(fn, wait);
    };
}

9.7 LessonList\index.js #

src\views\Home\components\LessonList\index.js

// 引入React和effect Hook
import React, { useEffect } from "react";
// 引入antd-mobile组件库中的相关组件
import { Image, Button, NoticeBar, Card, Skeleton } from "antd-mobile";
// 引入路由的Link组件
import { Link } from "react-router-dom";
// 引入ant-design图标组件
import { MenuOutlined } from "@ant-design/icons";
// 引入当前组件的样式文件
import "./index.less";

// 定义LessonList组件
function LessonList(props) {
  // 使用effect Hook加载课程列表
  useEffect(() => {
    // 如果课程列表为空,则调用getLessons方法
    if (props.lessons.list.length == 0) {
      props.getLessons();
    }
  }, []);

  // 返回组件渲染内容
  return (
    <section className="lesson-list">
      <h2>
        <MenuOutlined /> 全部课程
      </h2>
      // 判断课程列表是否有内容
      {props.lessons.list.length > 0 ? props.lessons.list.map((lesson) => (
        // 为每个课程生成一个Link链接
        <Link
          key={lesson.id}
          to={{ pathname: `/detail/${lesson.id}` }}
          state={lesson}
        >
          // 显示课程卡片
          <Card headerStyle={{ display: 'flex', justifyContent: 'center' }} title={lesson.title}>
            <Image src={lesson.poster} />
          </Card>
        </Link>
      )) : (
        // 如果没有课程,则显示加载动画
        <>
          <Skeleton.Title animated />
          <Skeleton.Paragraph lineCount={5} animated />
        </>
      )}
      // 判断是否还有更多课程可以加载
      {props.lessons.hasMore ? (
        <Button
          onClick={props.getLessons}
          loading={props.lessons.loading}
          type="primary"
          block
        >
          {props.lessons.loading ? "" : "加载更多"}
        </Button>
      ) : (
        // 如果没有更多课程,则显示“到底了”提示
        <NoticeBar content='到底了' color='alert' />
      )}
    </section>
  );
}
// 导出LessonList组件
export default LessonList;

9.8 LessonList\index.less #

src\views\Home\components\LessonList\index.less

/* 定义.lesson-list的样式 */
.lesson-list {
    /* 定义标题h2的样式 */
    h2 {
        /* 设置行高 */
        line-height: 100px;
        /* 定义i标签(图标)的样式 */
        i {
            /* 设置左右外边距 */
            margin: 0 10px;
        }
    }
    /* 定义通知栏的样式 */
    .adm-notice-bar.adm-notice-bar-alert{
        /* 内容居中显示 */
        justify-content: center;
        /* 定义通知内容的样式 */
        .adm-notice-bar-content{
            /* 取消flex拉伸或压缩 */
            flex: none;
        }
    }
    /* 定义图片的样式 */
    .adm-image-img{
        /* 设置图片高度 */
        height: 500px;
    }
}

9.9 Home\index.js #

src\views\Home\index.js

+import React,{useRef,useEffect} from "react";
import actionCreators from '@/store/actions/home';
import HomeHeader from './components/HomeHeader';
import { connect } from 'react-redux';
import HomeSwiper from "./components/HomeSwiper";
+import LessonList from "./components/LessonList";
+import { loadMore} from "@/utils";
import './index.less';
function Home(props) {
+   const homeContainerRef = useRef(null);
+   useEffect(() => {
+         loadMore(homeContainerRef.current, props.getLessons);
+   }, []);
    return (
        <>
            <HomeHeader
                currentCategory={props.currentCategory}
                setCurrentCategory={props.setCurrentCategory}
            />
+           <div className="home-container" ref={homeContainerRef}>
                <HomeSwiper sliders={props.sliders} getSliders={props.getSliders} />
+               <LessonList
+                   container={homeContainerRef}
+                   lessons={props.lessons}
+                   getLessons={props.getLessons}
+               />
+           </div>
        </>
    )
}
let mapStateToProps = (state) => state.home;
export default connect(
    mapStateToProps,
    actionCreators
)(Home);

9.10 Home\index.less #

src\views\Home\index.less

+.home-container {
+    position: fixed;
+    top: 100px;
+    left: 0;
+    width: 100%;
+    overflow-y: auto;
+    height: calc(100vh - 220px);
+    background-color: #FFF;
+}

10. 课程详情 #

10.1 参考 #

10.1.1 目录结构 #

.
├── package.json
├── public
│   └── index.html
├── README.md
├── src
│   ├── api
│   │   ├── home.js
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   ├── utils.js
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Detail
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   ├── HomeHeader
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   ├── HomeSwiper
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   └── LessonList
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

10.1.2 页面效果 #

lessondetail

10.2 src\index.js #

src\index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { Provider } from "react-redux";
import { store, history } from "./store";
import "./styles/global.less";
import Tabs from "./components/Tabs";
import Home from "./views/Home";
import Cart from "./views/Cart";
import Profile from "./views/Profile";
import Register from "./views/Register";
import Login from "./views/Login";
+import Detail from "./views/Detail";
import { HistoryRouter } from "redux-first-history/rr6";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
        <HistoryRouter history={history}>
            <main className="main-container">
                <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/cart" element={<Cart />} />
                    <Route path="/profile" element={<Profile />} />
                    <Route path="/register" element={<Register/>} />
          <Route path="/login" element={<Login/>} />
+                    <Route path="/detail/:id" element={<Detail/>} />
                </Routes>
            </main>
            <Tabs />
        </HistoryRouter>
    </Provider>
);

10.3 api\home.js #

src\api\home.js

import axios from "./";
export function getSliders() {
  return axios.get("/slider/list");
}
export function getLessons(currentCategory = "all",offset,limit) {
  return axios.get(
    `/lesson/list?category=${currentCategory}&offset=${offset}&limit=${limit}`
  );
}
+export function getLesson(id) {
+  return axios.get(`/lesson/${id}`);
+}

10.4 views\Detail\index.js #

src\views\Detail\index.js

// 导入React的相关库和功能
import React, { useState, useEffect } from "react";
// 导入redux的connect方法
import { connect } from "react-redux";
// 导入antd-mobile的组件
import { Card,Image } from "antd-mobile";
// 导入自定义的NavHeader组件
import NavHeader from "@/components/NavHeader";
// 导入请求方法getLesson
import { getLesson } from "@/api/home";
// 导入react-router-dom的钩子函数
import {useLocation,useParams} from 'react-router-dom';

// 定义详情组件
function Detail() {
    // 获取location对象
    const location = useLocation();
    // 获取路由参数
    const params = useParams();
    // 定义课程状态和设置方法
    let [lesson, setLesson] = useState({});
    // 使用effect钩子获取课程数据
    useEffect(() => {
        (async () => {
            // 调试点
            debugger
            // 尝试从location中获取课程数据
            let lesson = location.state;
            // 如果location中没有课程数据,发请求获取
            if (!lesson) {
                let result = await getLesson(params.id);
                if (result.success) lesson = result.data;
            }
            // 更新课程状态
            setLesson(lesson);
        })();
    }, []);

    // 渲染组件
    return (
        <>
            <NavHeader>课程详情</NavHeader>
            <Card headerStyle={{display:'flex',justifyContent:'center'}} title={lesson.title}>
             <Image src={lesson.poster} />
            </Card>
        </>
    );
}
// 导出连接到redux的Detail组件
export default connect()(Detail);

11. 下拉刷新 #

11.1 参考 #

11.1.1 目录结构 #

.
├── package.json
├── public
│   └── index.html
├── README.md
├── src
│   ├── api
│   │   ├── home.js
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   ├── utils.js
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Detail
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   ├── HomeHeader
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   ├── HomeSwiper
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   └── LessonList
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

11.2 src\utils.js #

src\utils.js

export function loadMore(element, callback) {
    function _loadMore() {
        let clientHeight = element.clientHeight;
        let scrollTop = element.scrollTop;
        let scrollHeight = element.scrollHeight;
        if (clientHeight + scrollTop + 10 >= scrollHeight) {
            callback();
        }
    }
    element.addEventListener("scroll", debounce(_loadMore, 300));
}
export function debounce(fn, wait) {
    var timeout = null;
    return function () {
        if (timeout !== null) clearTimeout(timeout);
        timeout = setTimeout(fn, wait);
    };
}
+export function downRefresh(element, callback) {
+  let startY;
+  let distance;
+  let originalTop = element.offsetTop;
+  let startTop;
+  let $timer = null;
+  element.addEventListener("touchstart", function (event) {
+    if ($timer) clearInterval($timer);
+    let touchMove = throttle(_touchMove, 30);
+    if (element.scrollTop === 0) {
+      startTop = element.offsetTop;
+      startY = event.touches[0].pageY; 
+      element.addEventListener("touchmove", touchMove);
+      element.addEventListener("touchend", touchEnd);
+    }
+    function _touchMove(event) {
+      let pageY = event.touches[0].pageY;
+      if (pageY > startY) {
+        distance = pageY - startY;
+        element.style.top = startTop + distance + "px";
+      } else {
+        element.removeEventListener("touchmove", touchMove);
+        element.removeEventListener("touchend", touchEnd);
+      }
+    }
+    function touchEnd(_event) {
+      element.removeEventListener("touchmove", touchMove);
+      element.removeEventListener("touchend", touchEnd);
+      if (distance > 30) {
+        callback();
+      }
+      $timer = setInterval(() => {
+         let currentTop = element.offsetTop;
+         if (currentTop - originalTop >= 1) {
+          element.style.top = currentTop - 1 + 'px';
+         } else {
+             $timer && clearInterval($timer)
+             element.style.top = originalTop + 'px';
+         }
+       }, 16);
+    }
+  });
+}

+export function throttle(func, delay) {
+  var prev = Date.now();
+  return function () {
+    var context = this;
+    var args = arguments;
+    var now = Date.now();
+    if (now - prev >= delay) {
+      func.apply(context, args);
+      prev = now;
+    }
+  };
+}

11.3 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
export const LOGOUT = 'LOGOUT';
export const CHANGE_AVATAR = "CHANGE_AVATAR";
export const GET_SLIDERS = "GET_SLIDERS";
export const GET_LESSONS = "GET_LESSONS";
export const SET_LESSONS_LOADING = "SET_LESSONS_LOADING";
export const SET_LESSONS = "SET_LESSONS";
+export const REFRESH_LESSONS = "REFRESH_LESSONS";

11.4 actions\home.js #

src\store\actions\home.js

import * as actionTypes from "../action-types";
import { getSliders, getLessons } from "@/api/home";
export default {
  setCurrentCategory(currentCategory) {
    return { type: actionTypes.SET_CURRENT_CATEGORY, payload: currentCategory };
  },
  getSliders() {
    return {
      type: actionTypes.GET_SLIDERS,
      payload: getSliders(),
    };
  },
  getLessons() {
    return (dispatch, getState) => {
      (async function () {
        let {
          currentCategory,
          lessons: { hasMore, offset, limit, loading },
        } = getState().home;
        if (hasMore && !loading) {
          dispatch({ type: actionTypes.SET_LESSONS_LOADING, payload: true });
          let result = await getLessons(currentCategory, offset, limit);
          dispatch({ type: actionTypes.SET_LESSONS, payload: result.data });
        }
      })();
    };
  },
+ refreshLessons() {
+   return (dispatch, getState) => {
+     (async function () {
+       let { currentCategory, lessons: { limit, loading } } = getState().home;
+       if (!loading) {
+         dispatch({ type: actionTypes.SET_LESSONS_LOADING, payload: true });
+         let result = await getLessons(currentCategory, 0, limit);
+         dispatch({ type: actionTypes.REFRESH_LESSONS, payload: result.data });
+       }
+     })();
+   }
+ }
};

11.5 reducers\home.js #

src\store\reducers\home.js

import * as actionTypes from "../action-types";
let initialState = {
    currentCategory: 'all',
    sliders: [],
    lessons: {
        loading: false,
        list: [],
        hasMore: true,
        offset: 0,
        limit: 5
    },
};
export default function (state = initialState, action) {
    switch (action.type) {
        case actionTypes.SET_CURRENT_CATEGORY:
            return { ...state, currentCategory: action.payload };
        case actionTypes.GET_SLIDERS:
            return { ...state, sliders: action.payload.data };
        case actionTypes.SET_LESSONS_LOADING:
            return {
                ...state,
                lessons: { ...state.lessons, loading: action.payload },
            };
        case actionTypes.SET_LESSONS:
            return {
                ...state,
                lessons: {
                    ...state.lessons,
                    loading: false,
                    hasMore: action.payload.hasMore,
                    list: [...state.lessons.list, ...action.payload.list],
                    offset: state.lessons.offset + action.payload.list.length
                },
            };
+       case actionTypes.REFRESH_LESSONS:
+           return {
+               ...state,
+               lessons: {
+                   ...state.lessons,
+                   loading: false,
+                   hasMore: action.payload.hasMore,
+                   list: action.payload.list,
+                   offset: action.payload.list.length,
+               },
+           };
        default:
            return state;
    }
}

11.6 Home\index.js #

src\views\Home\index.js

import React,{useRef,useEffect} from "react";
import actionCreators from '@/store/actions/home';
import HomeHeader from './components/HomeHeader';
import { connect } from 'react-redux';
import HomeSwiper from "./components/HomeSwiper";
import LessonList from "./components/LessonList";
+import { loadMore,downRefresh} from "@/utils";
+import {DotLoading } from 'antd-mobile';
import './index.less';
function Home(props) {
    const homeContainerRef = useRef(null);
    useEffect(() => {
          loadMore(homeContainerRef.current, props.getLessons);
+         downRefresh(homeContainerRef.current, props.refreshLessons);
    }, []);
    return (
        <>
+           <DotLoading />
            <HomeHeader
                currentCategory={props.currentCategory}
                setCurrentCategory={props.setCurrentCategory}
+               refreshLessons={props.refreshLessons}
            />
            <div className="home-container" ref={homeContainerRef}>
                <HomeSwiper sliders={props.sliders} getSliders={props.getSliders} />
                <LessonList
                    container={homeContainerRef}
                    lessons={props.lessons}
                    getLessons={props.getLessons}
                />
            </div>
        </>
    )
}
let mapStateToProps = (state) => state.home;
export default connect(
    mapStateToProps,
    actionCreators
)(Home);

11.7 Home\index.less #

src\views\Home\index.less

.home-container {
    position: fixed;
    top: 100px;
    left: 0;
    width: 100%;
    overflow-y: auto;
    height: calc(100vh - 220px);
    background-color: #FFF;
}
+.adm-loading.adm-dot-loading{
+    position: fixed;
+    top:200px;
+    width:100%;
+    text-align: center;
+    font-size: 48px;
+}

11.8 HomeHeader\index.js #

src\views\Home\components\HomeHeader\index.js

import React, { useState } from 'react';
import { BarsOutlined } from '@ant-design/icons';
import classnames from 'classnames';
import { Transition } from 'react-transition-group';
import logo from '@/assets/images/logo.png';
import './index.less';
const duration = 1000;
const defaultStyle = {
    transition: `opacity ${duration}ms ease-in-out`,
    opacity: 0,
}
const transitionStyles = {
    entering: { opacity: 1 },
    entered: { opacity: 1 },
    exiting: { opacity: 0 },
    exited: { opacity: 0 }
};
function HomeHeader(props) {
    let [isMenuVisible, setIsMenuVisible] = useState(false);
    const setCurrentCategory = (event) => {
        let { target } = event;
        let category = target.dataset.category;
        props.setCurrentCategory(category);
+       props.refreshLessons();
        setIsMenuVisible(false);
    }
    return (
        <header className="home-header">
            <div className="logo-header">
                <img src={logo} />
                <BarsOutlined onClick={() => setIsMenuVisible(!isMenuVisible)} />
            </div>
            <Transition in={isMenuVisible} timeout={duration}>
                {
                    (state) => (
                        <ul
                            className="category"
                            onClick={setCurrentCategory}
                            style={{
                                ...defaultStyle,
                                ...transitionStyles[state]
                            }}
                        >
                            <li data-category="all" className={classnames({ active: props.currentCategory === 'all' })}>全部课程</li>
                            <li data-category="react" className={classnames({ active: props.currentCategory === 'react' })}>React课程</li>
                            <li data-category="vue" className={classnames({ active: props.currentCategory === 'vue' })}>Vue课程</li>
                        </ul>
                    )
                }
            </Transition>
        </header>
    )
}
export default HomeHeader;

12. 虚拟列表 #

12.1 参考 #

12.1.1 目录结构 #

.
├── package.json
├── public
│   └── index.html
├── README.md
├── src
│   ├── api
│   │   ├── home.js
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   ├── utils.js
│   └── views
│       ├── Cart
│       │   └── index.js
│       ├── Detail
│       │   └── index.js
│       ├── Home
│       │   ├── components
│       │   │   ├── HomeHeader
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   ├── HomeSwiper
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   └── LessonList
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

12.2 Home\index.js #

src\views\Home\index.js

+import React, { useRef, useEffect } from "react";
import actionCreators from '@/store/actions/home';
import HomeHeader from './components/HomeHeader';
import { connect } from 'react-redux';
import HomeSwiper from "./components/HomeSwiper";
import LessonList from "./components/LessonList";
+import { loadMore, downRefresh, throttle } from "@/utils";
+import { DotLoading } from 'antd-mobile';
import './index.less';
function Home(props) {
    const homeContainerRef = useRef(null);
    const lessonListRef = useRef(null);
    useEffect(() => {
        loadMore(homeContainerRef.current, props.getLessons);
        downRefresh(homeContainerRef.current, props.refreshLessons);
+       homeContainerRef.current.addEventListener("scroll", throttle(lessonListRef.current, 13));
+       homeContainerRef.current.addEventListener('scroll', () => {
+           sessionStorage.setItem('scrollTop', homeContainerRef.current.scrollTop);
+       });
+       let scrollTop = sessionStorage.getItem('scrollTop');
+       if(scrollTop){
+           homeContainerRef.current.scrollTop = +scrollTop;
+           lessonListRef.current();
+       }
    }, []);
    return (
        <>
            <DotLoading />
            <HomeHeader
                currentCategory={props.currentCategory}
                setCurrentCategory={props.setCurrentCategory}
                refreshLessons={props.refreshLessons}
            />
            <div className="home-container" ref={homeContainerRef}>
                <HomeSwiper sliders={props.sliders} getSliders={props.getSliders} />
                <LessonList
                    container={homeContainerRef}
                    lessons={props.lessons}
                    getLessons={props.getLessons}
+                   ref={lessonListRef}
+                   homeContainerRef={homeContainerRef}
                />
            </div>
        </>
    )
}
let mapStateToProps = (state) => state.home;
export default connect(
    mapStateToProps,
    actionCreators
)(Home);

12.3 LessonList\index.js #

src\views\Home\components\LessonList\index.js

+import React, { useEffect, useReducer } from "react";
+import { Image, Button, NoticeBar, Card, Skeleton } from "antd-mobile";
import { Link } from "react-router-dom";
import { MenuOutlined } from "@ant-design/icons";
import "./index.less";

+function LessonList(props,lessonListRef) {
+ const [, forceUpdate] = useReducer(x => x + 1, 0);
  useEffect(() => {
    if (props.lessons.list.length == 0) {
      props.getLessons();
    }
+   lessonListRef.current = forceUpdate;
  }, []);
+ const remSize = parseFloat(document.documentElement.style.fontSize);
+ const itemSize = (650 / 75) * remSize;
+ const screenHeight = window.innerHeight - (222 / 75) * remSize;
+ const homeContainer = props.homeContainerRef.current;
+ let start = 0, end = 0;
+ const scrollTop = homeContainer?Math.max(homeContainer.scrollTop - ((320 + 65) / 75) * remSize,0):0;
+ start = Math.floor(scrollTop / itemSize);
+ end = start + Math.floor(screenHeight / itemSize);
+ start -= 2, end += 2;
+ start = start < 0 ? 0 : start;
+ end = end > props.lessons.list.length ? props.lessons.list.length : end;
+ const visibleList = props.lessons.list.map((item, index) => ({ ...item, index })).slice(start, end);
+ const style = { position: 'absolute', top: 0, left: 0, width: '100%', height: itemSize };
+ const bottomTop = (props.lessons.list.length) * itemSize;
  return (
    <section className="lesson-list">
      <h2>
        <MenuOutlined /> 全部课程
      </h2>
+      {visibleList.length > 0 ? (
+        <div style={{ position: 'relative', width: '100%', height: `${props.lessons.list.length * itemSize}px` }}>
+          {
+            visibleList.map((lesson) => (
+              <Link
+                style={{ ...style, top: `${itemSize * lesson.index}px`,backgroundColor: '#EEE' }}
+                key={lesson.id}
+                to={{ pathname: `/detail/${lesson.id}` }}
+                state={lesson}
+              >
+                <Card headerStyle={{ display: 'flex', justifyContent: 'center'}} title={lesson.title}>
+                  <Image src={lesson.poster} />
+                </Card>
+              </Link>
+            ))
+          }
+          {props.lessons.hasMore ? (
+            <Button
+              style={{ textAlign: "center",position: 'absolute', top: `${bottomTop}px` }}
+              onClick={props.getLessons}
+              loading={props.lessons.loading}
+              type="primary"
+              block
+            >
+              {props.lessons.loading ? "" : "加载更多"}
+            </Button>
+          ) : (
+            <NoticeBar style={{width:'100%',position: 'absolute', top: `${bottomTop}px`}} content='到底了' color='alert' />
+          )}
+        </div>
+      ) : (
+        <>
+          <Skeleton.Title animated />
+          <Skeleton.Paragraph lineCount={5} animated />
+        </>
+      )}
    </section>
  );
}
export default React.forwardRef(LessonList);

13. 路由懒加载 #

13.1 src\index.js #

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { Provider } from "react-redux";
+import {Mask} from 'antd-mobile';
import { store, history } from "./store";
import "./styles/global.less";
import Tabs from "./components/Tabs";
+const Home = React.lazy(() => import("./views/Home"));
+const Cart = React.lazy(() => import("./views/Cart"));
+const Profile = React.lazy(() => import("./views/Profile"));
+const Register = React.lazy(() => import("./views/Register"));
+const Login = React.lazy(() => import("./views/Login"));
+const Detail = React.lazy(() => import("./views/Detail"));
import { HistoryRouter } from "redux-first-history/rr6";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
        <HistoryRouter history={history}>
+            <React.Suspense fallback={<Mask visible={true} />}>
               <main className="main-container">
                 <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/cart" element={<Cart />} />
                    <Route path="/profile" element={<Profile />} />
                    <Route path="/register" element={<Register/>} />
          <Route path="/login" element={<Login/>} />
                    <Route path="/detail/:id" element={<Detail/>} />
                 </Routes> 
              </main>
+            </React.Suspense>
            <Tabs />
        </HistoryRouter>
    </Provider>
);

14. 购物车 #

14.1 参考 #

14.1.1 目录结构 #

.
├── package.json
├── public
│   └── index.html
├── README.md
├── src
│   ├── api
│   │   ├── home.js
│   │   ├── index.js
│   │   └── profile.js
│   ├── assets
│   │   └── images
│   │       └── logo.png
│   ├── components
│   │   ├── NavHeader
│   │   │   ├── index.js
│   │   │   └── index.less
│   │   └── Tabs
│   │       ├── index.js
│   │       └── index.less
│   ├── constants.js
│   ├── index.js
│   ├── store
│   │   ├── actions
│   │   │   ├── cart.js
│   │   │   ├── home.js
│   │   │   └── profile.js
│   │   ├── action-types.js
│   │   ├── history.js
│   │   ├── index.js
│   │   └── reducers
│   │       ├── cart.js
│   │       ├── home.js
│   │       ├── index.js
│   │       └── profile.js
│   ├── styles
│   │   └── global.less
│   ├── utils.js
│   └── views
│       ├── Cart
│       │   ├── index.js
│       │   └── index.less
│       ├── Detail
│       │   ├── index.js
│       │   └── index.less
│       ├── Home
│       │   ├── components
│       │   │   ├── HomeHeader
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   ├── HomeSwiper
│       │   │   │   ├── index.js
│       │   │   │   └── index.less
│       │   │   └── LessonList
│       │   │       ├── index.js
│       │   │       └── index.less
│       │   ├── index.js
│       │   └── index.less
│       ├── Login
│       │   ├── index.js
│       │   └── index.less
│       ├── Profile
│       │   ├── index.js
│       │   └── index.less
│       └── Register
│           ├── index.js
│           └── index.less
├── static
│   └── setRemUnit.js
└── webpack.config.js

14.2 src\index.js #

src\index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { Provider } from "react-redux";
import {Mask} from 'antd-mobile';
+import { store, persistor } from "./store";
import "./styles/global.less";
import Tabs from "./components/Tabs";
const Home = React.lazy(() => import("./views/Home"));
const Profile = React.lazy(() => import("./views/Profile"));
const Register = React.lazy(() => import("./views/Register"));
const Login = React.lazy(() => import("./views/Login"));
const Detail = React.lazy(() => import("./views/Detail"));
+const Cart = React.lazy(() => import("./views/Cart"));
import { HistoryRouter } from "redux-first-history/rr6";
+import { PersistGate } from 'redux-persist/integration/react'
+import {history} from "./store/history";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
+        <PersistGate loading={<Mask visible={true} />} persistor={persistor}>
+            <HistoryRouter history={history}>
+                <React.Suspense fallback={<Mask visible={true} />}>
+                <main className="main-container">
+                    <Routes>
+                        <Route path="/" element={<Home />} />
+                        <Route path="/cart" element={<Cart />} />
+                        <Route path="/profile" element={<Profile />} />
+                        <Route path="/register" element={<Register/>} />
+                        <Route path="/login" element={<Login/>} />
+                        <Route path="/detail/:id" element={<Detail/>} />
+                    </Routes> 
+                </main>
+                </React.Suspense>
+                <Tabs />
+            </HistoryRouter>
+        </PersistGate>
    </Provider>
);

14.3 Detail\index.js #

src\views\Detail\index.js

import React, { useState, useEffect } from "react";
import { connect } from "react-redux";
+import { Card, Image,Button } from "antd-mobile";
import NavHeader from "@/components/NavHeader";
import { getLesson } from "@/api/home";
+import actions from '@/store/actions/cart';
+import { useLocation, useParams } from 'react-router-dom';
+import './index.less';
function Detail(props) {
    const location = useLocation();
    const params = useParams();
    let [lesson, setLesson] = useState({});
    useEffect(() => {
        (async () => {
            let lesson = location.state;
            if (!lesson) {
                let result = await getLesson(params.id);
                if (result.success) lesson = result.data;
            }
            setLesson(lesson);
        })();
    }, []);
+   const addCartItem = (lesson) => {
+       let lessonImage = document.querySelector('.adm-image');
+       let cart = document.querySelector('.anticon.anticon-shopping-cart');
+       let clonedVideo = lessonImage.cloneNode(true);
+       let lessonImageWith = lessonImage.offsetWidth;
+       let lessonImageHeight = lessonImage.offsetHeight;
+       let cartWith = cart.offsetWidth;
+       let cartHeight = cart.offsetHeight;
+       let lessonImageLeft = lessonImage.getBoundingClientRect().left;
+       let lessonImageTop = lessonImage.getBoundingClientRect().top;
+       let cartRight = cart.getBoundingClientRect().right;
+       let cartBottom = cart.getBoundingClientRect().bottom;
+       clonedVideo.style.cssText = `
+           z-index: 1000;
+           opacity:0.8;
+           position:fixed;
+           width:${lessonImageWith}px;
+           height:${lessonImageHeight}px;
+           top:${lessonImageTop}px;
+           left:${lessonImageLeft}px;
+           transition: all 2s ease-in-out;
+       `;
+       document.body.appendChild(clonedVideo);
+       setTimeout(function () {
+           clonedVideo.style.left = (cartRight - (cartWith / 2)) + 'px';
+           clonedVideo.style.top = (cartBottom - (cartHeight / 2)) + 'px';
+           clonedVideo.style.width = `0px`;
+           clonedVideo.style.height = `0px`;
+           clonedVideo.style.opacity = '50';
+       }, 0);
+       props.addCartItem(lesson);
+   }
    return (
        <>
            <NavHeader>课程详情</NavHeader>
+           <Card headerStyle={{ display: 'flex', justifyContent: 'center' }} title={lesson.title} id="lesson-card">
                <Image src={lesson.poster} />
            </Card>
+           <Button
+               className="add-cart"
+               onClick={() => addCartItem(lesson)}
+           >加入购物车</Button>
        </>
    );
}
+let mapStateToProps = (state) => state;
+export default connect(
+      mapStateToProps,
+  actions
+)(Detail);

14.4 Detail\index.less #

src\views\Detail\index.less

.adm-image-img{
    height: 500px;
}

14.5 action-types.js #

src\store\action-types.js

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
export const LOGOUT = 'LOGOUT';
export const CHANGE_AVATAR = "CHANGE_AVATAR";
export const GET_SLIDERS = "GET_SLIDERS";
export const GET_LESSONS = "GET_LESSONS";
export const SET_LESSONS_LOADING = "SET_LESSONS_LOADING";
export const SET_LESSONS = "SET_LESSONS";
export const REFRESH_LESSONS = "REFRESH_LESSONS";
+export const ADD_CART_ITEM = 'ADD_CART_ITEM';//向购物车中增一个商品
+export const REMOVE_CART_ITEM = 'REMOVE_CART_ITEM';//从购物车中删除一个商品
+export const CLEAR_CART_ITEMS = 'CLEAR_CART_ITEMS';//清空购物车
+export const CHANGE_CART_ITEM_COUNT = 'CHANGE_CART_ITEM_COUNT';//直接修改购物车商品的数量减1
+export const CHANGE_CHECKED_CART_ITEMS = 'CHANGE_CHECKED_CART_ITEMS';//选中商品
+export const SETTLE = 'SETTLE';//结算

14.6 cart.js #

src\store\reducers\cart.js

import * as actionTypes from "@/store/action-types";
let initialState = [];
export default function (state = initialState,action) {
+ switch (action.type) {
+   case actionTypes.ADD_CART_ITEM:
+     let oldIndex = state.findIndex(
+       (item) => item.lesson.id === action.payload.id
+     );
+     if (oldIndex == -1) {
+       state.push({
+         checked: false,
+         count: 1,
+         lesson: action.payload,
+       });
+     } else {
+       state[oldIndex].count +=1;
+     }
+     break;
+   case actionTypes.REMOVE_CART_ITEM:
+     let removeIndex = state.findIndex(
+       (item) => item.lesson.id === action.payload
+     );
+     state.splice(removeIndex,1);
+     break;
+   case actionTypes.CLEAR_CART_ITEMS:
+     state.length = 0;
+     break;
+   case actionTypes.CHANGE_CART_ITEM_COUNT:
+     let index = state.findIndex(
+       (item) => item.lesson.id === action.payload.id
+     );
+     state[index].count=action.payload.count;
+     break;
+   case actionTypes.CHANGE_CHECKED_CART_ITEMS:
+     let checkedIds = action.payload;
+     state.forEach((item)=>{
+       if(checkedIds.includes(item.lesson.id)){
+         item.checked =true;
+       }else{
+         item.checked =false;
+       }
+     });
+     break;
+   case actionTypes.SETTLE:
+     state = state.filter((item) => !item.checked);
+     break;
    default:
      break;
  }
    return state;
}

14.7 reducers\index.js #

src\store\reducers\index.js

import { routerReducer } from '../history';
import home from './home';
import cart from './cart';
import profile from './profile';
+import { combineReducers } from 'redux-immer';
+import {produce} from 'immer';
+const rootReducer = combineReducers(produce,{
    router:routerReducer,
    home,
    cart,
    profile
});
export default rootReducer;

14.8 store\index.js #

src\store\index.js

import { legacy_createStore as createStore, applyMiddleware } from 'redux';
import reducers from './reducers';
import logger from 'redux-logger';
import thunk from 'redux-thunk';
import promise from 'redux-promise';
+import { persistStore, persistReducer } from 'redux-persist';
+import storage from 'redux-persist/lib/storage';
+import { routerMiddleware } from './history';
+const persistConfig = {
+  key: 'root',
+  storage,
+  whitelist: ['cart']
+}
+const persistedReducer = persistReducer(persistConfig, reducers)
+const store = applyMiddleware(thunk, routerMiddleware, promise, logger)(createStore)(persistedReducer);
+let persistor = persistStore(store);
+export { store, persistor };

14.9 history.js #

src\store\history.js

import { createBrowserHistory } from 'history';
import { createReduxHistoryContext } from "redux-first-history";
+export const history = createBrowserHistory();
const { routerReducer, routerMiddleware, createReduxHistory } = createReduxHistoryContext({ history });
export {
    routerReducer,
    routerMiddleware,
    createReduxHistory
}

14.10 actions\cart.js #

src\store\actions\cart.js

import * as actionTypes from "../action-types";
import { Toast } from "antd-mobile";
import { push } from "redux-first-history";
export default {
  addCartItem(lesson) {
    return function (dispatch) {
      dispatch({
        type: actionTypes.ADD_CART_ITEM,
        payload: lesson,
      });
      Toast.show("添加课程成功", 1);
    };
  },
  removeCartItem(id) {
    return {
      type: actionTypes.REMOVE_CART_ITEM,
      payload: id,
    };
  },
  clearCartItems() {
    return {
      type: actionTypes.CLEAR_CART_ITEMS,
    };
  },
  changeCartItemCount(id, count) {
    return {
      type: actionTypes.CHANGE_CART_ITEM_COUNT,
      payload: {
        id,
        count,
      },
    };
  },
  changeCheckedCartItems(checkedIds) {
    return {
      type: actionTypes.CHANGE_CHECKED_CART_ITEMS,
      payload: checkedIds,
    };
  },
  settle() {
    return function (dispatch) {
      dispatch({
        type: actionTypes.SETTLE,
      });
      dispatch(push("/"));
    };
  },
};

14.11 Cart\index.js #

src\views\Cart\index.js

+import React, { useState,useRef } from "react";
+import { connect } from "react-redux";
+import { Button, Input, SwipeAction, Modal, Grid, Space, Checkbox, List,Dialog } from "antd-mobile";
+import NavHeader from "@/components/NavHeader";
+import actions from "@/store/actions/cart";
+import './index.less';
+function Cart(props) {
+    const confirmSettle = () => {
+        Modal.confirm({
+            content: '请问你是否要结算',
+            onConfirm: () => {
+                props.settle();
+            },
+        })
+    };
+    let totalCount = props.cart
+        .filter((item) => item.checked)
+        .reduce((total, item) => total + item.count, 0);
+    let totalPrice = props.cart
+        .filter((item) => item.checked)
+        .reduce(
+            (total, item) =>
+                total + parseFloat(item.lesson.price.replace(/[^0-9\.]/g, "")) * item.count,
+            0
+        );
+    return (
+        <div style={{ padding: '2px' }}>
+            <NavHeader>购物车</NavHeader>
+            <CarrItems cart={props.cart} changeCartItemCount={props.changeCartItemCount} removeCartItem={props.removeCartItem} changeCheckedCartItems={props.changeCheckedCartItems} />
+            <Grid columns={15} gap={8}>
+                <Grid.Item span={3}>
+                    <Button
+                        type="warning"
+                        size="small"
+                        onClick={props.clearCartItems}
+                    >清空</Button>
+                </Grid.Item>
+                <Grid.Item span={5}>
+                    已选择{totalCount}件商品
+                </Grid.Item>
+                <Grid.Item span={4}>¥{totalPrice}元</Grid.Item>
+                <Grid.Item span={3}>
+                    <Button type="primary" size="small" onClick={confirmSettle}>结算</Button>
+                </Grid.Item>
+            </Grid>
+        </div>
+    );
+}
+const CarrItems = ({ cart, changeCartItemCount, removeCartItem, changeCheckedCartItems }) => {
+    const [value, setValue] = useState([])
+    const swipeActionRef = useRef(null)
+    return (
+        <Space direction='vertical'>
+            <Checkbox
+                indeterminate={value.length > 0 && value.length < cart.length}
+                checked={value.length === cart.length}
+                onChange={checked => {
+                    let newValue;
+                    if (checked) {
+                        newValue = cart.map(item => item.lesson.id);
+                    } else {
+                        newValue = [];
+                    }
+                    setValue(newValue)
+                    changeCheckedCartItems(newValue);
+                }}
+            >全选</Checkbox>
+            <Checkbox.Group
+                value={value}
+                onChange={v => {
+                    setValue(v)
+                    changeCheckedCartItems(v);
+                }}
+            >
+                <Space direction='vertical'>
+                    <List>
+                        {cart.map(item => (
+                                <List.Item>
+                                    <SwipeAction 
+                                     ref={swipeActionRef}
+                                     closeOnAction={false}
+                                     closeOnTouchOutside={false}
+                                      rightActions={[
+                                        {
+                                            key: 'remove',
+                                            text: '删除',
+                                            color: 'red',
+                                            onClick: async (value) => {
+                                                console.log('value',value)
+                                                const result = await Dialog.confirm({
+                                                    content: '确定要删除吗?',
+                                                })
+                                                if(result){
+                                                    removeCartItem(item.lesson.id);
+                                                }
+                                                swipeActionRef.current?.close()
+                                            },
+                                        }
+                                    ]}>
+                                    <Grid columns={12} gap={8}>
+                                        <Grid.Item span={1}>
+                                            <Checkbox value={item.lesson.id} checked={item.checked}></Checkbox>
+                                        </Grid.Item>
+                                        <Grid.Item span={6}>
+                                            {item.lesson.title}
+                                        </Grid.Item>
+                                        <Grid.Item span={2}>
+                                            ¥{item.lesson.price}
+                                        </Grid.Item>
+                                        <Grid.Item span={3}>
+                                            <Input
+                                                value={item.count}
+                                                onChange={val => {
+                                                    changeCartItemCount(item.lesson.id, Number(val))
+                                                }}
+                                            />
+                                        </Grid.Item>
+                                    </Grid>
+                                    </SwipeAction>
+                                </List.Item>
+                        ))}
+                    </List>
+                </Space>
+            </Checkbox.Group>
+        </Space>
+    )
+}
+let mapStateToProps = (state) => state;
+export default connect(mapStateToProps, actions)(Cart);

14.12 Cart\index.less #

src\views\Cart\index.less

.adm-grid{
    display: grid;
    align-items: center;
    height:64px;
}
.adm-grid-item{
    font-size:32px;
    line-height: 32px;
}

15 移动端适配 #

15.1 像素 #

当我们讨论显示技术,尤其是移动和高分辨率屏幕时,经常会碰到"物理像素"和"逻辑像素"这两个术语。为了更好地理解这两者,我们可以从以下几个方面来探讨它们:

15.1.1. 物理像素 (Physical Pixels): #

15.1.2. 逻辑像素 (Logical Pixels): #

15.1.3. 为什么这两者都重要? #

总结:物理像素与逻辑像素之间的区别基本上是硬件与软件、实际与抽象之间的区别。理解这两者的关系对于在多种屏幕尺寸和分辨率上创建优雅、响应式和高清的界面至关重要。

15.2 移动端适配REM #

在移动端开发中,适配是一个非常重要的问题。由于各种设备的屏幕尺寸和分辨率都不同,为了保证用户在任何设备上都能获得一致的、高质量的用户体验,开发者需要实现页面的适配。其中,REM是一种流行的适配方案。

15.2.1. REM是什么? #

REM 是 "Root EM" 的简写。它是一个相对单位,类似于CSS中的EM,但它总是相对于根元素<html>的字体大小,而不是相对于父元素的字体大小。

15.2.2. 为什么使用REM进行适配? #

使用REM进行适配的好处是,你只需要设置根元素<html>的字体大小,然后页面上的其他元素可以通过相对于这个根字体大小的REM单位来设置它们的大小。当根元素的字体大小发生变化时,所有使用REM作为单位的元素都会相应地改变大小。

15.2.3. 如何实现REM适配? #

16 immer #

immer 是一个用于处理 JavaScript 中的不可变状态更新的库。使用它,你可以用看起来似乎是直接修改对象和数组的方式来编写不可变的代码,但实际上并没有修改原始数据。这是通过使用代理来完成的,这些代理捕获所有的修改,并在结束时返回一个新的不可变对象,这个对象包含所有的修改。

为什么使用 immer? #

在应用程序的状态管理中(例如 React + Redux),通常要求更新状态时不直接修改状态,而是创建新的状态对象。immer 提供了一种简洁的方法来满足这一要求。

如何使用? #

安装:

npm install immer

基础示例:

import produce from 'immer';

const initialState = {
  name: "John",
  age: 25,
  address: {
    city: "New York",
    country: "USA"
  }
};

const newState = produce(initialState, draft => {
  draft.age = 26;
  draft.address.city = "Los Angeles";
});

console.log(initialState.address.city); // New York
console.log(newState.address.city); // Los Angeles

在上述示例中,我们可以看到,虽然我们直接修改了 draft 对象,但原始的 initialState 并没有被更改。produce 返回了一个新的对象,其中包含了我们所做的所有更改。

使用在 React 中: #

假设我们在一个 React 组件的状态中使用 immer

import React, { useState } from 'react';
import produce from 'immer';

function App() {
  const [state, setState] = useState({
    counter: 0,
    user: {
      name: "John"
    }
  });

  const increment = () => {
    setState(produce(draft => {
      draft.counter++;
    }));
  };

  const changeName = newName => {
    setState(produce(draft => {
      draft.user.name = newName;
    }));
  };

  return (
    <div>
      <p>Counter: {state.counter}</p>
      <p>Name: {state.user.name}</p>
      <button onClick={increment}>Increment Counter</button>
      <button onClick={() => changeName("Jane")}>Change Name</button>
    </div>
  );
}

export default App;

在这个 React 示例中,我们使用 immerproduce 函数直接更新组件的状态,这使得代码更为简洁,易于理解。

注意:使用 immer 时,确保不要在 produce 函数外部修改 draft 对象,这可能会导致不可预期的结果。

17 redux-immer #

当我们谈论 redux-immer 时,我们实际上是在讨论如何使用 immer 来简化 Redux 中的不可变性操作。immer 是一个让不可变操作变得简单和更可读的库,它允许你写可变的代码,然后把这些代码转换为不可变操作。

什么是 Immer? #

在 Redux 中,我们通常需要创建新的状态对象来表示任何状态的变化,这样可以确保状态是不可变的。这种操作可能会变得复杂,特别是当我们处理嵌套的状态结构时。immer 提供了一个更简洁的方法来处理这种不可变操作。

immer 的核心是 produce 函数,它接受两个参数:当前状态和一个 "草稿" 函数。在草稿函数中,你可以写可变的代码,然后 immer 会为你生成一个新的不可变状态。

如何使用 redux-immer? #

首先,确保安装了必要的库:

npm install redux-immer immer

接下来,我们将创建一个简单的 Redux store,并使用 redux-immerimmer

  1. 配置 Redux Store

在你的 Redux 配置中,使用 redux-immerproduce 替代 reduxcombineReducers

import { createStore } from 'redux';
import produce from 'immer';
import createReducer from 'redux-immer';

// 你的 reducer
const initialState = { counter: 0 };

function counterReducer(state = initialState, action) {
  return produce(state, draft => {
    switch (action.type) {
      case 'INCREMENT':
        draft.counter++;
        break;
      case 'DECREMENT':
        draft.counter--;
        break;
      default:
        break;
    }
  });
}

const rootReducer = createReducer({
  counter: counterReducer
});

const store = createStore(rootReducer);
  1. 使用 Redux Store

现在,我们可以像通常那样使用 Redux store。

store.subscribe(() => {
  console.log(store.getState());
});

store.dispatch({ type: 'INCREMENT' }); // 输出: { counter: 1 }
store.dispatch({ type: 'INCREMENT' }); // 输出: { counter: 2 }
store.dispatch({ type: 'DECREMENT' }); // 输出: { counter: 1 }

注意,你的 reducer 中的代码看起来像是在直接修改状态,但实际上,由于 immerproduce 函数,这些改变会产生一个新的状态,而不是修改原始状态。

总结 #

redux-immerimmer 为 Redux 提供了一个简洁、易于理解的方式来处理不可变性。你可以编写直观的可变代码,并依赖 immer 在背后为你处理不可变操作。这使得你的 reducer 代码更加简洁和可读。

18 redux-persisit #

Redux Persist 是一个用于在 Redux 存储中持久化部分或全部状态的库。当你刷新页面或者重新加载应用时,它能够帮助你保持 Redux 中的状态。这是通过将状态保存在本地存储或其他存储机制中,并在应用启动时恢复这些状态来实现的。

安装 #

首先,需要安装 redux-persist@reduxjs/toolkit(如果你还没有的话):

npm install redux-persist @reduxjs/toolkit react-redux

使用示例 #

  1. 配置

首先,你需要将 persistReducerpersistStore 引入你的 Redux store。

import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';

import rootReducer from './reducers';

const persistConfig = {
  key: 'root',
  storage,
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
});

export const persistor = persistStore(store);

在这里,我们使用了默认的 storage (即本地存储)。redux-persist 还支持其他存储机制。

  1. 在应用中应用持久化

在你的主要应用组件中,使用 PersistGate 包装你的主要应用内容。

import React from 'react';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';

import { store, persistor } from './store';
import AppContent from './AppContent';

function App() {
  return (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <AppContent />
      </PersistGate>
    </Provider>
  );
}

export default App;

在这里,AppContent 是你的应用的主要内容。PersistGate 延迟了应用内容的渲染,直到状态从存储中恢复。

  1. 示例 reducer

为了完整性,这是一个简单的 rootReducer 示例。

import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: state => state + 1,
    decrement: state => state - 1,
  },
});

export const { increment, decrement } = counterSlice.actions;

const rootReducer = {
  counter: counterSlice.reducer,
};

export default rootReducer;

以上就是一个基本的使用 redux-persist 的例子。需要注意的是,如果你的应用有特定的数据安全或隐私要求,你可能需要进一步配置 redux-persist,例如使用加密或选择不持久化某些特定的状态片段。