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 复制代码
首先分析页面的整体布局
可以发现只有底部的 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 提供的 Suspense
和 Lazy
实现了动态路由
简单说一下这里为什么要使用动态路由:
对于大型应用来说,一个首当其冲的问题就是所需加载的 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); 复制代码
以上是部分代码,完整代码看这。通过 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 代码看这里
现在就可以滑动了,对于引入的Scroll
组件是我的小伙伴负责开发,他的文章详细介绍了Scroll
组件的详细开发,Scroll
组件也是这个项目的灵魂
搜索框
搜索框的制作比较简单,直接上地址
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> ) 复制代码
完整代码在这里 最后来看下实现效果
商品详情页的开发
难点
详情页的难点有两个
详情页是以路由跳转的方式实现的,怎么从拿到外面传过来的商品信息
最好的做法是外部点击时根据商品独一无二的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 图片未选中的问题
分析问题,原因在于图片的状态丢失了,react-router 只保存的 NavLink
中 className
的 isActive
值,导致文字状态没变,但选中图片的状态回到了初始值,我的解决方法是通过引入redux
数据流 和 localstorage
让 Tab 标签的图片共享一个状态 index
,部分代码如下:
由于篇幅,完整代码看这里
总结
回过头梳理一下,可以说是实打实的项目经验,更重要的是,我们将性能优化由理论展开了实践,并在大大小小的组件封装过程中潜移默化地让大家体会react hooks的各种应用场景,可以说对React技术栈的同学是一个很好的巩固,对于之前掌握其他技术栈的同学也是一次新鲜的经历。
作者:masterjediYYF
链接:https://juejin.cn/post/7054061869799063566