要恋爱, 好歹开个小鹏吧 (React hooks 全栈上新)

1,562 阅读13分钟

项目演示:

项目线上地址:

有感兴趣的小伙伴可以点开gitee看看源码:gitee.com/jhqaq/car--…

效果演示

Record_2022-02-17-19-22-20.gif

Record_2022-02-17-19-23-29.gif

前言:

经过一段时间 React 的学习,想要查看自己React的掌握能力,决定仿照小鹏汽车微信小程序,自己用react仿写。其中踩过了不少坑,也碰到了一些难点,希望通过本篇文章带个一些新学React或想拿react做项目的小伙伴门一些帮助,本文重点讲解redux的实际使用,如果对redux还不太属性的小伙伴不妨好好看看本篇文章,也欢迎一些大佬对本文所出现的问题进行指正

观前提示

因为篇幅原因,本文列举出来的大多数代码只是提取核心代码讲解,并非一个js文件的完整代码,若对代码感兴趣的小伙伴可以查看源码

项目简介

  • 技术栈:React Hooks + Redux + Koa
  • 使用React Hooks 对前端页面的编写,Koa 进行后台的搭建,Redux进行数据流管理,全项目使用 styled-components 样式组件进行样式编写,React-Router V6进行路由配置编写,可能会涉及一些新特性
  • 项目架构是学习神三元老师的掘金小册React Hooks 与 Immutable 数据流实战来架构的,并且使用了神三元老师的baseUI和api的组件。

项目结构

├─ server                   // 后端 
    ├─Data                  // 数据
    index.js
├─ src
    ├─api                   // 网路请求代码、工具类函数和相关配置
    ├─assets                // 字体配置及全局样式
    ├─baseUI                // 基础 UI 轮子
    ├─components            // 可复用的 UI 组件
    ├─layouts               // 布局
    ├─pages                 // 页面
    ├─routes                // 路由配置文件
    └─store                 // redux 相关文件
      App.jsx               // 根组件
      main.jsx              // 入口文件

前端部分

路由配置

本项目使用 react-router v6 对路由进行配置。
并用useRoutes代替了以前 v5 的react-router-config。
若对useRoutes等新特性不熟悉的可以去此处链接了解一下。

配置ALLRoutes

  • 在routes路由配置界面 routes/index.js代码如下:
import React, { lazy, Suspense } from 'react';
import HomeLayout from '../layouts/HomeLayout';
import NotFound from '../layouts/NotFound';
import { Navigate } from 'react-router-dom';

const SuspenseComponent = (Component) => (props) => {
  return (
    <Suspense fallback={null}>
      <Component {...props}></Component>
    </Suspense>
  )
}

const Buycar = lazy(() => import("../pages/buycar"));
const Home = lazy(() => import("../pages/home"))
const My = lazy(() => import("../pages/my"))
const Find = lazy(() => import("../pages/find"))
const TryCar = lazy(() => import("../pages/trycar"))
const FindDetail = lazy(() => import("../pages/find/findDetail"))
const HomeDetail = lazy(() => import("../pages/home/detail"))
const Shopcar = lazy(() => import("../components/main/shopcar/Shopcar"))

export default [
  {
    path: "/",
    element: <HomeLayout />,
    children: [
      {
        path: "/",
        element: <Navigate to="/home" />
      },
      {
        path: "/home",
        element: <Suspense fallback={null}><Home></Home></Suspense>,
        children: [
          {
            path: ":id",
            element: <Suspense fallback={null} ><HomeDetail></HomeDetail></Suspense>
          },
          {
            path: 'shopcar',
            element: <Suspense fallback={null} ><Shopcar></Shopcar></Suspense>
          }
        ]
      },
      {
        path: "/buycar",
        element: <Suspense fallback={null}><Buycar></Buycar></Suspense>
      },
      {
        path: "/my",
        element: <Suspense fallback={null}><My></My></Suspense>
      },
      {
        path: "/find",
        element: <Suspense fallback={null}><Find></Find></Suspense>,
        children: [
          {
            path: ":id",
            element: <Suspense fallback={null} ><FindDetail></FindDetail></Suspense>
          }
        ]
      },
      {
        path: "/try",
        element: <Suspense fallback={null}><TryCar></TryCar></Suspense>
      },
      {
        path: "*",
        element: <NotFound />
      }
    ]
  }
];

首先得引入组件,然后通过SuspenseComponent进行封装,将引入的组件放入SuspenseComponent组件中,从而实现懒加载,避免程序一次性加载单页应用的所有的组件,而造成长时间无反应的情况。

在App.jsx中引入

  • app.jsx 代码如下:
import React from 'react';
import { useRoutes } from 'react-router';
import ALLRoutes from './routers/index'
import { Provider } from "react-redux"
import store from './store';
import { GlobalStyle } from './style';
function App() {
  let routes = useRoutes(ALLRoutes)
  return (
    <Provider store={store}>
      <GlobalStyle ></GlobalStyle>
      <div>
        {
          routes
        }
      </div>
    </Provider>
  )
}


export default App

导入react-router中的useRouter和上文代码的routes/index.js代码,并调用useRouter,实现路由的设置。 使用useRouter来代替传统代码一个个手写路由有以下俩个优势:

  • 使得App.jsx这个主页面代码显得简洁,因为配置路由在routes界面,因此会让App.jsx显得很简洁。
  • 会使路由配置变的更方便,变简洁。直接以数组的方式,完成了路由配置,通过useRoute使得路由有效。

redux 数据流管理

在我们使用redux管理数据前,我希望可以通过一张图来让一些新手更好理解redux。

image.png

详细介绍可以观看阮一峰老师的博客 Redux 入门教程

建立store中心仓库

store/index.js 代码如下:

import thunk from 'redux-thunk';
import { createStore, compose, applyMiddleware } from 'redux';
import reducer from "./reducer";

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

配置介绍:

  • redux-thunk 可以实现redux中异步action。
  • applyMiddleware 是允许redux加应用中间件,从而使得redux-thunk生效。
  • const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;会使得Redux插件生效,进行一些redux仓库的调试。从而在开发的过程中通过Redux插件来观察redux仓库中的状态的变化,以及做一些调试。

store/reducer.js 代码如下:

import { combineReducers } from 'redux';
import { reducer as homeReducer } from '../pages/home/store/index'
import { reducer as activeReducer } from "../layouts/store/index"
import { reducer as trycarReducer } from "../pages/trycar/store/index"
import { reducer as buycarReducer } from "../pages/buycar/store/index"
import { reducer as myReducer } from "../pages/my/store/index"
import { reducer as findReducer } from '../pages/find/store/index';

export default combineReducers({
    home: homeReducer,
    active: activeReducer,
    trycar: trycarReducer,
    buycar: buycarReducer,
    my: myReducer,
    find: findReducer
});

这是合并分支redux的方法,这些引入的reducer都是我们一个page页面级别组件的一个store仓库,也是分支。 最后通过export default combineReducers({})来合并分支,从而形成整个reducer,传入给上文中的const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));来创建redux数据仓库。

建立分支仓库

├─ store
    ├─actionCreators.js      // 创建action
    ├─constants.js           // 声明唯一标识,管控reducer中的switch case
    ├─index.js               // 合并其他三个js文件,作为分支文件的入口文件,传给redux中心仓库
    ├─reducer.js             // 负责管理子分支的状态的

src/pages/hone/constans 代码如下:

export const CHANGE_HOMEDATA = "changeHomeData"
export const CHANGE_SHOPCAR = "CHANGE_SHOPCAR"
export const DECREMENT_NUM = "DECREMENT_NUM"
export const INCREMENT_NUM = "INCREMENT_NUM"
export const CHANGE_CHECK = "CHANGE_CHECK"
export const CHANGE_ALL = "CHANGE_ALL"
export const COUNT_ALL = "COUNT_ALL"

这些自定义的常量作会作为reducer函数的判断唯一值,reducer根据这唯一值来进行不同的redux数据操作。将这些自定义常量规定好,并输出,在其他文件中使用的话能够弹出自动提醒,防止自己手写失误,导致action中的type出现偏差而造成reducer不会进行操作,而是进行default默认操作。

src/pages/hone/actionCreators 代码如下:

import * as actionType from './constants.js';
import { reqhome } from '../../../api/index'


export const InsertHomeData = (data) => ({
    // console.log("进去成功...............");
    type: actionType.CHANGE_HOMEDATA,
    data: data
})

export const getHomeData = () => {
    return (dispatch) => {
        reqhome()
            .then((res) => {
                dispatch(InsertHomeData(res.data.data))
            })
            .catch((e) => {
                console.log('出错了');
            })
    }
}

export const InsertShopcar = (data) => ({
    type: actionType.CHANGE_SHOPCAR,
    data: data
})

export const IncrementShopcarNum = (data) => ({
    type: actionType.INCREMENT_NUM,
    data: data
})

export const DecrementShopcarNum = (data) => ({
    type: actionType.DECREMENT_NUM,
    data: data
})

export const changeCheck = (data) => ({
    type: actionType.CHANGE_CHECK,
    data: data
})

export const changeAll = (data) => {
    return (dispatch) => {
        // total?

        dispatch({
            type: actionType.CHANGE_ALL,
            data: data

        })
        dispatch(countAll(data))
    }
}

export const countAll = (data) => ({
    type: actionType.COUNT_ALL,
    data: data
})

这里是要写生成action的方法,格式一般为{type: , data: }的格式,这里的import { reqhome } from '../../../api/index',是用ajax封装好的请求后台:server的json文件。然后声明函数reqhome,通过封装好的axios接口,来请求后端接口数据
ajax 封装代码如下:

import axios from 'axios';
axios.defaults.baseURL = 'http://127.0.0.1:9999';

export default function Ajax(url, data = {}, type = 'GET') {
    return new Promise((resolve, rejet) => {
        let Promise;
        if (type === 'GET') {
            Promise = axios.get(url, {
                params: data
            })
        } else {
            Promise = axios.post(url, {
                params: data
            })
        }
        Promise.then((response) => {
            resolve(response);
        }).catch((error) => {
            console.error("数据请求异常!", error)
        })
    })
}

传递三个参数,必须传递的是url是访问后端node允许程序的后端接口,通过接口获取json数据。

src/pages/hone/reducer 代码如下:

// import shopcar from '../../shopcar';
import * as actionTypes from './constants';

const defaultstate = {
    homedata: [],
    shopcar: [],
    totalprice: 0,
    checkall: false
}

const reducer = (state = defaultstate, action) => {
    const { type, data } = action
    let temp = null;
    let index = null;
    let checkalls = null;
  
    switch (type) {
        case actionTypes.CHANGE_HOMEDATA: // 初始化home界面的数据
            // console.log("进入了reducer")
            return { ...state, homedata: data }


        case actionTypes.COUNT_ALL:
            let total = 0;
            console.log(data, "---------------");
            console.log(data.length);
            console.log("进入了COUNT_ALL");
            for (let i = 0; i < data.length; i++) {
                if (data[i].check != false) {
                    total += data[i].num * (data[i].prc * 1);
                }
            }
            return { ...state, totalprice: total }
            
         default:
            return state;
    }
}
export default reducer;

这里只显示了一些reducer方法,首先先初始化defaultstate,设置redux中的home分支数据仓库需要的变量进行初始化,然后根据传入的action.type通过switch/case来进行操作数据仓库。

src/pages/hone/index 代码如下:

import reducer from "./reducer";
import * as constants from "./constants"
import * as actionCreators from "./actionCreators"

export {
    reducer,
    constants,
    actionCreators
}

引入上述三个文件,并且合并输出给中心store,进行分支合并。

购物车功能

将购物车数据加入redux.home.shopcar中

// UI组件的一个方法:将购物车信息整合到
    const inputShopcar = () => {    
        let shopcaritem = {
            desc: desc,
            id: id,
            picUrl: picUrl,
            prc: prc,
            size: productSize[checkIndex - 1].size,
            num: inputNumber,
            check: false
        }
        setItemToShopcar(shopcaritem);
    }
-----------------------------------------------------
// 容器组件的定义:
const mapStateToPorps = (state) => {
    return {
        shopcar: state.home.shopcar
    }
}
const mapDispatchToProps = (dispatch) => {
    return {
        setItemToShopcar(item) {
            dispatch(Action.InsertShopcar(item));
        }
    }
}

export default connect(mapStateToPorps, mapDispatchToProps)(memo(ShopcarTable))

关注核心点:

  1. 首先先通过connect创建一个高阶组件,负责与reudx进行通信拿的到值。再通过高阶组件通过俩个参数将reudx拿到的数据和方法通过mapStateToPorps, mapDispatchToProps传递给UI组件也就是ShopcarTable组件。
  2. 在inputShopcar方法中,这是调用了高阶组件传递来的setItemToShopcar方法,在此之前,我们人为的创建了一个对象let shopcaritem = { desc: desc, id: id, picUrl: picUrl, prc: prc, size: productSize[checkIndex - 1].size, num: inputNumber, check: false }并将对象传递给actionCreator中的setItemToShopcar方法,将其封装成为了一个action,再交给reducer进行调用。
export const InsertShopcar = (data) => ({
    type: actionType.CHANGE_SHOPCAR,
    data: data
})
  1. 在reducer中进行逻辑判断,根据对应type写出对应操作改变redux状态。
 // 本文中的type 和 data都是从action中解构出来的
 case actionTypes.CHANGE_SHOPCAR:   // 对购物车进行增加操作,并且判断是否为唯一id
          temp = state.shopcar;
          index = state.shopcar.findIndex(state => state.id == data.id);
          if (index != -1) { //找到了相同的index,需要进行处理
              temp[index].num = state.shopcar[index].num + data.num;
              
              return { ...state, shopcar: [...temp] }
          } else { // 没有找到相同的index,直接 添加进入shopcar
              temp = [...state.shopcar, data];
              
              return { ...state, shopcar: [...temp] }
          }

通过传来的唯一值type来指定进行哪个操作,也就是上面代码的操作。首先拿到以前购物车的数据state.shopcar(防止再添加一条以前数据消失)。然后进行遍历以前购物车数据对象中的id属性,如果以前数据存在一个与传入的的对象 data的属性相同的,那么就不会添加新对象到state.shopcar数组中,而是对响应查找到的index,在数组中的索引的数量进行加操作。如果查询不到相同id,表示此次添加的对象是新的对象,那么直接添加进入购物车中。

购物车界面

重点:因为我们选用redux保存了购物车数据,那么对数据的修改不再是传统的修改useState来改变状态了,而是:在组件中调用了高阶组件传递的方法->方法创建action->reducer接受action并返回新的状态->组件接受新的状态,重新渲染。因此我们对购物车一切改变都是在reducer中进行修改的。

先来看看src/components/shop.jsx中高阶组件与redux进行通信的数据与方法:

const StateToPorps = (state) => {
    return {
        shopcar: state.home.shopcar,
        totalprice: state.home.totalprice,
        checkall: state.home.checkall
    }
}
const DispatchToProps = (dispatch) => {
    return {
        decreasenum(id) {//进行减操作,需传递id
            dispatch(Action.DecrementShopcarNum(id));
        },
        incrementnum(id) {//进行加操作,需传递id
            dispatch(Action.IncrementShopcarNum(id));
        },
        changeCheck(id) {//进行勾选操作,需传递id
            dispatch(Action.changeCheck(id));
        },
        changeAll(shopcar) {//全选操作,需传递shopcar数组
            dispatch(Action.changeAll(shopcar));
        },
        countAll(shopcar) {//计算所以勾选商品的价格,需传递shopcar数组
            dispatch(Action.countAll(shopcar));
        }
    }
}

export default connect(StateToPorps, DispatchToProps)(memo(Shopcar))

StateToPorps参数传递状态:
shopcar:是一个数组,里面所有元素都是一个商品信息对象。
totalprice: 是一个Number,表示当前shopcar中所有勾选的商品的价格。
checkall: 是一个布尔值,表示当前状态是否是全选状态
DispatchToProps参数传递方法不多做介绍了

页面我就不多介绍了,有感兴趣的可以观看源码,让我们专注redux进行购物车修改吧。

加减以及勾选操作

这三个操作本质上都是同一个,就是根据传入的id,遍历购物车数组,找到对应id的对象,对该对象进行修改。

DecrementShopcarNum(),IncrementShopcarNum(),changeCheck()生成action,通过dispatch(action),触发reducer中的方法。
以changeCheck为例子,在reducer中:

 case actionTypes.CHANGE_CHECK:
            temp = state.shopcar;
            index = temp.findIndex(temp => temp.id == data);
            temp[index].check = !temp[index].check;
            return { ...state, shopcar: [...temp] }

进行遍历循环,查找id属性,找到商品对象并修改check属性,因为check为boolean值因此只要取!操作就能完成修改。加减操作同理就不再演示了。

changeAll 和 countAll操作

在reducer中代码如下:

  case actionTypes.CHANGE_ALL:
      checkalls = !state.checkall;
      console.log(data[0]);
      for (let i = 0; i < data.length; i++) {
              data[i].check = checkalls;
      }
           
     return { ...state, shopcar: data, checkall: checkalls }
     
  case actionTypes.COUNT_ALL:
            let total = 0;
            for (let i = 0; i < data.length; i++) {
                if (data[i].check != false) {
                    total += data[i].num * (data[i].prc * 1);
                }
            }
    return { ...state, totalprice: total }

与上面的加减勾选方法遍历查找一个对象进行修改不同,countAll 和 changeAll操作是对整个数组进行遍历对其每个对象都要进行操作,因为这俩个方法不是针对某一条购物车信息,而是针对全部的购物车信息。

在changeAll方法中:首先通过变量checkalls获得state.checkall全选状态取非,并且通过遍历shopcar数组,将数组中每一个对象的check属性改成state.checkall属性。

再countAll方法中:同样是声明一个变量,然后进行遍历shopcar数组的每一个对象,进行判断如果check属性为true进行加操作将数量价格累加到计数并且返回。

思考:

上文介绍了购物车的各种方法以及实现,例如加操作,减操作,勾选操作 和全选操作都是有onClick事件通过点击事件进行触发的,那么countAll操作是怎么触发并且时时刻刻进行监听修改呢?
答案就是React生命周期,通过useEffect监听某个对象的变化进行修改。话不多说上代码:

    useEffect(() => {
        countAll(shopcar);
    }, [shopcar])

监听从容器组件传递的shopcar数组,shopcar每次变化都会引起countAll进行重新执行,因此无论是进行加减勾选全选操作,最终都会改变shopcar数组,因此用useEffect监听shopcar的改变,一旦改变就重新进行计算总价格的操作才是最优解。

代码优化

应用场景:我们生活中往往会逛淘宝,如果我们要买零食,输入关键字,会刷新多条零食数据,但并不会一次性加载所有数据。但我们刷新到底部的时候又会进行数据请求,又会继续加载,这样防止一次性请求大量数据。接下来就用redux进行模拟实现吧。
在src/pages/find代码如下:

const mapStateToPorps = (state) => {
    return {
        findData: state.find.findData
    }
}
const mapDispatchToProps = (dispatch) => {
    return {
        getFindDatas(page) {
            dispatch(actions.getFindData(page))
        },
        getDetailDatas(item) {
            dispatch(actions.getDetailCreate(item))
        }
    }
}

export default connect(mapStateToPorps, mapDispatchToProps)(memo(Find))

与home界面代码差不多,但唯一的不同是getFindData获取find界面数据的函数传递了一个参数page,这个page会在请求后台url的时候作为url的参数进行传递。

export const reqfinddate = (page) => {
    return Ajax(`/finddata/${page}`)
}

来到后台server/index.js 代码如下:

const FindData = require('./Data/FindData/FindData.json')

router.get('/finddata/:page', async (ctx) => {
    let limit = 10
    let { page } = ctx.params
    let { active } = FindData
    let list2 = active.slice((page - 1) * limit, page * limit)
    ctx.response.body = {
        success: true,
        data: {
            newLists: list2,
        }
    }
})

limit:表示一次传输多少条数据给前端,也就是一面包含多少条数据。
active:从json文件中解构出来的我们所需要的所有数据。
page:表示第几页,需要根据page切割所需要的传输给前端的数据。

src/pages/find/store/actionCreators

export const ChangeFindData = (data) => {
    return {
        type: actionType.CHANGE_FIND_DATA,
        data: data
    }
    
export const getFindData = (page) => {
    return (dispatch) => {
        reqfinddate(page)
            .then((res) => {
                console.log(res)
                dispatch(ChangeFindData(res.data.data))
            })
            .catch((e) => {
                console.log('出错了');
            })
    }
}

大体与上文home页面的请求数据相同,只不过在请求数据reqfinddata的时候多传入了一个参数,也就是向后台请求数据时候应该传递请求数据在第page页。
src/pages/find/store/reducer 代码如下:

        case actionTypes.CHANGE_FIND_DATA:
            let newData = {
                newLists: [
                    ...state.findData.newLists,
                    ...action.data.newLists
                ]
            }
            return { ...state, findData: newData }

因为每次传递都只传递十条(limit=10),因此action.data.length为10,我们在redux仓库需要做的操作就是在保留原数组newLists的数据下,将action.data的数据增添到newLists中。运用了扩展运算符进行修改,因为扩展运算符会生成新的数组,因为数组是引用类型因此redux数据仓库检测到对象,会使对应改变的对象所在的组件进行重新渲染。

使用图片懒加载

我们做到了数据的分页请求一定limit的数据,那么是否还有一些别的方法来做到性能优化呢?没错就是图片的懒加载,本文是使用scroll事件进行监听进行图片的懒加载。

src/pages/find/index.jsx 代码如下:

import Lazyload, { forceCheck } from 'react-lazyload'// 引入懒加载所需包
import loading from '@/assets/Images/1.gif' // 引入懒加载图片时候的代替图片
import loading2 from '@/assets/Images/loading.gif'// 引入上拉刷新出现的loading图片
      <Lazyload
           height={100}
           placeholder={<img width="100%" height="100%" src={loading} />}
      >
          <img src={item.picUrl} alt="" ></img>
      </Lazyload>

当对图片进行遍历的时候,再显示图片的地方包裹一个Lazyload,并在其 placeholder标签属性上进行懒加载时候的预加载图片。

总结:

本次文章也是对redux学习进行了实践,也希望我的文章能够给读者带来一些收获。
该项目也有很多不足之处:

  • 后端接口都是用.json文件进行代替,没有加深对koa的理解。
  • 并没有配置redux-persist实现redux数据的持久化保存。
  • 组件化开发,复用性差。 希望未来继续学习,抽空学习ts,再写一个完整的ts全栈练习项目来练手。