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

5,535 阅读9分钟

前言

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