3. zhuzhuketang-hook-express-front

180 阅读10分钟
  • README.md

1. 项目介绍

  • 使用 react 全家桶 + express 全家桶开发一个全栈项目

前端:

  • react redux react-redux react-router-dom react-router
  • redux redux-promise redux-thunk redux-logger connected-react-router
  • 安装依赖
    • npm i react react-dom @types/react @types/react-dom react-router-dom @types/react-router-dom react-transition-group @types/react-transition-group react-swipe @types/react-swipe antd qs @types/qs -S
    • npm i webpack@4 webpack-cli@3 webpack-dev-server html-webpack-plugin@4 -D
    • npm i typescript ts-loader source-map-loader style-loader css-loader less-loader less url-loader file-loader -D
    • npm i redux react-redux @types/react-redux redux-thunk redux-logger @types/redux-logger redux-promise @types/redux-promise -S
    • npm i connected-react-router -S
  • 支持 typescript
    • 生成 tscconfig.json 告诉 ts-loader 怎么编译 typescript 代码
    • tsc --init
    • ts 中有多种 import 方式,分别对应 js 中不同 的 export // import * as xx from "xx" commonjs 模块 import xx from 'xx' 标准 es6 模块
  • 编写 webpack 配置文件

前端注意点

  • require/import引入图片
/*
如果在js中引入本地静态资源图片时使用import img from './img/bd_logo1.png'这种写法是没有问题的,
但是在typscript中是无法识别非代码资源的,所以会报错TS2307: cannot find module '.png'。因此,
我们需要主动的去声明这个module。新建一个ts声明文件如:images.d.ts(如下)就可以了。这样ts就可以
识别svg、png、jpg等等图片类型文件。项目编译过程中会自动去读取.d.ts这种类型的文件,所以不需要我们
手动地加载他们。当然.d.ts文件也不能随便放置在项目中,这类文件和ts文件一样需要被typescript编译,
所以一样只能放置在tsconfig.json中include属性所配置的文件夹下。
*/

前端相关优化

  • 滚动到底部加载防抖 下拉刷新节流
  • 在下拉的过程中也可以下拉,也就是在回弹的过程中也可以下拉
  • 可以只渲染可是区域内的卡片,如果卡片不在可是区域内,不渲染。但是为了滚动条正常,需要放一张假的卡片撑高
  • 实现骨架屏
  • 路由切回来的时候要滚动到上次的位置

后端

  • express mongoose
  • npm i express mongoose body-parser bcryptjs jsonwebtoken morgan cors validator helmet dotenv multer http-status-codes -S
  • npm i typescript @types/node @types/express @types/mongoose @types/bcryptjs @types/jsonwebtoken @types/morgan @types/cors @types/validator ts-node-dev nodemon @types/helmet @types/multer -D
  • 初始化 tsconfig.json
    • npx tsconfig.json 是一个命令行工具 通过他生成配置文件 生成更符合的配置文件 可以选择是 node/react/rn 项目
  • script 要想启一个 ts 版本的后台服务有两种方式:
    • "start": "ts-node-dev --respawn src/index.ts" ts-node-dev 用 ts-node 启动开发环境,当源码发生改变的时候可以自动重启
    • "dev": "nodemon --exec ts-node --files src/index.ts" nodemon 默认不支持 ts 要用 ts-node 执行 node 脚本
    • 这两种方式都可以,任意选择
  • .env 文件
    • 敏感信息不要提交到 github,应放到环境变量配置文件,忽略提交
    • .env.example 可以提交到 github

后端相关软件

  • mongodb 安装步骤
    1. 在官网下载安装包 路径:官网首页-software - enterprise server - download
    2. 将解压后的文件放入 /usr/local ,默认情况下在 Finder 中是看不到 /usr 这个目录的(终端用得溜的请略过),可以打开 Finder 后按 shift + command +G 输入 /usr/local 后回车便能看到这个隐藏的目录了 (解压后的文件重命名为 MongoDB,和下面命令保持一致)
    3. 配置环境变量,打开终端,输入“open -e .bash_profile”,在打开的文件中加入 export PATH=${PATH}:/usr/local/MongoDB/bin
    4. 用 Command+S 保存配置,关闭上面的.bash_profile 编辑窗口,然后在终端输入"source .bash_profile"使配置生效。输入"mongod -version",回车后如果看到下面的版本号则说明 MongoDB 已经成功安装到了 Mac 上
    5. 创建一个文件夹,用于作为数据库(安装 MongoDB 时并不会自动生成,需要我们手动创建,可以在命令行输入创建,也可以直接在 Finder 中手动新建)我创建在/usr/local/MongoDB/bin/data/db
  • 运行
    1. mongod --dbpath=/usr/local/MongoDB/bin/data/db 启动 MongoDB 数据库(bin 目录下的 mongod.exe 程序就是用于启动 MongoDB 的)
  • 可视化工具 robo 3T robomongo.org/download => click download Robo3T only
  • postman 接口测试工具 官网下载后可以直接使用 www.postman.com/downloads/

后端使用到的各种 npm 插件

  • express 启动服务
  • mongoose 链接数据库
  • cors 用来跨域的中间件
  • morgan 用来输入访问日志
  • helmet 用来做安全过滤,比如一些攻击性脚本,xss
  • multer 用于上传文件呢
  • "dotenv/config" 这个包的作用是读取.env 文件然后写入 process.env 环境变量,可以通过 process.env.变量名拿到.env 文件中定义了的值
  • http-status-codes 包含各种状态码
  • jsonwebtoken 生成和验证 token
  • bcryptjs 对用户密码进行加密,以及验证用户密码是否匹配存在数据库中的密码

个人中心接口

  1. 注册
  2. 登录
  3. token 验证
  • 每一类接口处理函数放在一个 controller 文件里面进行统一管理, 个人中心的三个接口处理函数就都放在 controllers/user.ts
  • 数据库相关的操作全放在 models/user.ts 中 涉及到 mongoose 的使用:包含 Schema/model,以及创建 model 的实例进行数据存储
  • 在 schema 上除了定义数据字段具体内容以外,还可以给 model 模型扩展方法;给模型实例扩展方法;在 options 中设置对数据进行处理的方法(toJSON)
  • 方法实际涉及到:
    • 在每次保存注册信息之前执行的操作 通过 model 模型 new 出来的文档实例在调用 save 方法进行保存数据之前对密码进行加密操作 UserSchema.pre('save', async function(next: HookNextFunction){} 加密之后调用 next 方法向下继续执行保存工作
    • 给 model 模型扩展方法,叫 login,用于在登录的时候校验输入的密码是否匹配数据库存入的 UserSchema.static('login', async function(this:any,username:string, password:string):Promise<UserDocument | null> {} 返回 user | null
    • 给模型实例扩展方法 生成 token 因为要获取实例的具体属性 所以方法放实例上 UserSchema.methods.getAccessToken = function(this: UserDocument) :string {}

前端文件

默认样式

image.png

tsconfig.json

{
    "compilerOptions": {
        "outDir": "./dist", // 输出目录 tsc
        "sourceMap": true, // 是否要生成sourcemap文件
        "noImplicitAny": true, // 是否不允许出现隐含的any类型
        "module": "commonjs", // 模块规范, 要把所有的模块编译成什么模块
        "target": "es5", // 编译的目标是es5
        "jsx": "react", // 如何编译jsx语法
        "esModuleInterop": true, // 允许在commonjs模块和esmodule进行转换
        "baseUrl": ".", // 查找非相对路径的模块的起始位置
        "paths": {  // 配置baseurl和path之后 @符就可以正常使用了
            "@/*":["src/*"]
        }
    },
    "include": [
        "./src/**/*" // 只编译src目录下面的ts文件
    ]
}

webpack.config.js

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
const tsImportPluginFactory = require("ts-import-plugin");

/*
antd的按需加载:
以前用babel-plugin-import可以实现antd的按需加载,但是要和babel-loader配合使用
在ts-loader中是不行的
在ts-loader中:可以使用 ts-import-plugin
*/
module.exports = {
    mode: process.env.NODE_ENV == "production"? "production" : "development",
    entry: "./src/index.tsx",
    output: {
        path: path.resolve(__dirname, "dist"),
        filename: "bundle.js"
    },
    // devtool: "source-map",
    devServer: { // 开发服务器的配置
        hot: true,
        port: 8080,
        progress: true, // 展示打包进度条
        contentBase: "./dist",
        historyApiFallback: { // browserHistory的时候,刷新会报404,自动重定向到index.html
            index: "./index.html"
        },
    },
    resolve: {
        alias: {
            "@": path.resolve(__dirname, "src"),
            "~": path.resolve(__dirname, "node_modules")
        },
        extensions: [".tsx", ".ts", ".js", ".json"],
        modules: [path.resolve(__dirname, "node_modules")]
    },
    module: {
        rules: [
            {
                test: /\.(j|t)sx?$/,
                use:[
                    // {
                    //     loader: "thread-loader",
                    //     options: {workers:3} // 进程数量
                    // },
                    // {
                    //     loader: "babel-loader",
                    //     options: { 
                    //         cacheDirectory: true,
                    //         options: {
                    //             presets: [
                    //                 "@babel/preset-env",
                    //                 "@babel/preset-react"
                    //             ]
                    //         }
                    //     }
                    // },
                    {
                        loader: "ts-loader",
                        options: {
                            transpileOnly: true, // 只转译不检查 提高效率
                            getCustomTransformers: () => { // 获取或者说定义自定义转换器
                                before: [ tsImportPluginFactory( [{
                                    libraryName:'antd', //  对哪个模块进行按需加载
                                    libraryDirectory:'es', // 按需加载的模块,  如果要实现按需加载,必须是es module
                                    style:'css', // 自动引入对应的css
                                }]) ]
                            }
                        } // 样式不见了, 原因不知道???????????????
                    }
                ],
                exclude: /node_modules/,
            },
            // {
            //     enforce: "pre",
            //     test: /\.(j|t)sx?$/,
            //     loader:  "source-map-loader" // 生成sourcemap文件的
            // },
            {
                test:  /\.css$/,
                use:  ['style-loader', {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 0
                    }
                }, {
                    loader: "postcss-loader",
                    options: {
                        plugins: [
                            require("autoprefixer")
                        ]
                    }
                }, {
                    loader: "px2rem-loader",
                    options: {
                        remUnit: 75,
                        remPrecesion:8
                    }
                }]
            },
            {
                test:  /\.less$/,
                use:  ['style-loader', {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 0
                    }
                }, {
                    loader: "postcss-loader",
                    options: {
                        postcssOptions: {
                            plugins: [
                              [
                                "autoprefixer",
                              ],
                            ],
                        },
                    }
                }, {
                    loader: "px2rem-loader",
                    options: {
                        // 750px => 10rem    200px => 200/75 = 2.66667rem
                        remUnit: 75, // 1rem单位是75px
                        remPrecesion:8
                    }
                }, "less-loader"]
            },
            {
                test: /\.(jpg|png|gif|svg|jpeg)$/,
                use: ["url-loader"]
            }
        ]
    },
    plugins:  [
        new HtmlWebpackPlugin({
            template: "./src/index.html"
        }),
        // 热更新插件
        new webpack.HotModuleReplacementPlugin(),
    ]

}

index.html

  • 动态设置根元素大小
<body>
    <script>
        // 设置根元素的大小
        (function(){
            function refreshRem() {
                // 如果屏幕宽度是750px, 1rem = 100px
                // document.documentElement.style.fontSize = document.documentElement.clientWidth / 750 * 100 + "px";
                document.documentElement.style.fontSize = document.documentElement.clientWidth / 10 + "px"; // 使用px2rem时
            }
            refreshRem();
            window.addEventListener("resize",  refreshRem, false);
        })()
    </script>
    <div id="root"></div>
</body>

实现路由 index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Switch, Route, Redirect} from "react-router-dom";
import { Provider } from 'react-redux';
import store from "./store";

// 实现多语言配置
import { ConfigProvider} from 'antd';
import zh_CN from 'antd/lib/locale-provider/zh_CN';

import "./assets/style/common.less";
// 三个路由组件,一个组件对应一个页面 页面组件
import Home from './routes/Home'; 
import Mine from './routes/Mine'; 
import Profile from './routes/Profile'; 
import Register from './routes/Register';
import Login from './routes/Login';
import Detail from './routes/Detail';
import Cart from './routes/Cart';

// 路由库
import { ConnectedRouter } from 'connected-react-router';
import Tabs from './components/tabs/index';

import history from "@/history";
/*
import {createHashHistory} from "history";

let history = createHashHistory();
export default history;
*/

ReactDOM.render(<Provider store={store}>
    <ConnectedRouter  history={history}>
        <ConfigProvider locale={zh_CN}>
            <main className="main-container">
                <Switch>
                    <Route path="/" exact component={Home}/>
                    <Route path="/mine" exact component={Mine}/>
                    <Route path="/profile" exact component={Profile}/>
                    <Route path="/register" exact component={Register}/>
                    <Route path="/login" exact component={Login}/>
                    <Route path="/detail/:id" exact component={Detail}/>
                    <Route path="/cart" exact component={Cart}/>
                </Switch>
            </main>
            <Tabs/>
        </ConfigProvider>
    </ConnectedRouter>
</Provider>, document.getElementById("root"))

创建仓库

image.png

src/store/action-types.tsx

export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';

export const VALIDATE = 'VALIDATE';
export const LOGOUT  =  'LOGOUT';
export const SET_AVATAR = 'SET_AVATAR';
export const GET_SLIDERS =  'GET_SLIDERS';
export const GET_LESSONS = 'GET_LESSONS'; // 调接口返回课程数据
export const SET_LESSONS = 'SET_LESSONS';  // 将返回的课程数据同步到仓库
export const SET_LESSON_LODING = 'SET_LESSON_LODING'; // 修改课程加载的loading
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_ITEM = 'CLEAR_CART_ITEM';
export const CHANGE_CART_ITEM_COUNT = 'CHANGE_CART_ITEM_COUNT';
export const CHANGE_CHECKED_CART_ITEMS = 'CHANGE_CHECKED_CART_ITEMS';
export const SETTLE = 'SETTLE';  // 结算的时候会把选中的商品添加到订单里,从购物车删除

src/store/index.tsx

import {createStore, applyMiddleware, Dispatch} from 'redux';
import logger from 'redux-logger';
import promise from 'redux-promise';
import thunk from 'redux-thunk';
import { routerMiddleware } from 'connected-react-router';
import history from '@/history';
import rootReducer  from './reducers/index';
import { ICombineState } from '@/typings/state';



// promise和thunk都是中间件
// promise可以让我们派发promise, thunk让我们派发函数
let store = applyMiddleware(routerMiddleware(history), promise, thunk, logger)(createStore)(rootReducer);


export type StoreDispatch = Dispatch;
export type StoreGetState = () => ICombineState;
export default store;

reducers: src/store/reducers

  • index.tsx
    • 使用 combinReducers合并所有的分reducer
    • ICombineState 是声明的根state的类型
// index.tsx
import {
    AnyAction, 
    // combineReducers, 
    ReducersMapObject
} from "redux";
import { connectRouter} from 'connected-react-router';
import home from "./home";
import mine from './mine';
import profile from './profile';
import cart from './cart';
import history from '@/history';
import { Reducer } from "react";
import {ICombineState} from '@/typings/state';
import produce from 'immer';
import {  combineReducers } from 'redux-immer'; // 引入immer要使用redux-immer中的CombineReducers
 
let reducers:ReducersMapObject< ICombineState,  AnyAction> = {
    home,
    mine,
    profile,
    cart,
    router: connectRouter(history)
}
// const rootReducer:Reducer<ICombineState,AnyAction> = combineReducers<ICombineState>(reducers);
const rootReducer:Reducer<ICombineState,AnyAction> = combineReducers(produce, reducers);


export default rootReducer;
  • cart.tsx
import { CartState, CartItem } from "@/typings/cart";
import { AnyAction } from "redux";
import  * as actionTypes from '../action-types';

let initialState: CartState =  [];
export default function(state: CartState = initialState, action: AnyAction): CartState {
    switch(action.type){
        // 添加一个条目 如果已经有了,数量加一,如果没有,加一个条目
        case actionTypes.ADD_CART_ITEM: // {payload: lesson}
            let oldlesson = state.find(item => item.lesson.id === action.payload.id);
            if(oldlesson) {
                oldlesson.count ++;
            }else{
                state.push({
                    count: 1,
                    checked: false,
                    lesson: action.payload
                });
            }
            return state;
        case actionTypes.REMOVE_CART_ITEM: // {payload: id}
            let removeIndex = state.findIndex(item => item.lesson.id === action.payload);
            if(removeIndex !== -1) {
                state.splice(removeIndex, 1);
            }
            return state;
        case actionTypes.CLEAR_CART_ITEM:
            return [];
        case actionTypes.CHANGE_CART_ITEM_COUNT:// {payload: {count, id}}
            let changelesson = state.find(item => item.lesson.id === action.payload.id);
            if(changelesson) {
                changelesson.count = action.payload.count;
            }
            return state;
        case actionTypes.CHANGE_CHECKED_CART_ITEMS:// {payload: [1,2,3]}
            let checkedIds = action.payload;
            return state.map((item: CartItem) => {
                if(checkedIds.includes(item.lesson.id)) {
                    item.checked = true;
                }else{
                    item.checked = false;
                }
                return item;
            })
        case actionTypes.SETTLE:
            return state.filter((item: CartItem) => !item.checked)
        default:
            return state;
    }
}
  • home.tsx
    • home状态的类型是 IHomeState
import { AnyAction } from 'redux';
import { IHomeState} from '@/typings/state';
import * as actionTypes from '../action-types';
const initialState: IHomeState = {
    currentCategory: "all", //homeheader中选中的类目
    sliders: [],
    lessons:  {
        loading: false,
        list: [],
        hasMore: true,
        offset: 0,
        limit: 5
    }

}
export default function(state:IHomeState = initialState, action:AnyAction):IHomeState {
    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:
            state.lessons.loading = false;
            state.lessons.list = [...state.lessons.list, ...action.payload.list];
            state.lessons.hasMore = action.payload.hasMore;
            state.lessons.offset =  state.lessons.offset  + action.payload.list.length;
            return state;
            // return {
            //     ...state,
            //     lessons: {
            //         ...state.lessons,
            //         loading: false,
            //         list: [...state.lessons.list, ...action.payload.list],
            //         hasMore: action.payload.hasMore,
            //         offset: state.lessons.offset  + action.payload.list.length,
            //     }
            // }
        case actionTypes.SET_LESSON_LODING:
            // 引入immer了,所以可以这样改 
            state.lessons.loading = action.payload;
            return state;
        case actionTypes.REFRESH_LESSONS:
            state.lessons.loading = false;
            state.lessons.list = action.payload.list;
            state.lessons.hasMore = action.payload.hasMore;
            state.lessons.offset =  action.payload.list.length;
            return state;
        default:
            return state;
    }
}
  • mine.tsx
import { AnyAction } from 'redux';
import { IMineState} from '@/typings/state';
const initialState: IMineState = {

}
export default function(state:IMineState = initialState, action:AnyAction):IMineState {
    return state;
}
  • profile.tsx
import { AnyAction } from 'redux';
import { IProfileState, LOGIN_TYPES} from '@/typings/state';
import * as ActionTypes from '@/store/action-types';

const initialState: IProfileState = {
    loginState: LOGIN_TYPES.UN_VALIDATE,
    user: null,
    error: null,
}
export default function(state:IProfileState = initialState, action:AnyAction):IProfileState {
    switch(action.type) {
        case ActionTypes.VALIDATE:
            if(action.payload.success) {
                return {
                    loginState:  LOGIN_TYPES.LOGINED,
                    user:action.payload.data,
                    error: null
                }
            }else{
                return {
                    loginState:  LOGIN_TYPES.UNLOGINED,
                    user:null,
                    error: action.payload
                }
            }
        case ActionTypes.LOGOUT:
            return {
                loginState:  LOGIN_TYPES.UNLOGINED,
                user:null,
                error: null
            }
        case ActionTypes.SET_AVATAR:
            return {
                ...state,
                user: {
                    ...state.user,
                    avatar: action.payload
                }
            }
        default:
            return state;
    }
}

公共组件

  • src/components/Nav
  • src/components/Tabs

Tabs组件 -- 作为底部导航

  • NavLink实现路由跳转
  • flex布局三个图标
import React from 'react';
import { NavLink, withRouter } from "react-router-dom";
import {
    ShoppingCartOutlined,
    HomeOutlined,
    UserOutlined
  } from '@ant-design/icons';
import "./index.less";

interface IProps {

}
function Tabs(_props:IProps) {
    return (
        <footer>
            <NavLink exact to="/"><HomeOutlined /><span>首页</span></NavLink>
            <NavLink to="/cart" id="shopcart"><ShoppingCartOutlined /><span>购物车</span></NavLink>
            <NavLink to="/profile"><UserOutlined /><span>个人中心</span></NavLink>
        </footer>
    )
}
export default Tabs;
footer {
  position: fixed;
  left: 0;
  bottom: 0;
  height: 120px;
  width: 100%;
  z-index: 1000;
  background-color: #fff;
  border-top: 0.02rem solid #d5d5d5;
  display: flex;
  justify-content: center;
  align-items: center;
  a {
    display: flex;
    flex: 1;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    color: #000;
    span:first-child {
      font-size: 0.5rem;
    }
    span:last-child {
      font-size: 0.3rem;
      line-height: 0.5rem;
    }
    &.active {
      color: orange;
      font-weight: bold;
    }
  }
}

Nav组件

import React, {PropsWithChildren} from 'react';
import { LeftOutlined } from '@ant-design/icons';
import {History} from 'history';
import "./index.less";

type Props = PropsWithChildren<{history: History}>
function Nav(props: Props) {
    return (
        <header className="nav-header">
            <LeftOutlined onClick={() => props.history.goBack()} className="icon-nav"/>
            {props.children}
        </header>
    )
}
export default Nav;
.nav-header {
  position: fixed;
  left: 0;
  top: 0;
  height: 1rem;
  z-index: 1000;
  width: 100%;
  box-sizing: border-box;
  text-align: center;
  line-height: 1rem;
  background-color: #2a2a2a;
  color: #fff;
  .icon-nav {
    position: absolute;
    left: 0.2rem;
    line-height: 1rem;
  }
}

首页 HOME

  • src/routes/Home

image.png

index

  • Home组件是路由渲染出来的 属性中包含路由属性
  • 需要连接仓库
    • mapStateToProps 传入根状态 返回分状态
    • 传入home组件的props属性的类型包含:路由相关 / mapStateToProps返回的分状态的类型 / mapDispatchToProps类型,还有children属性类型 type IProps = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
import React, {PropsWithChildren, useEffect, useLayoutEffect, useRef} from 'react';
import { connect} from 'react-redux';
import {RouteComponentProps} from 'react-router-dom';
import "./index.less";
import HomeHeader from './components/HomeHeader';
import {ICombineState, IHomeState} from '@/typings/state';
import mapDispatchToProps from '@/store/actions/home';
import HomeSliders from './components/HomeSliders/index';
import LessonList from './components/LessonList/index';
import { loadMore, downRefresh } from '@/utils';
import { Spin } from 'antd';

// 因为home组件是路由组件,所以属性对象会包含路由属性,另外home组件还需要连接仓库
// props声明: 三部曲
// 路由属性 mapStateToProps函数的返回对象属性  mapDispatchToProps对象里属性
// PropsWithChildren 加入children属性  type PropsWithChildren<P> = P & { children?: ReactNode }
type IProps = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
function Home(props: IProps) {
    let homeContainerRef = useRef<HTMLDivElement>(null);
    let lessonListRef = useRef(null);
    
    // 监听滚动事件 下拉加载
    useEffect(()  => {
        let func = loadMore(homeContainerRef.current, props.getLessons);
        downRefresh(homeContainerRef.current,props.refreshLessons);
        homeContainerRef.current.addEventListener("scroll", lessonListRef.current);

        if(props.lessons.list.length > 0) { // 说明之前看过这个页面
            homeContainerRef.current.scrollTop = parseFloat(localStorage.getItem("homeScrollTop")) || 500;
        }

        return () => {
            // 子元素上绑定的事件是不用清理吗?homeContainerRef.current都为null了??????????????
            // homeContainerRef.current.removeEventListener("scroll", func);
            // homeContainerRef.current.removeEventListener("scroll", lessonListRef.current);

            // 在组件销毁的时候, 将当前滚动了的高度存储到localstorage
            // localStorage.setItem("homeScrollTop", homeContainerRef.current.scrollTop + '');
        };
    }, [])
    useLayoutEffect(()  =>  {
        return ()  =>  {
            console.log(homeContainerRef.current.scrollTop,'========')
            localStorage.setItem("homeScrollTop", homeContainerRef.current.scrollTop + '');
        }
    },[])
    return (
        <div>
            <HomeHeader
                currentCategory = {props.currentCategory}
                setCurrentCategory = {props.setCurrentCategory}
                refreshLessons = {props.refreshLessons}
            />
            <div className="refresh-loading">
                <Spin size="large"/>
            </div>
            <div className="home-container" ref={homeContainerRef}>
                <HomeSliders
                    sliders={props.sliders}
                    getSliders={props.getSliders}
                />
                <LessonList
                    ref = {lessonListRef} // 函数组件不能传递ref需要用forwardRef转换
                    getLessons={props.getLessons}
                    lessons={props.lessons}
                    currentCategory = {props.currentCategory}
                    homeContainerRef = {homeContainerRef}
                />
            </div>
        </div>
    )
}

const mapStateToProps = (state: ICombineState):IHomeState => {
    return {
        ...state.home,
        ...state.profile
    }
}

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Home);
.home-container {
  position: fixed;
  top: 100px;
  width: 100%;
  overflow-y: auto;
  height: calc(100vh - 220px);
  z-index: 200;
}
.refresh-loading {
  position: fixed;
  top: 100px;
  text-align: center;
  padding: 20px;
  width: 100%;
  z-index: 0;
}

Home内部组件

HomeHeader

  • 切换类目的点击事件
    • 给外面的ul绑定,而不是给每一个li绑定
    • 给每一li设置了一个自定义属性 data-category
    • 点击每一个li的时候,event.target指向当前的li
    • 通过自定义属性获取当前li代表的类目
import React, { CSSProperties, useState } from 'react';
import { BarsOutlined} from '@ant-design/icons';
import { Transition } from 'react-transition-group';
import classnames from 'classnames';
import "./index.less";
// 如果用require加载,返回值的default属性才是图片地址
// let logo = require('../../../../assets/images/logo.jpeg');
// 如果要用import:
/*
如果在js中引入本地静态资源图片时使用import img from './img/bd_logo1.png'这种写法是没有问题的,
但是在typscript中是无法识别非代码资源的,所以会报错TS2307: cannot find module '.png'。因此,
我们需要主动的去声明这个module。新建一个ts声明文件如:images.d.ts(如下)就可以了。这样ts就可以
识别svg、png、jpg等等图片类型文件。项目编译过程中会自动去读取.d.ts这种类型的文件,所以不需要我们
手动地加载他们。当然.d.ts文件也不能随便放置在项目中,这类文件和ts文件一样需要被typescript编译,
所以一样只能放置在tsconfig.json中include属性所配置的文件夹下。
*/
import logo from '@/assets/images/logo.jpeg';

const duration =1000; // 动画的持续时间
// CSSProperties其实就是行内样式的对象定义
const defaultStyle:CSSProperties= {
    transition: `all ${duration} ease-in-out`,
    opacity: 0,
}
interface TransitionStyles {
    entering: CSSProperties;
    entered: CSSProperties;
    exiting: CSSProperties;
    exited: CSSProperties;
    unmounted: CSSProperties;
}
// 不同阶段 不同样式
const transitionStyles:TransitionStyles = {
    entering: {opacity: 1},
    entered: {opacity: 1},
    exiting: {opacity: 0, display:"none"},
    exited: {opacity: 0,  display:"none"},
    unmounted: {opacity:  0 , display:"none"}
}
interface IProps {
    currentCategory: string; // 当前选中的分类 放在redux仓库
    setCurrentCategory: (currentCategory: string) => any; // 改变仓库中的选中的分类
    refreshLessons: Function
}

function HomeHeader(props: IProps) {
    // 是否显示菜单
    let [isMenuVisible, setMenuVisible] = useState(false);
    const setCurrentCategory = (event:React.MouseEvent<HTMLUListElement>) => {
        let target:HTMLUListElement =  event.target as HTMLUListElement;
        console.log(target, 'target')
        let category = target.dataset.category;
        props.setCurrentCategory(category);
        props.refreshLessons();
        setMenuVisible(false); // 隐藏列表
    }
    return (
        <header className="home-header">
            <div className="logo-header">
                {/* <img src={logo.default}/> */}
                <img src={logo}/>
                <BarsOutlined className="icon-header" onClick={() => setMenuVisible(!isMenuVisible)}/>
            </div>
            <Transition in={isMenuVisible} timeout={duration}>
                {
                    (state: keyof TransitionStyles) => (
                        <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;

/*
动画是怎么实现的:
动态的给一个元素增加和删除类名,不同的类名对应不同的样式
另外再加上一个transition效果 就可以了
eact-transition-group 看不出动画效果啊


react-motion

*/
@BG: #2a2a2a;
.home-header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 999;
  .logo-header {
    height: 100px;
    background-color: @BG;
    color: #fff;
    display: flex;
    justify-content: space-between;
    align-items: center;
    img {
      width: 200px;
      //   width: 2rem;
      margin-left: 0.2rem;
      height: 100%;
    }
    .icon-header {
      font-size: 0.6rem;
      margin-right: 0.2rem;
    }
  }
  .category {
    position: absolute;
    width: 100%;
    top: 1rem;
    left: 0;
    padding: 0.1rem 0.5rem;
    background-color: @BG;
    li {
      line-height: 0.6rem;
      text-align: center;
      color: #fff;
      font-size: 0.3rem;
      border-top: 0.02rem solid lighten(@BG, 20%);
      &.active {
        color: olivedrab;
      }
    }
  }
}

类型文件

  • src/typings

image.png

// index.tsx
export * from './profile';
export * from './state';
export * from './slider';
export * from './lesson';
// images.d.ts
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'
declare module '*.tiff'
// cart.tsx
import { ILesson } from './lesson';

export interface CartItem {
    lesson: ILesson,
    count: number,
    checked: boolean
}

export type CartState = CartItem[];
// lesson.tsx
export interface ILesson {
    id: string;
    order: number;
    title: string; // 标题
    video: string; // 视频地址
    poster: string; // 海报地址
    url: string; // url地址
    price: string; // 价格
    category: string; // 分类
}

export interface LessonData {
    success: boolean;
    data: {
        list:ILesson[];
        hasMore: boolean;
    }
}

export interface GetLessonData {
    success: boolean;
    data: ILesson
}
// profile.tsx
export interface RegisterPayload {
    username: string;
    password: string;
    confirmPassword: string;
    email: string;
}
export interface LoginPayload {
    username: string;
    password: string;
}
// slider.tsx
export interface ISLider {
    _id: string;
    url: string;
}

export interface sliderData {
    success: boolean;
    data: ISLider[];
}
// state.tsx

import { RouterState } from 'connected-react-router';
import { CartState } from './cart';
import { ILesson } from './lesson';
import { ISLider } from './slider';

export interface Lessons {
    loading: boolean;
    list: ILesson[];
    hasMore: boolean;
    offset: number;
    limit: number;
}

export interface IHomeState {
    currentCategory: string;
    sliders: ISLider[];
    lessons: Lessons
}
export interface IMineState {

}
interface IUser {
    id?: string;
    username: string;
    email: string;
    avatar: string;
}
export enum LOGIN_TYPES {
    UN_VALIDATE = 'UN_VALIDATE', //尚未验证登录状态
    LOGINED = 'LOGINED', // 已经登录
    UNLOGINED = 'UNLOGINED' // 没有登录
}
export interface IProfileState {
    loginState: LOGIN_TYPES;
    user: IUser|null;
    error: string | null;
}

// 构建combineState根状态 
export interface ICombineState {
    home: IHomeState,
    mine: IMineState,
    profile: IProfileState,
    router: RouterState,
    cart: CartState
}


个人中心组件 Profile

  • 判断登录状态 useEffect 只判断一次
import React, {PropsWithChildren, useEffect, useState} from 'react';
import {connect} from 'react-redux';
import { ICombineState, IProfileState} from '@/typings/state';
import { RouteComponentProps } from 'react-router-dom';
import mapDispatchToProps from '@/store/actions/profile';
import { LOGIN_TYPES, } from '@/typings/state';
import Nav from '@/components/Nav/index';
import "./index.less";
import { Alert, Button, Descriptions, Upload, message } from 'antd';
import { RcFile, UploadChangeParam } from 'antd/lib/upload';
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';

type IProps = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;

function Profile(props: IProps) {
    let [uploading, setUploading] = useState(false);
    // 验证是否登录 - 副作用 - useeffect - 只调用一次 - []
    useEffect(() => {
        // 发请求 /user/validate
        props.validate();
    }, []);
    console.log(props,  '=============')
    let content;
    if(props.loginState  === LOGIN_TYPES.UN_VALIDATE) {
        content = null;
    }else if(props.loginState === LOGIN_TYPES.LOGINED) {
        const uploadButton = (
            <div>
                {uploading ? <LoadingOutlined /> : <PlusOutlined />}
                <div className="ant-upload-text">上传</div>
            </div>
        );
        // 在上传过程中会不停触发handlechange事件
        const handleChange = (info: UploadChangeParam) => {
            if(info.file.status === 'uploading')  {
                setUploading(true);
            }else if(info.file.status === 'done') {
                // response就是上传接口返回的相应体
                let { success, data } = info.file.response;
                if(success) {
                    setUploading(false);
                    props.setAvatar(data);
                }else {
                    message.error("上传头像失败")
                }
            }
        }
        content = (
            <div className="user-info">
                <Descriptions title="当前用户">
                    <Descriptions.Item label="用户名">{props.user.username}</Descriptions.Item>
                    <Descriptions.Item label="邮箱">{props.user.email}</Descriptions.Item>
                    <Descriptions.Item label="头像">
                        <Upload
                            name="avatar" // 往服务端上传头像的时候应该用那个字段名上传
                            listType="picture-card"
                            className="avatar-uploader"
                            showUploadList={false}
                            action="http://localhost:8001/user/uploadAvatar"
                            beforeUpload={beforeUpload}
                            data={{ userId: props.user.id }}
                            onChange={handleChange}
                        >
                            {
                                props.user.avatar ? <img src={props.user.avatar} style={{width: '100%'}}/> : uploadButton
                            }
                        </Upload>
                    </Descriptions.Item>
                </Descriptions>
                <Button type="primary" danger  onClick={props.logout}>退出</Button>
            </div>
        )
    }else {
        content = (
            <>
                <Alert type="warning" message='未登录' description='尚未登录,请注册或者登录'/>
                <div style={{textAlign:"center", padding:".5rem"}}>
                    <Button type="dashed"  onClick={()=> props.history.push("/login")}>登录</Button>
                    <Button type="dashed"  onClick={()=> props.history.push("/register")}>注册</Button>
                </div>
            </>
        )
    }
    return (
        <section>
            <Nav history={props.history}>个人中心</Nav>
            {content}
        </section>
    )
}

const mapStateToProps = (state: ICombineState):IProfileState => state.profile;
export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Profile);


// 上传图片前的判断
function beforeUpload(file:RcFile) {
    const isJpgOrPng = file.type === 'image/jpg' || file.type === 'image/png';
    if(!isJpgOrPng) {
        message.error('你只能上传JPG/PNG文件');
    }
    const isLessThan2M = file.size / 1024 / 1024 < 2;
    if(!isLessThan2M) {
        message.error('图片必须小于2M');
    }
    return isJpgOrPng && isLessThan2M; // 返回true才执行上传
}

登陆组件 Login

import React, {PropsWithChildren} from 'react';
import { Button, Form, Input } from 'antd';
import { connect } from 'react-redux';
import { ICombineState, IProfileState } from '@/typings/state';
import mapDispatchToProps from '@/store/actions/profile';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom';
import { RegisterPayload } from '@/typings/profile';
import Nav from '@/components/Nav/index';
import "./index.less";

type Props = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;




function Login(props: Props) {
    const [form] = Form.useForm();
    const onFinish = (values: RegisterPayload) => {
        console.log('Received values of form: ', values);
        // 调接口进行注册
        props.login(values)
    };
    const formItemLayout = {
        labelCol: {
          xs: { span: 24 },
          sm: { span: 8 },
        },
        wrapperCol: {
          xs: { span: 24 },
          sm: { span: 16 },
        },
    };
    const tailFormItemLayout = {
        wrapperCol: {
          xs: {
            span: 24,
            offset: 0,
          },
          sm: {
            span: 16,
            offset: 8,
          },
        },
    };
    return (
        <>
            <Nav history={props.history}>登录</Nav>
            <Form
                {...formItemLayout}
                form={form}
                name="login"
                onFinish={onFinish}
                initialValues={{
                    residence: ['zhejiang', 'hangzhou', 'xihu'],
                    prefix: '86',
                }}
                scrollToFirstError
                className="login-form"
            >
                <Form.Item
                    name="username"
                    label="用户名"
                    rules={[
                        {
                            required: true,
                            message: '用户名不能为空',
                        },
                    ]}
                >
                    <Input/>
                </Form.Item>
                <Form.Item
                    name="password"
                    label="密码"
                    rules={[
                        {
                            required: true,
                            message: '密码不能为空',
                        },
                    ]}
                >
                    <Input/>
                </Form.Item>
                <Form.Item {...tailFormItemLayout}>
                    <Button type="primary" htmlType="submit">
                    登录
                    </Button> 或者 <Link to="/register">注册</Link>
                </Form.Item>
            </Form>
        </>
    )
}

let mapStateToProps = (state:ICombineState):IProfileState => state.profile;
export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Login);

注册组件 Register.ts

import React, {PropsWithChildren} from 'react';
import { Button, Form, Input } from 'antd';
import { connect } from 'react-redux';
import { ICombineState, IProfileState } from '@/typings/state';
import mapDispatchToProps from '@/store/actions/profile';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom';
import { RegisterPayload } from '@/typings/profile';
import Nav from '@/components/Nav/index';

import "./index.less";

type Props = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;




function Register(props: Props) {
    const [form] = Form.useForm();
    const onFinish = (values: RegisterPayload) => {
        console.log('Received values of form: ', values);
        // 调接口进行注册
        props.register(values)
    };
    const formItemLayout = {
        labelCol: {
          xs: { span: 24 },
          sm: { span: 8 },
        },
        wrapperCol: {
          xs: { span: 24 },
          sm: { span: 16 },
        },
    };
    const tailFormItemLayout = {
        wrapperCol: {
          xs: {
            span: 24,
            offset: 0,
          },
          sm: {
            span: 16,
            offset: 8,
          },
        },
    };
    return (
        <>
            <Nav history={props.history}>注册</Nav>
            <Form
                {...formItemLayout}
                form={form}
                name="register"
                onFinish={onFinish}
                initialValues={{
                    residence: ['zhejiang', 'hangzhou', 'xihu'],
                    prefix: '86',
                }}
                scrollToFirstError
                className="register-form"
            >
                <Form.Item
                    name="username"
                    label="用户名"
                    rules={[
                        {
                            required: true,
                            message: '用户名不能为空',
                        },
                    ]}
                >
                    <Input/>
                </Form.Item>
                <Form.Item
                    name="password"
                    label="密码"
                    rules={[
                        {
                            required: true,
                            message: '密码不能为空',
                        },
                    ]}
                >
                    <Input/>
                </Form.Item>
                <Form.Item
                    name="confirmPassword"
                    label="确认密码"
                    dependencies={['password']}
                    rules={[
                        {
                            required: true,
                            message: '确认密码不能为空',
                        },
                        ({ getFieldValue }) => ({
                            validator(_, value) {
                              if (!value || getFieldValue('password') === value) {
                                return Promise.resolve();
                              }
                              return Promise.reject(new Error('密码不一致'));
                            },
                        }),
                    ]}
                >
                    <Input.Password/>
                </Form.Item>
                <Form.Item
                    name="email"
                    label="邮箱"
                    rules={[
                        {
                            required: true,
                            message: '邮箱不能为空',
                        },
                        {
                            type: 'email',
                            message: '邮箱格式有误',
                        },
                    ]}
                >
                    <Input.Password/>
                </Form.Item>
                <Form.Item {...tailFormItemLayout}>
                    <Button type="primary" htmlType="submit">
                    注册
                    </Button> 或者 <Link to="/login">登录</Link>
                </Form.Item>
            </Form>
        </>
    )
}

let mapStateToProps = (state:ICombineState):IProfileState => state.profile;
export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Register);

详情页组件 Detail

import Nav from '@/components/Nav';
import { GetLessonData, ICombineState, ILesson } from '@/typings';
import { Button, Card } from 'antd';
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { StaticContext } from 'react-router';
import { getLesson} from '@/api/home';
import { ShoppingCartOutlined } from '@ant-design/icons';
import { connect } from 'react-redux';
import actions from '@/store/actions/cart';

interface Params {
    id: string;
}

type Props =  PropsWithChildren<RouteComponentProps<Params, StaticContext, ILesson> & ICombineState  & typeof actions>

function Detail(props: Props) {
    let  [lesson, setLesson] = useState<ILesson>({} as ILesson);
    useEffect(() => { // 一般在useeffect中调用异步方法时:要么用promise要么用IIFE
        (async function(){
            let lesson =  props.location.state;

            // 如果是从课程列表页面跳转过来的,是有state; 如果是刷新的,就没有state
            if(!lesson) { // 如果没有lesson,调接口,参数是课程id
                let result:GetLessonData = await getLesson<GetLessonData>(props.match.params.id);
                if(result.success) {
                    lesson = result.data;
                }
            };
            setLesson(lesson);
        })()
    }, []);
    const addCartItem = (lesson:ILesson) => {
        // 加入购物车动画效果实现
        let cover:HTMLDivElement = document.querySelector(".ant-card-cover");
        let coverLeft = cover.getBoundingClientRect().left;
        let coverTop = cover.getBoundingClientRect().top;
        let coverWidth = cover.offsetWidth;
        let coverHeight = cover.offsetHeight;

        let cart:HTMLElement = document.querySelector("#shopcart>span");
        console.log(cart,"////////////////////")
        let cartWidth = cart.offsetWidth;
        let cartHeight = cart.offsetHeight;
        let cartRight = cart.getBoundingClientRect().right;
        let cartBottom = cart.getBoundingClientRect().bottom;

        let cloneCover:HTMLDivElement = cover.cloneNode(true) as HTMLDivElement;

        cloneCover.style.cssText = (
            `
                z-index: 1000;
                opacity: 0.8;
                position: fixed;
                width: ${coverWidth}px;
                height: ${coverHeight}px;
                left:  ${coverLeft}px;
                top: ${coverTop}px;
                transition: all 2s ease-in-out;

            `
        );
        document.body.appendChild(cloneCover);

        setTimeout(() =>   {
            cloneCover.style.left = `${cartRight - cartWidth / 2}px`;
            cloneCover.style.top = `${cartBottom - cartHeight / 2}px`;
            cloneCover.style.width = '0px';
            cloneCover.style.height = '0px';
            cloneCover.style.opacity = '.5';
        }, 0)
        setTimeout(() => {
            cloneCover.parentNode.removeChild(cloneCover);
        },2000)
        props.addCartItem(lesson);
    }
    return (
        <>
            <Nav history={props.history}>课程详情</Nav>
            <Card
                hoverable
                style={{width: "100%"}}
                cover={<img src={lesson.poster}/>}
            >
                <Card.Meta
                    title={lesson.title}
                    description={
                        <>
                            <p>{lesson.poster}</p>
                            <p>
                                <Button 
                                    className="add-cart"
                                    icon={<ShoppingCartOutlined />}
                                    onClick={() => addCartItem(lesson)}
                                >加入购物车</Button>
                            </p>
                        </>
                    }
                />
            </Card>
        </>
    )
}

export default connect(
    (state: ICombineState): ICombineState => state,
    actions
)(Detail);

购物车组件 Cart.tsx

import { ICombineState, ILesson } from '@/typings';
import { CartItem, CartState } from '@/typings/cart';
import React, { PropsWithChildren } from 'react';
import { connect } from 'react-redux';
import { RouteComponentProps } from 'react-router';
import mapDispatchToProps from '@/store/actions/cart';
import { Button, Col, InputNumber, Popconfirm, Row, Table } from 'antd';
import Nav from '@/components/Nav';


type Props = PropsWithChildren<RouteComponentProps & typeof mapDispatchToProps & ReturnType<typeof mapStateToProps>>;
function Cart(props: Props) {
    const column  =  [
        {
            title: '商品',
            dataIndex: 'lesson',
            render: (val:ILesson, row: CartItem) => {
                return (
                    <>
                        <p>{val.title}</p>
                        <p>{val.price}</p>
                    </>
                )
            }
        },
        {
            title: '数量',
            dataIndex: 'count',
            render: (val:number, row: CartItem) => {
                return (
                    <InputNumber
                        size="small"
                        min={1}
                        value={val}
                        onChange={(value) => props.changeCartItemCount(row.lesson.id, value)}
                    />
                )
            }
        },
        {
            title: '操作',
            render: (val:number, row: CartItem) => {
                return (
                    <Popconfirm
                        title="是否要删除商品"
                        onConfirm={() =>  props.removeCartItem(row.lesson.id)}
                        okText = "是"
                        cancelText = "否"
                    >
                        <Button size="small" type="primary">删除</Button>
                    </Popconfirm>
                )
            }
        }
    ];
    const rowSelection = {
        selectedRowKeys: props.cart.filter((item:CartItem) => item.checked).map((item:CartItem) => item.lesson.id),
        onChange: (selectedRowKeys: string[]) => {
            props.changeCheckedCartItems(selectedRowKeys);
        }
    }
    let totalCount = props.cart.filter((item:CartItem) => item.checked).reduce((total:number, item:CartItem) => total + item.count,  0);
    let totalPrice = props.cart.filter((item:CartItem) => item.checked).reduce((total:number, item:CartItem) => total + item.count * parseFloat(item.lesson.price.slice(1)),  0);
    
    return (
        <>
            <Nav history={props.history}>购物车</Nav>
            <Table
                columns = {column}
                dataSource = {props.cart}
                pagination={false}
                rowSelection = {rowSelection}
                rowKey={row => row.lesson.id}
            ></Table>
            <Row style={{padding :"5px"}}>
                <Col span={4}><Button type="dashed" size="small" onClick={props.clearCartItems}>清空</Button></Col>
                <Col span={7}>{`以选择了${totalCount}商品`}</Col>
                <Col span={9}>{`总价${totalPrice}元`}</Col>
                <Col span={4}><Button type="primary" size="small" onClick={props.settle}>结算</Button></Col>
            </Row>
        </>
    )
}


let mapStateToProps = (state: ICombineState): {cart: CartState}  => ({cart : state.cart});
export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Cart);

src/api文件夹

home.tsx

import request from './index';
import { sliderData } from '../typings/index';
export function getSliders() {
    return request.get<sliderData, sliderData>('/slider/list');
}


/**
 * @param currentCategory 要获取那个分类下面的课程列表 all全部 react vue
 * @param offset 从那个索引开始获取
 * @param limit 限定要返回的条目数
*/
export function getLessons<T>(
    offset:number,
    limit: number,
    currentCategory: string = 'all',
) {
    return request.get<T,T>(`/lesson/list?category=${currentCategory}&offset=${offset}&limit=${limit}`)
}

// 根据id获取课程
export function getLesson<T>(
    id: string
) {
    return request.get<T,T>(`/lesson/${id}`)
}

index.ts

import axios, { AxiosRequestConfig } from 'axios';
axios.defaults.baseURL = "http://localhost:8001";
axios.defaults.headers.post['Content-Type'] = "application/json;charset=UTF-8";

// token传递给服务器:写拦截器:发送请求时往请求头中放入token
axios.interceptors.request.use((config:AxiosRequestConfig) => {
    let access_token = sessionStorage.getItem("access_token");
    if (access_token)
    config.headers["Authorization"] = `Bearer ${access_token}`;
    return config;
}, (error: any) => Promise.reject(error));

// 请求时的拦截器
axios.interceptors.response.use(response => response.data, error => Promise.reject(error));

export default axios;

profile.ts

import request from './index';
import { RegisterPayload, LoginPayload } from '@/typings/profile';


export function validate() {
    return request.get("/user/validate");
}
export function register<T>(values: RegisterPayload) {
    return request.post<T,T>("/user/register", values);
}
export function login<T>(values: LoginPayload) {
    return request.post<T,T>("/user/login", values);
}