阅读 309

React 实战打造第一个电商项目

前言

学习 react 也有一段时间了, 所以准备挑个电商APP练练手,为什么选择得物呢?????当然是因为样式简洁,好写呗????
此项目为是和小伙伴一起完成的,欢迎大家也去看我小伙伴的文章????‍♀️
此次项目没有使用UI框架,基础轮子组件有一些借鉴了三元大佬的网易云音乐项目,其余都是独立设计,算是对自己一个小小的挑战

实战

创建应用

首先使用的脚手架是 vite,使用 vite 的原因就一个字,快!

项目技术栈

  • react 全家桶: react + react-router + redux

  • redux-thunk: 处理异步逻辑的redux中间件

  • immer: 轻量级 immutable,进行持久性数据结构处理

  • react-lazyload: react 懒加载库

  • better-scroll: 提升移动端滑动体验

  • stlyed-components:css in js 的工程化工具

  • axios:用来请求后端 api 数据

  • moment:处理时间和日期的库

  • lokijs: js内存数据库

每个组件都应用 memo 包裹,使得 React 在更新组件之前进行 props 的比对,若 props 不变则不对组件更新,减少不必要的重渲染

项目的架构如下

react-dewu/     node_modules/     src/         api/             网络请求代码和相关配置         assets/          静态文件         baseUI/          基础UI轮子         components/      可复用的UI组件         database/        数据库         layouts/         整体布局         mock/            mock假数据模拟后端         pages/           页面         routes/          路由配置文件         store/           redux 相关文件         utils/           工具类函数         App.jsx          根组件         main.jsx         入口文件         style.js         默认样式     index.html     package.json     readme.md     vite.config.js 复制代码

首先分析页面的整体布局

image.png
可以发现只有底部的 tabbar 部分属于共享布局,那就开始编写路由配置

import React, { lazy, Suspense } from 'react'; import HomeLayout from '../layouts/HomeLayout'; import NotFound from '../layouts/NotFound'; import { Navigate } from 'react-router-dom'; const IdentifyComponent = lazy(() => import("../pages/identify")); const ShopListComponent = lazy(() => import("../pages/shopping")); const WashComponent = lazy(() => import("../pages/wash")); const MyComponent = lazy(() => import("../pages/my")); export default [   {     path: "/",     element: <HomeLayout />, // 一级路由,对应公共组件,放置 tabbar 的布局     // 二级路由 配置四个 tab 栏     children: [       {         path: "/",          element: <Navigate to="/shop" /> // 默认跳转到 shop 商品页面       },       {         path: "/shop",         element: <Suspense fallback={null}><ShopListComponent></ShopListComponent></Suspense>,       },       {         path: "/identify",         element: <Suspense fallback={null}><IdentifyComponent></IdentifyComponent></Suspense>       },       {         path: "/wash",         element: <Suspense fallback={null}><WashComponent></WashComponent></Suspense>       },       {         path: "/my",         element: <Suspense fallback={null}><MyComponent></MyComponent></Suspense>       },       {         path: "*",          element: <NotFound />       }     ]   } ]; 复制代码

这里使用了 React 提供的 SuspenseLazy 实现了动态路由

简单说一下这里为什么要使用动态路由:
对于大型应用来说,一个首当其冲的问题就是所需加载的 JavaScript 的大小。程序应当只加载当前渲染页所需的 JavaScript。有些开发者将这种方式称之为 "代码分拆(code-splitting)" — 将所有的代码分拆成多个小包,在用户浏览过程中按需加载,这样可以提高首屏加载效率

为了让路由文件生效,必须在 App 根组件下面导入路由配置,现在在 App.jsx 中:

import React from 'react'; import { useRoutes } from 'react-router';// useRoutes 读取路由配置转换为 Route 标签 import ALLRoutes from './routes/index'; import { IconStyle } from './assets/iconfont/iconfont'; import { Provider } from 'react-redux'; import store from './store'; import { GlobalStyle } from './style'; const App = () => {   const routes = useRoutes(ALLRoutes);   return (     <Provider store={store}>       <GlobalStyle></GlobalStyle>       <IconStyle></IconStyle>       { routes }     </Provider>   ); } export default App; 复制代码

使用react-router V6 的新特性 useRoutes 方法取代了 react-router-config 的 renderRoutes 方法,太方便了

然后进行公共页面组件 HomeLayout 的开发

import React, { useEffect } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { Tab, TabItem } from './HomeLayout.style';  function Home(props) {   return (     <>       <Tab>         <NavLink            to="/shop"            className={({isActive}) => isActive ? "selected" : null}         >           <TabItem onClick={() => {changeDispatchIndex(0)}}>             <span>购买</span>           </TabItem>         </NavLink>         <NavLink            to="/identify"            className={({isActive}) => isActive ? "selected" : null}         >           <TabItem onClick={() => {changeDispatchIndex(1)}}>             <span>鉴别</span>           </TabItem>         </NavLink>         <NavLink            to="/wash"            className={({isActive}) => isActive ? "selected" : null}         >           <TabItem onClick={() => {changeDispatchIndex(2)}}>             <span>洗护</span>           </TabItem>         </NavLink>         <NavLink            to="/my"            className={({isActive}) => isActive ? "selected" : null}         >           <TabItem onClick={() => {changeDispatchIndex(3)}}>             <span>我</span>           </TabItem>         </NavLink>       </Tab>       <Outlet /> /*渲染下一层子路由*/     </>   ); } export default React.memo(Home); 复制代码

image.png

以上是部分代码,完整代码看这。通过 NavLink 中 className 提供的 isActive属性在选中Tab时激活 selected 属性,给字体加粗并且有下划线的效果, 现在就可以体验在 Tab 上自由切换的感觉了

进行第一个页面级(shop页面)组件的开发

分析上图片,要开发的组件有:

  • 商品列表

  • 横向分类列表

  • 搜索框

商品列表的开发

import React from 'react'; import LazyLoad from 'react-lazyload'; import { useNavigate } from 'react-router-dom'; import { getCount } from '../../utils/shop'; import {    ListItem,   List } from './style'; import shopImg from './shop.png'; function RecommendList(props) {   let navigate = useNavigate();   const enterDetail = (id) => {     navigate(`/shop/${id}`);   }   return (       <List>         {           props.recommendList.map(item => {             return (               <ListItem key={item.purchaseNum} onClick={() => enterDetail(item.purchaseNum)}>                 <div className="img_wrapper">                   <div className="decorate"></div>                   <LazyLoad placeholder={<img width="100%" height="100%" src={shopImg} alt="music"/>}>                     <img src={item.image} width="100%" height="100%" alt="music"/>                   </LazyLoad>                   <div className="show_price">                     <svg t="1641216704920" className='i' viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2168" width="15" height="15"><path d="M883 552.5v-87.7H617.8L864.9 107l-73-48-272.6 394.9L246 59.4 172.3 106l247.5 358.8H154.9v87.1h320v90.2h-320V731h320v234h88.7V729.8H883v-86.5H563.6v-89.5z" p-id="2169" fill="#333333"></path></svg>                     <span className="content">{item.price}</span>                   </div>                   <div className="play_count">                     <span className="count">{getCount(item.purchaseNum)}+人付款</span>                   </div>                   <div className="desc">{item.title}</div>                 </div>               </ListItem>             )           })         }       </List>   ); } export default React.memo(RecommendList); 复制代码

实现效果如下

对应的 style.js 在这里。getCount 是一个工具类函数,与业务功能关系不大,我把它专门放在了utils文件夹下
完整列表组件代码看这里

相关优化

  • 引入react-lazyload 实现图片懒加载

<LazyLoad placeholder={<img width="100%" height="100%" src={shopImg} alt="music"/>}>     <img src={item.image} width="100%" height="100%" alt="music"/>  </LazyLoad> 复制代码

横向分类列表的开发

在 baseUI 文件夹下新建 horizen-item 目录,接着新建 index.jsx 首先分析这个基础组件接受哪些参数

import { PropTypes } from 'prop-types'; // 引入 prop-types 库进行类型检查 Horizen.defaultProps = {   list: [], // 为接受的列表数据   handleClick: null, // 为点击不同的 item 执行方法   title: '', // 为列表左边的标题   oldVal: '', // 为当前的 item 值 }; Horizen.propTypes = {   list: PropTypes.array,   handleClick: PropTypes.func,   title: PropTypes.string,    oldVal: PropTypes.string }; 复制代码

进行 redux 层的开发,在 Shopping 目录下,新建 store 文件夹,然后新建以下文件

actionCreators.js  //放不同action的地方  constants.js  //常量集合,存放不同action的type值  index.js  //用来导出reducer action reducer.js  //存放initialState和reducer函数 复制代码

然后把对象解构出来

  const { list, oldVal, title } = props;   const { handleClick } = props; 复制代码

返回的jsx为

  return (     <Scroll direction={"horizental"} refresh={true}>       <div ref={Category} >         <List>           <span>{title}</span>           {             list.map((item) => {               return (                 <ListItem                   key={item.key}                   className={oldVal === item.key ? 'selected' : ''}                   onClick={() => clickHandle(item)}>                   {item.name}                 </ListItem>               )             })           }         </List>       </div>     </Scroll>   ) 复制代码

完整的 horizen-item 代码看这里

image.png
现在就可以滑动了,对于引入的Scroll组件是我的小伙伴负责开发,他的文章详细介绍了Scroll组件的详细开发,Scroll组件也是这个项目的灵魂

搜索框

image.png
搜索框的制作比较简单,直接上地址

Redux 层的开发

申明初始化state

const defaultState = {   shopList:[], // 商品列表总数   enterloading: true, // 加载时 loading 状态   pullUpLoading: false, // 上拉刷新时 loading 状态   pullDownLoading: false, // 下拉刷新更多数据时 loading 状态   category: "1001", // 横向分类列表参数   pageCount: 0, // 当前页数,用于实现分页功能   listOffset: 0, // 请求列表的偏移   index: 0  } 复制代码

定义constants

export const CHANGE_SHOP_LIST = "shop/CHANGE_SHOP_LIST"; export const CHANGE_ENTERLOADING = "shop/CHANGE_ENTER_LOADING"; export const CHANGE_PULLUP_LOADING = 'shop/CHANGE_PULLUP_LOADING'; export const CHANGE_PULLDOWN_LOADING = 'shop/CHANGE_PULLDOWN_LOADING'; export const CHANGE_CATOGORY = 'shop/CHANGE_CATEGORY'; export const CHANGE_LIST_OFFSET = 'shop/CHANGE_LIST_OFFSET'; export const CHANGE_PAGE_COUNT = 'shop/CHANGE_PAGE_COUNT'; export const CHANGE_INDEX = 'shop/CHANGE_INDEX'; export const REFRESH_MORE_SHOP_LIST = 'shop/REFRESH_MORE_SHOP_LIST'; 复制代码

定义rudecer函数

import { produce } from 'immer'; import * as actionTypes from './constants'; export const shopReducer = produce((state, action) => {   switch(action.type) {     case actionTypes.CHANGE_ENTERLOADING:       state.enterloading = action.data;       break;     case actionTypes.CHANGE_SHOP_LIST:       state.shopList = action.data;       break;     case actionTypes.CHANGE_CATOGORY:       state.category = action.data;       break;     case actionTypes.CHANGE_PULLUP_LOADING:       state.pullUpLoading = action.data;       break;     case actionTypes.CHANGE_PULLDOWN_LOADING:       state.pullDownLoading = action.data;       break;     case actionTypes.CHANGE_PAGE_COUNT:       state.pageCount = action.data;       break;     case actionTypes.REFRESH_MORE_SHOP_LIST:       state.shopList = _.shuffle(action.data);       break;     case actionTypes.CHANGE_LIST_OFFSET:       state.listOffset = action.data;       break;     case actionTypes.CHANGE_INDEX:       state.index = action.data;       break;   } }, defaultState); 复制代码

编写具体的 action

import * as actionTypes from './constants'; import { getShopListRequest, getMoreShopListRequest } from '../../../api/request'; export const changeRecommendList = (data) => ({   type: actionTypes.CHANGE_SHOP_LIST,   data }); export const updateShopList = (data) => ({   type: actionTypes.REFRESH_MORE_SHOP_LIST,   data }); export const changeListOffset = (data) => ({   type: actionTypes.CHANGE_LIST_OFFSET,   data }); //进场 loading export const changeEnterLoading = (data) => ({   type: actionTypes.CHANGE_ENTERLOADING,   data }); //滑动最底部loading export const changePullUpLoading = (data) => ({   type: actionTypes.CHANGE_PULLUP_LOADING,   data }); //顶部下拉刷新loading export const changePullDownLoading = (data) => ({   type: actionTypes.CHANGE_PULLDOWN_LOADING,   data }); export const changePageCount = (data) => ({   type: actionTypes.CHANGE_PAGE_COUNT,   data }); export const changeCategory = (data) => ({   type: actionTypes.CHANGE_CATOGORY,   data }) export const changeIndex = (data) => ({   type: actionTypes.CHANGE_INDEX,   data }) export const refreshMoreShopList = (offset, category, shopList) => {   return (dispatch) => {     getMoreShopListRequest(category, offset).then(res => {       const data = [...shopList, ...res.data.data.items];       const length = data.length;         setTimeout(() => {           dispatch(changeRecommendList(data));           dispatch(changePullUpLoading(false));           dispatch(changeListOffset(length));         }, 1000)     }).catch(() => {       console.log('获取更多数据失败');     })   } } export const refreshShopList = (category) => {   return (dispatch) => {     getShopListRequest(category).then(data => {       setTimeout(() => {         dispatch(updateShopList(data.data.data.items));         dispatch(changePullDownLoading(false));       }, 1000)     }).catch(() => {       console.log('顶部下拉刷新请求数据失败')     })    } } export const updateCategoryData = (category) => {   return (dispatch) => {     getShopListRequest(category).then(data => {       setTimeout(() => {         dispatch(updateShopList(data.data.data.items))         dispatch(changeEnterLoading(false));       },1000)     }).catch(() => {       console.log('分类横向列表更新数据失败');     })   } }   export const getShopList = (category) => {   return (dispatch) => {     getShopListRequest(category).then(data => {       setTimeout(() => {         dispatch(changeRecommendList(data.data.data.items))         dispatch(changeEnterLoading(false));       },1000)     }).catch(() => {       console.log('数据传输错误');     })   } } 复制代码

将相关变量导出

import { shopReducer } from './reducer' import * as actionCreators from './actionCreators' import * as constants from './constants' export { shopReducer, actionCreators, constants }; 复制代码

组件连接Redux

import { shopReducer } from '../pages/shopping/store'; export default combineReducers({   shopping: shopReducer }); 复制代码

上拉刷新/下拉加载更多实现

在这里 Scroll 基础组件的作用就展现出来了。之前我们封装了 Scroll 组件,监听上拉 / 下拉刷新的功能已编写完成,核心代码如下

  // 顶部下拉刷新   const handlePullDown = () => {     pullDownRefresh(category, pageCount);   }      const pullDownRefresh = () => {     dispatch(actionTypes.changePullDownLoading(true));     dispatch(actionTypes.changeListOffset(0));     dispatch(actionTypes.refreshShopList(category));   }   // 滑到最底部刷新部分的处理   const handlePullUp = () => {     pullUpRefresh(category, pageCount);   }   const pullUpRefresh = (category, count) => {     dispatch(actionTypes.changePullUpLoading(true));     dispatch(actionTypes.refreshMoreShopList(listOffset, category, shopList));     dispatch(actionTypes.changePageCount(() => count + 1));   } //pullUp  上拉加载逻辑  //pullDown   下拉加载逻辑  //pullUpLoading  是否显示上拉 loading 动画  //pullDownLoading  是否显示下拉 loading 动画 //onScroll  滑动触发的回调函数 // scrollRef 操作DOM return (       <Scroll          onScroll={forceCheck}         pullUp={ handlePullUp }         pullDown = { handlePullDown }         ref={ scrollRef }         pullUpLoading = { pullUpLoading }         pullDownLoading = { pullDownLoading }       >         <div>           <RecommendList recommendList={shopList} />         </div>      </Scroll> ) 复制代码

完整代码在这里 最后来看下实现效果
image.png

商品详情页的开发


难点

详情页的难点有两个

  • 详情页是以路由跳转的方式实现的,怎么从拿到外面传过来的商品信息
    最好的做法是外部点击时根据商品独一无二的id值去请求接口,返回数据
    这里我偷了个懒,不想写接口呀????,这里我使用里 useContext 包裹上一层路由组件,实现了数据的传输,当然也可以用 redux 拿到数据,使用 useContext 也算是熟悉一下用法吧

    // 在 /shopping/index.jsx 下 // recommendShopList  推荐更多商品 // shopList 商品列表   export const ShopsContext = createContext();   return (      <ShopsContext.Provider value={{recommendShopList, shopList}}>      </ShopsContext.Provider>   ) 复制代码

    // 在 /shop/index.jsx 下 import { ShopsContext } from '../shopping'; import _ from 'lodash'; const ShopDetail = () => {     // 解构出数据     const { recommendShopList, shopList } = useContext(ShopsContext);     // 拿到页面跳转时的查询参数,这里我给的参数是商品的购买数量     const { id } = useParams();     // 引入lodash库的findIndex方法根据拿到的id值找在shoplist中到对应的商品信息的下标     const [index] = useState(_.findIndex(shopList, function(o) {return o.purchaseNum == id}));     let { loading, shopListDetail } = useSelector((state) => ({       loading: state.shopDetail.loading,       shopListDetail: state.shopDetail.shopListDetail     }));     useEffect(() => {       dispatch(actionTypes.goToDetail(shopList[index].imageArr));     },[]) } 复制代码

    这样就拿到商品的详情信息啦

  • 实现评论功能 根据  moment 和 lokijs 实现,具体教程看这

详情页的静态页面开发并不难,轮播图的制作可以看 swiper 的官网, 完整商品详情页代码看这里

项目的亮点和难点

  • 此次项目路由使用了 react-router V6 全新版本,前期看官方英文文档花费了一番功夫

  • 每个组件都应用 memo 包裹,使得 React 在更新组件之前进行 props 的比对,若 props 不变则不对组件更新,减少不必要的重渲染,使用 useCallback 优化父子组件函数的传递,使用 useMemo 优化父子组件对象/数组的传递

  • 坚守前端 MVVM 的设计思想,组件化,模块化思想

  • 解决刷新页面时 Tab 图片未选中的问题

image.png
分析问题,原因在于图片的状态丢失了,react-router 只保存的 NavLinkclassNameisActive 值,导致文字状态没变,但选中图片的状态回到了初始值,我的解决方法是通过引入redux 数据流 和 localstorage 让 Tab 标签的图片共享一个状态 index,部分代码如下:

image.png
由于篇幅,完整代码看这里

总结

回过头梳理一下,可以说是实打实的项目经验,更重要的是,我们将性能优化由理论展开了实践,并在大大小小的组件封装过程中潜移默化地让大家体会react hooks的各种应用场景,可以说对React技术栈的同学是一个很好的巩固,对于之前掌握其他技术栈的同学也是一次新鲜的经历。


作者:masterjediYYF
链接:https://juejin.cn/post/7054061869799063566

文章分类
代码人生
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐