凡人修React|结丹必备!React-Hooks+Redux(RTK)实战抖音商城第二弹

3,656 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

前言

逮嘎猴!我是薛定谔的咖啡猫!一晃而过已入盛夏,凡人修React|新手练气筑基必备! 抖音商城[商品信息卡组件]开发也是一月前的。这段时间本人在修React之路上又喜获Redux它是应用的状态容器,提供可预测的状态管理,以及ReduxToolKit(RTK),可以简化配置store、创建reducer等,是编写Redux应用更加简便。本文重点也在Redux的使用,就稍微介绍一下Redux的重点。

Redux的基本术语

  • Actions
    action是一个具有 type 字段的普通 JavaScript 对象。你可以将 action 视为描述应用程序中发生了什么的事件。

      const addTodoAction = {
          type: 'todos/todoAdded',
          payload: 'Buy milk'
      }
    
  • Reducers reducer是一个函数,接收当前的 state 和一个 action 对象,必要时决定如何更新状态,并返回新状态。不妨将 reducer 视为一个事件监听器,它根据接收到的 action(事件)类型处理事件。

  • Store Store 就是存储着应用的 state tree 的对象。

  • Dispatch Redux store 有一个方法叫 dispatch。更新 state 的唯一方法是调用 store.dispatch() 并传入一个 action 对象。 store 将执行所有 reducer 函数并计算出更新后的 state

Reudx的三大原则

  • 全局应用状态保存在单个 store 中
  • store 中的 state 是只读的
  • Reducer 函数用于更新状态以响应 actions

Redux的单向数据流

  • State 描述了应用程序在某个时间点的状态,UI 基于该状态渲染
  • 当应用程序中发生某些事情时:
    • UI dispatch 一个 action
    • store 调用 reducer,随后根据发生的事情来更新 state
    • store 通知 UI state 发生了变化
  • UI 基于新 state 重新渲染

动画的方式来表达数据流更新:

ReduxDataFlowDiagram-49fa8c3968371d9ef6f2a1486bd40a26.gif

含有异步逻辑时:

ReduxAsyncDataFlowDiagram-d97ff38a0f4da0f327163170ccc13e80.gif

技术栈

  • React:声明式、组件化的构建用户界面
  • React Router:保持 UI 与 URL 同步
  • Redux:应用的状态容器,提供可预测的状态管理。使用React-Redux与其绑定
  • Reudx ToolKit:包含了Redux核心,官方推荐的编写Redux逻辑的方法,简化了大多数Redux任务
  • Axios:是一个基于promise的网络请求库
  • react-lazyload:React的懒加载库
  • antd-mobile:蚂蚁金融团队推出开源组件库

项目构建

路由配置

路由封装进行统一管理,并搭配LzaySuspense实现页面懒加载,使得页面按需加载。 在/src/routes/index.js中:

    import React,{lazy} from 'react'
    import {Routes,Route,Navigate} from 'react-router-dom'
    import Home from '@/pages/Home'
    const Orders =lazy(()=>import('@/pages/Orders'))
    const Purchase =lazy(()=>import('@/pages/Purchase'))
    const Cart=lazy(()=>import('@/pages/Cart'))

    export default function RoutesConfig() {
      return (
        <Routes>
            <Route path='/' element={<Navigate to={"/home"} replace />} />
            <Route path='/home' element={<Home/>} />
            <Route path='/orders' element={<Orders />} />
            <Route path='/purchase/:id' element={<Purchase/>}></Route>
            <Route path='/cart' element={<Cart/>}></Route>
        </Routes>
      )
    }

在APP.jsx中加上Suspense

    import { Suspense} from 'react'
    import RoutesConfig from "./routes"
    import Loading from './components/Loading'
    function App() {

      return (
        <div className="App">
          <Suspense fallback={<Loading/>}>
            <RoutesConfig />
          </Suspense>
        </div>
      )
    }

    export default App

接口配置

网络请求数量多,这时 axios 使用单例模式。每个请求域名+端口部分,没必要重复,可以配置到baseUr中,并设置拦截器。 在/api/config.js中:

    import axios from "axios"
    export const baseUrl = 'https://www.fastmock.site/mock/efb99b7bee0fe1e8fc2887e9a7d16759/douyin_mall';
    const axiosInstance = axios.create({
        baseURL: baseUrl
    })

    axiosInstance.interceptors.response.use(
        res => res.data,
        err => {
            console.log(err, '网络错误')
        }
    )

    export { axiosInstance }

在/api/request.js中:

    import { axiosInstance } from "./config";

    export const getGoodsInfoRequest = () =>
        axiosInstance.get('/goodsInfo')

    export const getOrdersInfoRequest = () =>
        axiosInstance.get('/ordersInfo')

    export const getCartInfoRequest = () =>
        axiosInstance.get('/cartInfo')

Redux 配置

本文使用了ReduxToolKit,其包含了Redux的核心,仍然是Redux的思想与逻辑。只是有了简化操作的API。

  • 在/store/index.js中创建store

      // 使用configureStore创建Store
      import { configureStore } from "@reduxjs/toolkit";
      import goodsInfoSlice from "./features/goodsInfoSlice";
      import cartInfoSlice from "./features/cartInfoSlice";
      import ordersInfoslice from "./features/ordersInfoslice";
      
      // 调用configureStore创建一个store,这个函数是对Redux的
      // createStore函数的一个封装,它接受一个对象,对象里包括: 
      // reducer:接收单个reducer函数或者包含若干reducer函数的对象 
      // middleware?:接收Redux中间件函数数组,默认使用了react-thunk 
      // devTools?:决定是否开启对Redux DevTools浏览器插件的支持 
      // preloadedState?:传给Redux的createStore函数中同名
      // enhancers?:接收Redux store enhancer数组
    
      const store =configureStore({
          
          reducer:{
              goodsInfo:goodsInfoSlice,
              cartInfo:cartInfoSlice,
              ordersInfo:ordersInfoslice
          }
      })
      
      // 默认导出store
      export default store
      
    

Slice的创建以goodsInfoSlice为例

  • 在/store/features/goodsInfoSlice.js 创建Slice
    并使用createAsyncThunk创建异步函数,在Slice中的extraReducers中执行

      // 通过createSlice 创建Slice
      import { createSlice ,createAsyncThunk} from "@reduxjs/toolkit";
      import { getGoodsInfoRequest } from "@/api/request";
    
      // 使用createAsyncThunk创建异步action
      // 该方法触发时会有三种状态
      // pending(进行中) fulfilled(成功),rejected(失败)
      export const getGoodsInfo=createAsyncThunk('goodsInfo/getGoodsInfo', //type
          // data
          async ()=>{
              // console.log(await getGoodsInfoRequest())
              return  await getGoodsInfoRequest()
              // return data;
          }
      )
      // createSlice为某一个类型的action创建了一个切片,
      // 将这个action相关的信息都归集到了一起,更便于维护
      export const goodsInfoSlice=createSlice({
          // slice的名称
          name:'goodsInfo',
          // state的初始值
          initialState,
          // 一个包含了action的对象,每一个key都会生成一个actions
          //(相当于原生redux的Switch Case写法)
          reducers:{
              // toolkit内部调用了immer包,我们可以
              //直接对state对象做修改,不用解构旧的state
          },
          // extraReducers 字段让 slice 处理在别处定义的 actions, 
          // 包括由 createAsyncThunk 或其他slice生成的actions。
          extraReducers(builder){
              builder
              .addCase(getGoodsInfo.pending,(state)=>{
                  console.log('请求数据中。。。');
                  // state.loading=false;
              })
              .addCase(getGoodsInfo.fulfilled,(state,{payload})=>{
                  console.log('请求数据完成',payload);
                  state=Object.assign(state,payload)
                  state.loading=true;
              })
              .addCase(getGoodsInfo.rejected,(state,err)=>{
                  console.log('请求数据失败',err)
              })
          }
      }   
      )
    
      // 导出action ->reducers里方法
      // export const {}=goodsInfoSlice.actions
    
      // 默认导出reducer
      export default goodsInfoSlice.reducer
    

三个主要页面

购物车页面

Checked的状态实现

22.gif

购物车的选中有单个选中店铺内全选,购物车全选,其中全选状态的改变都由单个选中,即购物车中被选中的个数来决定。

  • 在CartInfoSlice中添加相应的action

          // 改变单件商品的checked状态
          changeChecked:(state,{payload})=>{
                  state.buyGoods.byId[payload.id].checked=!state.buyGoods.byId[payload.id].checked;
          },
          // 改变某个店铺的checked状态 实际是改变单件商品的checked状态
          changeShopAllChecked:(state,{payload})=>{
              // 获取目前是否全选
              const isShopAllChecked= (payload.shop.buyGoods.length == 
              payload.shop.buyGoods.filter((item)=>
                  payload.buyGoods.byId[item].checked).length);
              // console.log(isShopAllChecked);
              payload.shop.buyGoods.map((item)=>
                  // 将checked设置为目前全选状态的非
                  state.buyGoods.byId[item].checked=!isShopAllChecked);
          },
          // 改变所有的Checked状态 实际是改变单件商品的checked状态
          changeAllChecked:(state,{payload})=>{
              // 获取目前是否全选
              const isAllChecked= (payload.buyGoods.allIds.length == 
                  payload.buyGoods.allIds.filter((item)=>
                      payload.buyGoods.byId[item].checked).length);
    
              payload.buyGoods.allIds.map((item)=>
                  // 将checked设置为目前全选状态的非
                  state.buyGoods.byId[item].checked=!isAllChecked)
          },
          
    
  • 在Cart/CartListItem/index.jsx中控制单个Checked和店铺Checked

      // 使用useDispatch来派发action
      import { useDispatch } from 'react-redux'
      // 引入cart的action
      import { 
          increcount ,
          decrecount,
          changeChecked,
          changeShopAllChecked
      } from '@/store/features/cartInfoSlice'
      
      ...
      <div className="checkbox_wrapper"
         onClick={()=>
         {dispatch(changeShopAllChecked({shop,buyGoods}))}}
      >
       {/* 通过buyGoods中的商品的checked的长度 
       与 总物品的长度 来驱动 该店铺的全选状态 */}
      <Checkbox checked={shop.buyGoods.length ==  
          shop.buyGoods.filter((item)=>
              buyGoods.byId[item].checked).length}/>
       </div>
      ...
      <div className="checkbox_wrapper" 
            onClick={()=>{dispatch(changeChecked({id}))}}
      >
           <Checkbox checked={checked} />
      </div>
      ...
    
  • 在CarFooter中控制所有全选状态

      ...
      <div className="checkbox_wrapper"
           onClick={()=>
           {dispatch(changeAllChecked({shops,buyGoods}))}}
      >
          <Checkbox checked={buyGoods.allIds.length ==
             buyGoods.allIds.filter((item)=>
                 buyGoods.byId[item].checked).length
                    && shops.allIds.length
           }>
            全选
            </Checkbox>
       </div>
       ...
    

删除选中的Item

33.gif

  • 在cartInfoSlice中添加删除的action

      // 删除CartList中的内容
      deleteCartList:(state,{payload})=>{
          const checkedItemId=payload.buyGoods.allIds.filter((item)=>
          payload.buyGoods.byId[item].checked);
          // 过滤掉被删除的ItemId
          state.buyGoods.allIds=state.buyGoods.allIds.filter((item)=>!checkedItemId.includes(item));
          // 再通过checkedItemId修改 buyGoods中Item 内容
          checkedItemId.forEach((item)=>{
              Reflect.deleteProperty(state.buyGoods.byId,item);
          });
          // 修改shops中的buyGoods
          state.shops.allIds.forEach((item)=>{
              // 过滤掉shops中buyGoods中被删除的ItemId
              state.shops.byId[item].buyGoods=state.shops.byId[item].buyGoods.filter((item)=>!checkedItemId.includes(item));
          });
          // 检查某个店铺购买的goods是否全被删除
          state.shops.allIds.forEach((item)=>{
              if(state.shops.byId[item].buyGoods.length===0){
                  state.shops.allIds=state.shops.allIds.filter((id)=>!(id===item))
                  // 删除该店铺的cartInfo
                  Reflect.deleteProperty(state.shops.byId,item)
              }
          })   
      },
    
  • 点击管理切换到删除按钮进行删除

      <button className="button_settle"
            onClick={()=>{ 
                dispatch(deleteCartList({shops,buyGoods}));
                 Toast.show({
                     content: '删除成功'
                      })
                 }}
     >删除</button>}
    

价格总计及结算

  • count数量的修改,在CartSlice 添加相应的action

      ...
      // 购买商品数量加1
      increcount:(state,{payload})=>{
          state.buyGoods.byId[payload.id].count+=1;
      },
      //购买商品数量减1
      decrecount:(state,{payload})=>{
          state.buyGoods.byId[payload.id].count-=1;
          if(state.buyGoods.byId[payload.id].count<1){
              // count不能小于1
              state.buyGoods.byId[payload.id].count=1;
          }
      },
      ...
      
    
  • 总价根据count和Checked状态确定结算时会将要购买的商品加入到订单页面中,并删除购物车对应的数据

44.gif

    ...
    <div className="buy_total">
       <span>合计:</span><span className="total">¥{
      // 通过商品的checked状态来计算总价total 驱动UI状态 
      buyGoods.allIds.reduce((total,item)=>
      total+(buyGoods.byId[item].checked ? parseFloat(
      buyGoods.byId[item].price * buyGoods.byId[item].count):0),0)
       }</span>
    </div>}
    ...
       onClick:()=>{
            //来自orders的action
           dispatch(addOrder({shops,buyGoods}))
           dispatch(deleteCartList({shops,buyGoods}));
       }
    ...

订单页面

删除订单

55.gif

  • OrdersSlice中的action

      ...
      deleteOrder:(state,{payload})=>{
          Reflect.deleteProperty(state.orders.byId,payload.order.id)
          state.orders.allIds=state.orders.allIds.filter((item)=>{return !(item === payload.order.id)}) 
      },
      addOrder:(state,{payload})=>{
          const checkedItemId=payload.buyGoods.allIds.filter((item)=>
          payload.buyGoods.byId[item].checked);
          // 通过checkedItemId找出结算的goods和shop
          const checkedItem=checkedItemId.map((item)=>{return payload.buyGoods.byId[item]})
          // 增加结算的Item
          checkedItem.map((item)=>{
              state.buyGoods.allIds.push(item.id);
              state.buyGoods.byId[item.id]=item;
          });
          checkedItem.map((item)=>{
              if(!state.shops.allIds.includes(item.shopId)){
                  state.shops.allIds.push(item.shopId)
                  // 增加新的shop信息
                  state.shops.byId[item.shopId]={
                      id:payload.shops.byId[item.shopId].id,
                      name:payload.shops.byId[item.shopId].name,
                      imgUrl:payload.shops.byId[item.shopId].imgUrl,
                  }
              }
          });
          const tempbuyGoods={};
          checkedItem.forEach((item)=>{
              if(!(item.shopId in tempbuyGoods)){
                  tempbuyGoods[item.shopId]=[item.id]
              }else{
                  tempbuyGoods[item.shopId]=[...tempbuyGoods[item.shopId],item.id]
              }
              
          });
          for (let key in tempbuyGoods){
              state.orders.byId['order'+key]={
                  id:'order'+key,
                  shopId:key,
                  buyGoods:tempbuyGoods[key],
                  state:"待支付"
              }
              state.orders.allIds.push('order'+key);
          }
    
      }
      ...
    
  • 订单页面删除订单

      ...
      onClick:()=>{
          dispatch(deleteOrder({order}));
          Toast.show({
          content: '删除成功'
          })
      }
      ...
      
    

购买页面/商品详情页

  • 购买页面显示选中不同型号的商品并购买加入购物车

66.gif

77.gif

- CartSlice中添加相应的action

        ...
        addToCart:(state,{payload})=>{
        console.log(payload.targetShopInfo,payload.buyGoodsInfo)
        // 店铺信息处理
        // 新增商品的所属店铺是否已有
        // 如果没有 加上
        if(!state.shops.allIds.includes(payload.targetShopInfo.id)){
            state.shops.allIds.push(payload.targetShopInfo.id);
            state.shops.byId[payload.targetShopInfo.id]=payload.targetShopInfo;
            // 增加buygoods的key
            state.shops.byId[payload.targetShopInfo.id]={
                ...state.shops.byId[payload.targetShopInfo.id],...{buyGoods:[]}};
        }
        // 商品信息处理
        // 已有该商品信息
        if(state.buyGoods.allIds.includes(payload.buyGoodsInfo.id)){
            // count 叠加
            state.buyGoods.byId[payload.buyGoodsInfo.id].count+=payload.buyGoodsInfo.count
        }else{
            // 没有 新增商品信息
            state.buyGoods.allIds.push(payload.buyGoodsInfo.id);
            state.buyGoods.byId[payload.buyGoodsInfo.id]=payload.buyGoodsInfo
            // 将新增商品信息添加到该店铺下
            state.shops.byId[payload.targetShopInfo.id].buyGoods.push(payload.buyGoodsInfo.id)
        }
        // 商品信息处理完毕

        }
        ...
    

State的范式化处理

本文的State(数据)有一个嵌套情况的存在,如店铺信息中包含商品信息,商品信息又需要知道隶属于哪一个店铺,及商品信息包含店铺信息,订单信息中亦是如此。这就带来了一些问题:

  • 当数据在多处冗余后,需要更新时,很难保证所有的数据都进行更新。
  • 嵌套的数据意味着 reducer 逻辑嵌套更多、复杂度更高。尤其是在打算更新深层嵌套数据时。
  • 不可变的数据在更新时需要状态树的祖先数据进行复制和更新,并且新的对象引用会导致与之 connect 的所有 UI 组件都重复 render。尽管要显示的数据没有发生任何改变,对深层嵌套的数据对象进行更新也会强制完全无关的 UI 组件重复 render

那么不妨将这部分视为数据库,作如下State的范式管理,以goodsInfo为例

    ...
    {
        goods:{
            byId:{
                1:{
                    id:1,
                    shopId:"mi111",
                    name:"RedmiNote11 4G手机",
                    imgUrl:"https://img14.360buyimg.com/n7/jfs/t1/189071/26/24872/250013/62870273E0be46c79/2be13b0a9f01f66f.jpg",
                    price:999,
                    types:{
                        "颜色":['黑色','蓝色','紫色'],
                        "存储":['8+128G','8+256G','12+256G'],
                        allTypes:["颜色","存储"]
                    }
                },
                2:{
                    id:2,
                    shopId:"mi111",
                    name:"ipad mini 平板电脑",
                    imgUrl:"https://img10.360buyimg.com/n7/jfs/t1/86940/3/26618/122396/6246970cE40f70e5f/fbfef3df69cf8217.jpg",
                    price:3599,
                    types:{
                        "颜色":['黑色','蓝色','紫色'],
                        "存储":['8+128G','8+256G','12+256G'],
                        allTypes:["颜色","存储"]
                    }
                },
                3:{
                    id:3,
                    shopId:"mi222",
                    name:"RedmiNote11 4G手机",
                    imgUrl:"https://img14.360buyimg.com/n7/jfs/t1/189071/26/24872/250013/62870273E0be46c79/2be13b0a9f01f66f.jpg",
                    price:999,
                    types:{
                        "颜色":['黑色','蓝色','紫色'],
                        "存储":['8+128G','8+256G','12+256G'],
                        allTypes:["颜色","存储"]
                    }
                },
                4:{
                    id:4,
                    shopId:"mi222",
                    name:"ipad mini 平板电脑",
                    imgUrl:"https://img10.360buyimg.com/n7/jfs/t1/86940/3/26618/122396/6246970cE40f70e5f/fbfef3df69cf8217.jpg",
                    price:3599,
                    types:{
                        "颜色":['黑色','蓝色','紫色'],
                        "存储":['8+128G','8+256G','12+256G'],
                        allTypes:["颜色","存储"]
                    }
                },

            },
            allIds:[1,2,3,4]
        },
        shops:{
            byId:{
                "mi111":{
                    id:"mi111",
                    name:"小米官方旗舰店111",
                    imgUrl:"https://dss0.bdstatic.com/-0U0bnSm1A5BphGlnYG/tam-ogel/1122879666_1184688902_88_88.png",
                    goods:[1,2]
                },
                "mi222":{
                    id:"mi222",
                    name:"小米官方旗舰店222",
                    imgUrl:"https://dss0.bdstatic.com/-0U0bnSm1A5BphGlnYG/tam-ogel/1122879666_1184688902_88_88.png",
                    goods:[3,4]
                },
            },
            allIds:["mi111","mi222"]
        },
    }
    ...

其他配置及优化

配置vite alias

写代码时引入文件路径通常会跳很多级,使用“@”作为src的别称

    ...
    var init = function () {
var clientWidth = document.documentElement.clientWidth || document.body.clientWidth;
if (clientWidth >= 640) {
  clientWidth = 640;
}
var fontSize = 20 / 375 * clientWidth;
document.documentElement.style.fontSize = fontSize + "px";
  }

init();

window.addEventListener("resize", init);
    ...
    

全局风格样式文件

项目中通常会用一个全局风格的样式文件global-style.js 设置属于该项目的颜色,字体大小等。

    ...
    // 全局样式定义
    export default {
        "theme-color": "#FF2C57",
        "theme-color-light":"#EB89A0"
    }
    ...

react-lazyload实现图片懒加载

在placholder的属性中使用一张图片占位

    ...
    <LazyLoad className="item_img_wrapper"
        placeholder={
        <img width="100%" height="100%" src={temp}/>}
    >
            <img src={imgUrl} alt="" className="item_img" />
     </LazyLoad>
    ...

React.memo()组件性能优化

memo用来优化函数组件的重复渲染行为,如果函数组件在相同的props的情况渲染相同的结果,那么使用memo会跳过这次渲染,使用最近一次相同结果的渲染。

最后

项目写完仍有很多不足,继续加油!下面是Redux官网和本次项目地址

Redux中文官网
项目gitee地址
继续冲!!!

9999.gif