前言
学习 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技术栈的同学是一个很好的巩固,对于之前掌握其他技术栈的同学也是一次新鲜的经历。