美好的一天从美食开始,手把手教你点外卖:React Hooks + Redux 仿美团外卖实战项目

1,031 阅读15分钟

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

前言

夏日炎炎,现在的每天气温都高达30度+,想必大家都不愿意出门暴晒,小编给大家准备美团外卖 0.0.1版,欢迎大伙下单,骑手小哥给大伙送货上门哟,我给大家打骨折八折。

咳咳,言归正传,笔者最近学习了最新的react hooks + redux,由于最近疫情问题,每天都点外卖,点多了,有了感情,于是想着要不自己也整个。所以就开发了一个破解版美团外卖,下单不用钱,欢迎大伙来捧个场子(手动狗头)。

在线预览项目地址

技术栈总览

  • react全家桶:react、react-router、react-router-dom
  • redux:Redux 是 JavaScript 状态容器,提供可预测化的状态管理
  • redux-thunk:用最简单的方式搭建异步 action 构造器,属于redux生态系统的中间件
  • axios:Axios,是一个基于promise的网络请求库,作用于node.js浏览器中,常用来获取数据
  • classnames:一个简单的 JavaScript 实用程序,用于有条件地将类名连接在一起。
  • font-awesome:字体图标库
  • react-lazyload:顾名思义,react中的懒加载库
  • react-weui与antd-mobile:好用的UI组件库
  • styled-components:react官方都觉得好用,称之为css in js,用来编写css样式
  • swiper:移动端最常用的轮播图组件库

项目初体验

先给大伙安利一下,认个眼熟哈哈

  • 白天的首页

home2.gif

  • 商品详情页

homeOrder.gif

  • 购物车

homeOrder2.gif

  • 商品评价和店家信息

HomeDetail.gif

项目起步

1. 项目结构

├─ public                             
├─ src
    ├─api                   // 网路请求及相关配置
       config.js            // 接口配置文件
       request.js           // 接口请求文件
       adapter.js           // 移动端自适应配置 
       utils.js             // 封装的工具函数
    ├─assets                // 字体图标、图片等静态资源
    ├─components            // 可复用的 UI 组件
        ├─common            // 通用组件
    ├─pages                 // 页面级别组件
        Home                // 首页
        Cities              // 位置详情
        HomeDetail          // 商品详情
        Order               // 订单
        Mine                // 我的
        Search              // 搜索
    ├─routes                // 路由配置文件
    ├─store                 // redux总仓库
    ├─styles                // css初始化样式
    └─utils                 // 全局的工具函数
App.jsx                     // 根组件
main.jsx                    // 入口文件

2. 路由配置

在最开始配置路由时,是直接在根组件引入Route,可随着路由数量的增多,就有必要对路由进行单独管理。于是我在routes目录封装了RoutesConfig函数,在根组件引入,并在RoutesConfig组件外层嵌套Suspense组件。

PS:在路由配置时可以使用react自带的lazy和Suspense对页面进行懒加载,这样页面相当于按需加载,只有你使用它的时候才会加载它,可以减少首页白屏时间。

  • 根组件具体代码如下

image.png

  • RoutesConfig组件
// 独立配置文件 
import { lazy} from 'react'
import { Routes, Route, Link } from 'react-router-dom'
import Home from '@/pages/Home'
const Order = lazy(() => import('@/pages/Order'))
const Mine = lazy(() => import('@/pages/Mine'))
const Cities = lazy(() => import('@/pages/Cities'))
const Search = lazy(() =>import('@/pages/Search'))
const HomeDetail = lazy(() => import('@/pages/HomeDetail'))
const HomeOrder = lazy(() => import('@/pages/HomeDetail/HomeOrder'))
const HomeComment = lazy(() => import('@/pages/HomeDetail/HomeComment'))
const HomeBusiness = lazy(() => import('@/pages/HomeDetail/HomeBusiness'))

const RoutesConfig = () => {
    return (
        <Routes>
            <Route path="/" element={<Home /> }></Route>
            <Route path="/home" element={<Home />}></Route>
            <Route path="/order" element={<Order />}></Route>
            <Route path="/mine" element={<Mine />}></Route>
            <Route path="/cities" element={<Cities />}></Route>
            <Route path="/search" element={<Search/>}></Route>
                <Route path="/homedetail/:id" element={<HomeDetail />}>
                    <Route path='/homedetail/:id/order' element={<HomeOrder/>}></Route>
                    <Route path='/homedetail/:id/comment' element={<HomeComment/>}></Route>
                    <Route path='/homedetail/:id/business' element={<HomeBusiness/>}></Route>
                </Route>
        </Routes>
    )
}

export default RoutesConfig

3. 数据接口

众所周知,在项目开发的初期,有一个完善的后端接口真是美事啊。在这里给大家安利一个别人给我安利的好插件。

JSON-Handle:对于杂乱的JSON文件进行解析编写,得到你想要的JSON数据文件;大伙可以在F12开发者工具里的Netword栏里,再筛选fetch/xhr请求找到可以用的数据文件。

可是大伙们,俺是在项目快搞定的时候才记得有这儿一回事o(╥﹏╥)o(手扒了整个项目的数据)。有没有专业的大哥告诉俺咋怎么快速获取有用的接口数据,可以在评论区告诉小编

本项目的数据是在FastMock上存储的,这是一个很好用的免费模拟接口网站

image.png

然后在api目录封装了request.js,用以保存对所有接口的引入

4. 使用redux对数据流进行管理

到了这里准备工作已经就绪了,开始项目开发了。
redux官方文档中,我们可以得知什么时候该用redux来管理你的项目state。有一位古希腊的哲学家说过:当你在犹豫要不要使用它的时候,那你还是先别用它。不过我犹豫了我也用就是

image.png

  1. 首先在src目录下新建一个store文件夹,里面存储着本项目唯一的store仓库和reducer函数。

     Redux 应用只有一个单一的 store。当需要拆分数据处理逻辑时,你应该使用 reducer 组合 而不是创建多个 store。 就跟我们学习的二叉树、多叉树一样,每一个单页面就是一个分叉,在reducer中combineReducers一下,就绘制成了一颗完整的树

    • redux状态管理 image.png

    • store代码如下

      import { createStore, compose, applyMiddleware } from 'redux'
      import thunk from 'redux-thunk'
      import reducer from './reducer'
      const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
      const store = createStore(reducer,
          composeEnhancers(
              applyMiddleware(thunk)
          ))
      export default store
      
    • reducer代码如下

      
      import {combineReducers}  from 'redux'
      import {reducer as CitiesReducer} from '@/pages/Cities/store/index'
      import {reducer as HomeReducer} from '@/pages/Home/store/index'
      import {reducer as SearchReducer} from '@/pages/Search/store/index'
      import {reducer as HomeDetailGoodsReducer} from '@/pages/HomeDetail/HomeOrder/store/index'
      import {reducer as HomeDetailCommentsReducer} from '@/pages/HomeDetail/HomeComment/store/index'
      import {reducer as HomeDetailBusinessReducer} from '@/pages/HomeDetail/HomeBusiness/store/index'
      
      export default combineReducers({
          cities:CitiesReducer,
          home:HomeReducer,
          search:SearchReducer,
          comment:HomeDetailCommentsReducer,
          business:HomeDetailBusinessReducer,
          goods:HomeDetailGoodsReducer
         
      })
      
  2. store.js中使用的第三方包或函数

    • redux-thunk 可以实现redux处理异步action
    • applyMiddleware: 顾名思义,这是对于中间件的引用,通过compose函数可以将多个中间件合并为一个中间件对象
    • const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose:可以让浏览器插件 Redux DevTools 生效,以便开发人员模式运行应用。
  3. 在各个页面创建子仓库,每一个子仓库就是一个分支。

    拿首页举例

    ├─Home                        // 首页  
        store                     // 仓库
           actionCreators.js      // action纯函数
           constants.js           // 保存着action的type
           index.js               // 子仓库本身
           reducer.js             // reducer指定了应用状态的变化如何响应actions并发送到store的
    

    这样方便对每一个路由页面进行数据流管理,而不用在总仓库中堆积

  4. 最后,在入口文件main.js中引入总store仓库

    我们要先在根组件<APP/>最外层包裹一层react-redux提供的Provide声明。这样每次store数据改变时才能通知到每个组件。

       import React from 'react'
       import ReactDOM from 'react-dom/client'
       import App from './App'
       import { BrowserRouter } from 'react-router-dom'
       // 引入初始化css样式文件
       import '@/styles/reset.css'
       // 引入字体图标
       import 'font-awesome/css/font-awesome.min.css'
       import 'swiper/dist/css/swiper.min.css'
       // 使用redux
       import { Provider } from 'react-redux'
       import store from './store'
    
       ReactDOM.createRoot(document.getElementById('root')).render(
         <Provider store={store}>
           <BrowserRouter>   
               <App />
           </BrowserRouter>
         </Provider**>**
       )
    
  5. 对了,在组件中使用redux管理好的数据需要做好这两点

    1. 还是那首页举例,首先来创建容器组件把这些展示组件和 Redux 关联起来。技术上讲,容器组件就是使用 store.subscribe() 从 Redux state 树中读取部分数据,并通过 props 来把这些数据提供给要渲染的组件。
    2. 所以我们可以使用react-redux库的 connect() 方法来生成容器组件,这个方法做了性能优化来避免很多不必要的重复渲染
    • 模板代码如下
    import { getRestaurantsList, getBannersList } from './store/actionCreators'
    
       function Home(props) {
         const { restaurants, banners, loading } = props
         const { getRestaurantsListDispatch, getBannersListDispatch } = props
     
          useEffect(() => {
               getBannersListDispatch()
               getRestaurantsListDispatch()
             }, [])
             
             return(
             ...
             )
     }
        const mapStateToProps = (state) => {
            return {
               banners: state.home.BannersList,
               restaurants: state.home.RestaurantsList,
               loading: state.home.Loading
         }
       }
       const mapDispatchToProps = (dispatch) => {
         return {
           getBannersListDispatch() {
             dispatch(getBannersList())
       },
           getRestaurantsListDispatch() {
             dispatch(getRestaurantsList())
       },
         }
       }
    
       export default connect(mapStateToProps, mapDispatchToProps)(Home)
    

    其中的connect方法的两个参数:

    • mapStateToProps:状态树,允许组件对store进行读操作的函数。
    • mapDispatchToProps:对store进行写操作的派发函数.

5. 首页的实现

首页是长这个样子的

image.png

5.1. 我们来拆分一下,看看一个完整的单页面由几个组件构成。

    ├─pages                 // 页面级别组件
        Home                // 首页
           Banners          // 广告组件
           CitySelect       // 选择城市组件
           Search           // 搜索组件
           Modal            // 模态框组件
           SetMeal          // 轮播图组件
           StoreList        // 商家排序列表组件
           StoreInfo        // 全部商家信息组件        

哇塞!一个小小的单页面就用了7个组件,集齐七颗龙珠了属于是。

不过这里也体现了React的强大。React 认为渲染逻辑本质上与其他UI逻辑内在耦合,比如,在 UI 中需要绑定处理事件、在某些时刻状态发生变化时需要通知到 UI,以及需要在 UI 中展示准备好的数据。这样的组件可以很好的复用。

5.2.然后我们对城市选择组件分析一下

// 子组件一般不做数据请求 由父组件统一并传参过来
export default function CitySelect(props) {
  let { cityName } = props

cityName =='' && window.sessionStorage.getItem("cityName") ? cityName = window.sessionStorage.getItem("cityName") : (window.sessionStorage.cityName = cityName)

  return (
    <Wrapper>
      <Link
        className="citygps"
        to="/cities">
        <i className="icon_city"></i>
        <span>{cityName ? cityName : '获取城市坐标'}</span>
        <i className="  next"></i>

      </Link>
    </Wrapper>
  )
}

很简单的逻辑,点击获取城市坐标,跳转到Citis页面,选择你所在的位置,然后返回首页。

亮点:在这个对于城市名cityName进行了持久化,保存在了浏览器的sessionStorage中,跳转到其他页面,再返回首页时城市位置不变。

5.3. 再看看搜索框组件

这里是使用了微信的weui里面的搜索组件,添加了一个点击事件,点击跳转到搜索页面

        <SearchBar
            placeholder="请输入商家或者商品名称"
            lang={{ cancel: '取消' }}
            className='search'
          />

实现效果如下:

search.gif

具体的功能还未完善,比如搜索关键词,页面出现相应的热词,点击跳转到商品详情页

5.4.首页的一些小功能

  • 通过绑定滑动事件,页面滑动时,搜索框和商家排序组件会固定在顶部,还有滑动到底部出现backTop图标,可以点击它,返回页面顶部

实现效果如下

home3.gif

这是封装了一个工具函数,代码如下

export const isFixed = (str, height) => {
   let box = document.querySelector(str)
   let fn = function () {
      var scrollTop = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop;
      // console.log(scrollTop);
      if (scrollTop > height) {
         box.classList.add('fixed')
      } else {
         box.classList.remove('fixed')
      }
   }
   window.addEventListener('scroll', fn)

}
  • 然后就是点击综合排序,出现modal,并且页面会立刻吸顶,然后再次点击遮罩层或者综合排序都可以结束状态

实现效果如下:

modal.gif

具体代码点这里

6. 商家详情页

商家详情页是这个样子的

image.png

6.1. 老规矩还是先给大家看看页面结构


    ├─HomeDetail                  // 页面级别组件
        HomeBussiness            // 商家信息
        HomeComment              // 评论
        HomeOrder               // 点单

在这里是使用二级路由,由上文可知,点餐、评价、商家三个先是共用了HomeDetail组件的头部, 然后三个页面通过tap栏切换,跳转到不同的路由。

具体代码点这里哟

6.2. 最重要的就是HomeOrder组件,里面有较多的state状态管理

在这里的功能我是参照正版的美团外卖

  • 点击 + 号,实现商品数量总价的更新,还有购物车的样式变化,并且在购物车商品数量大于0的时候,才可以点击小人(外卖小哥)弹出购物车详情页,展示你选购的商品信息 部分代码如下:
function HomeOrder(props) {
  // 商品数量加减
  const changeGoodNum = (e, status, id) => {
    e.preventDefault();
    e.stopPropagation();
    let data = {
      status: status,
      id: id
    }
    changeGoodsNumDispatch(data)
  }
 //小红点数量的变化
  const cartNumber = () => {
    let num = 0
    details.map((item) => {
      if (item.name != '热销') {
        item.spus.map((ele) => {
          num += ele.praise_num
        })
      }
    })
    return num
  }

    
//总价的计算
const mapStateToProps = (state) => {
  let arr = []
  state.goods.GoodsList.forEach((item) => {
    if (item.name != '热销') {
      item.spus.forEach((item) => {
        let price = 0
        price += (item.praise_num > 0 ? item.min_price * item.praise_num : 0)
        arr.push(price)
      })
    }
  })
  return {
    price: arr.reduce((pre, curr) => pre += curr, 0)
  }
}

  • 点击 - 号 ,也是实现商品数量总价的更新,并且在购物车商品数量==0的时候,-号会隐藏,同时还有购物车的样式也会变化

    部分重要代码如下:

const reducer =  (state = defaulState, action) => {
    let val = state.GoodsList
    let changeList = []
    switch (action.type) {
        // +-
        case actionTypes.CHNAGE_GOODS_NUM:
            for (let i = 0; i < val.length; i++) {
                changeList.push(val[i].spus)
            }
            changeList.map((item) => {
                item.map((item) => {
                    if (item.id == action.data.id) {
                        if (action.data.status == 'add') {
                            item.praise_num++
                            state.SingleCart.push(item)
                        } else {
                            item.praise_num--
                            state.SingleCart.pop()
                        }
                    }
                })
            })
            return Object.assign({}, state, { GoodsList: [...val] })
        default:
            return state
    }
}
export default reducer

具体代码点这里哟

  • 购物车详情:在购物车商品数量大于0的时候才会存在,本质是个modal,可以通过点击遮罩层或者那个小人实现隐藏显示;还有清空购物车的功能,点击会清除购物车的商品信息总价,还有清空商品列表的状态
  //清空购物车
  const clearCart = () => {
    changeGoodsAllNumDispatch()
  }
  const reducer =  (state = defaulState, action) => {
    let val = state.GoodsList
    let changeList = []
    switch (action.type) {
        //清除每个商品的数量
        case actionTypes.CHNAGE_GOODSAll_NUM:
            for (let i = 0; i < val.length; i++) {
                changeList.push(val[i].spus)
            }
            changeList.map((item) => {
                item.map((item) => {
                    item.praise_num = 0
                })
            })
            state.SingleCart = []
            return Object.assign({}, state, { GoodsList: [...val] })
        default:
            return state
    }
}
export default reducer

具体代码点这里哟

  • 还有就是左侧的sideBar,点击会实现双联动效果,并且对应的样式会改变,可以说是点哪里亮哪里
 // 点击 获取右侧商品的id 然后scrollIntoView事件方法滚动到对应位置
  const scrollToAnchorLeft = (anchorName) => {
    if (anchorName) {
      let anchorElement = document.getElementById(anchorName)
      anchorElement && anchorElement.scrollIntoView({
        block: 'start',
        behavior: 'smooth'
      })
    }
    return true
  }
  

补充:

  • 在购物车这里,我在reducer函数中定义了SingleCart作为选购商品的的状态容器,无论 + -都是GoodsList(整个商品列表)的状态变化使得SingleCart的状态变化。所以在HomeOrder组件中的商品列表的添加商品、减少商品都会使得购物车组件的数据变化

  • + -按钮处定义了点击事件函数,点击会changeGoodNum函数,这就会引起GoodsList状态的变化,通过新旧状态变化将新数据重新渲染到页面上

  • 在这里还采用了自顶向下的数据流,即父组件(HomeOrder组件)传递给子组件(ShoppingCart组件)的;

实现效果如下:

homeOrder3.gif

6.3.评价和商品详情组件

  1. HomeComment组件的亮点在于使用了tap栏切换,并且不同的tap栏展示的内容会经过筛查渲染到页面

  2. HomeBussiness组件没有亮点

实现效果如下:

comment.gif

项目优化

1.图片懒加载

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

2.组件性能优化memo

如果你的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。

给常使用的组件添加此组件,可以减少消耗

    const Home = () => {}

    export default React.memo(Home)

3. 路由懒加载

  1. React.lazy 函数能让你像渲染常规组件一样处理动态引入(的组件)。此代码将会在组件首次渲染时,自动导入包含 OtherComponent 组件的包。

  2. React.lazy 接受一个函数,这个函数需要动态调用 import()。它必须返回一个 Promise,该 Promise 需要 resolve 一个 default export 的 React 组件。

  3. 然后应在 Suspense 组件中渲染 lazy 组件,如此使得我们可以使用在等待加载 lazy 组件时做优雅降级(如 loading 指示器等)。

具体代码上文已经实现,就不再重复了

4. 移动端页面自适应 rem

因为本项目是移动端的美团外卖,为了更好的适配移动端屏幕大小,本项目中采用了rem布局。

  • 首先就是要设置HTMLfont-size大小
    var init = function () {
    var clientWidth = document.documentElement.clientWidth || document.body.clientWidth;
    if (clientWidth >= 640) {
      clientWidth = 640;
    }
    var fontSize = 16 / 375 * clientWidth;
    document.documentElement.style.fontSize = fontSize + "px";
    }
    
    init();
    
    window.addEventListener("resize", init);
    ```
    

如上所示,封装了一个工具函数,在页面大小变化的时候可以自动的计算出HTMLfont-size大小,从而达到适配不同屏幕尺寸的手机

5.路径别名

相信大家都用过这样的别名设置'@/components/common/Scroll'。 这是为了避免一长串的../../../来引入不同目录下的文件,可能会导致文件之间的相对路径的引用错误,我们可以在vite.config.js中添加一下代码。

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve:{
    alias:{
      "@":path.join(__dirname,'src')
    }
  },
  base:'./'

最后再给大伙推荐一个好用的开发插件

Path Autocomplete:在VSCode扩展中即可下载。

    1. 作用: 在vscode中使用@提示路劲
    1. 配置: 打开vscode设置 搜索 settings.json 添加以下代码
     // 导入文件时是否携带文件的拓展名
    "path-autocomplete.extensionOnImport": true,
    // 配置@的路径提示
    "path-autocomplete.pathMappings": {
    "@": "${folder}/src"
    },
    
  • 3.效果如下:

image.png

总结

  1. 整个项目还有许多功能没有实现的,有点遗憾
    • 搜索页面的模糊搜索
    • 订单页面的数据管理
    • 购物车点击结算后,依照正版的美团外卖还有个Payment页面组件
    • 我的页面的注册登录功能
  2. 项目中还存在一些bug,在未来解决
    • HomeOrder商品列表页问题:每个商品会有初始数量,所以需要先清空购物车再进行购买商品,这不符合事实逻辑
    • 首页的页面滚动问题:在点击综合排序遮罩层出现后,还是可以滑动底下的页面
    • 还有就是项目的页面加载时间太长了:在切换路由时,白屏等待时间过长

以上的几点,对于笔者来说是鞭策亦是动力。对于没有完成的功能,在不久的将来,我将逐一实现,并将这个项目发展为全栈项目(笔者在努力学习node+koa) , 为的就是让大伙在家里也能吃上我做的外卖,哦不,用上我的美团外卖0.0.1(手动狗头)

最后

大伙都看到这里啦,如果对于你来说也有帮助的话,不妨点赞评论加关注一波,这对于我来说真的很重要!!!
还有就是如果有小伙伴在这上面下单成功,记得给我打电话,我会去二道口捞你的(手动狗头)