严选全栈上新(React Hooks 实战开发入门)

3,499 阅读10分钟

前言

学习了一段时间的react,打算仿写了一个项目来练练手,也为春招做准备,接下来分享一下我的项目以及遇到的难点。

项目介绍

  • 技术栈 : React hooks + Redux + Koa

  • 使用 Redux 集中管理数据,Koa 搭建后台,Mockjs 模拟后端数据接口

  • 使用 styled-components 样式组件编写样式

  • 使用 React-Router v5 编写前端路由

  • 坚守 MVVM 、组件化、模块化思想,纯手写函数式组件编写页面

项目线上地址

线上地址

项目展示

QQ录屏20220221154724.gif

项目结构

├─ server                   // 后端
    ├─ Data                 // 数据 
    index.js          
├─ src
    ├─ api                  // 数据请求代码、工具类函数和相关配置
    ├─ assets               // 字体配置、静态资源
    ├─ baseUI               // 基础 UI 轮子
    ├─ common               // 可复用的 UI 组件
    ├─ components           // 各页面样式组件
        ├─ main             // 主页面 UI 组件
    ├─ layouts              // 布局
    ├─ pages                // 页面
        ├─ main             
            ├─ store        // 分仓库
    ├─ routes               // 路由配置文件
    ├─ store                // redux 相关文件、总仓库 
       App.css              // 全局样式、字体
       App.jsx              // 跟组件
       main.jsx             // 入口文件

前端部分

路由配置

  • routes中路由配置
import React, { lazy, Suspense } from 'react';
import BlankLayout from '../layouts/BlankLayout';
import { Redirect, Link } from 'react-router-dom';
const Main = lazy(()=> import('../pages/Main/Main'));
const Detail = lazy(() => import('../pages/details/Detail'));
import Tabbuttom from '../components/tabbuttom/Tabbuttom';

const SuspenseComponent = Component => props => {
    return (
        <Suspense fallback={null}>
            <Component {...props}></Component>
        </Suspense>
    )
}

export default [{
    component: BlankLayout,
    routes:[
        {
            path:'/',
            exact: true,
            render: () => < Redirect to = { "/home" }/>,
        },
        {
            path:'/home',
            component: Tabbuttom,
            routes: [
                {
                    path: '/home',
                    exact: true,
                    render: () => < Redirect to = { "/home/main" }
                    />,
                },
                {
                    path: '/home/main',
                    component: SuspenseComponent(Main),
                }
                ......
            ]
        },
        {
            path: '/detail',
            component: SuspenseComponent(Detail),
            routes: [
                {
                    path: '/detail/:id',
                    component: SuspenseComponent(Detail)
                }
            ]
        }
    ]
}]

通过 React.lazy 实现组件的懒加载。封装SuspenseComponent函数,通过使用 Suspense 标签将要进行 lazy(懒加载)的组件进行包裹,也就是加载过程中的行为,动态导入组件,优化交互。

  • 使用 renderRouter 渲染下级路由 为了使路由生效,在所需要开启子路由的地方使用 renderRoutes

App.jsx中代码:

import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from './routes/index'

function App() {

  return (
    <Provider>
      <div className='App'>
        <BrowserRouter>
          {renderRoutes(routes)}
        </BrowserRouter>
      </div>
    </Provider>
  )
}

export default App

image.png

这是我们项目的tabbar,通过Tabbuttom里的Link to来改变路由,在图标上写一个点击事件,点击时 dispatch 一个值从而改变store中默认的index值,就实现了页面的切换。

  • 注意我们需要根据页面当前的路由来确定页面位置,不然在别的页面一刷新,页面就又回到了首页(状态丢失) 。这里使用 useLocation 监听url地址变化来解决这个问题。以下是主要代码:
    // Tabbuttom.jsx
    ......
    const Bottom = (props) => {
    const { route, totalnum } = props
    const { pathname } = useLocation()
    const index = route.routes.findIndex(item => item.path === pathname) - 1
    console.log(props)
    const { setIndexDispatch } = props
    return (
        <>
            {/* 二级路由而准备 */}
            {renderRoutes(route.routes)}
            <ul className="Botton-warper">
                ......
                <li className="Botton-warper-warp" key="3"
                    onClick={() => { setIndexDispatch(2) }}>
                    <Link to='/home/cart' style={{ textDecoration: "none" }}>
                        <div className="icon">
                            {
                                index === 2 ? <img className='icon-img' src={CartIconActive} alt='' /> :
                                <img className='icon-img' src={CartIcon} alt='' />
                            }
                        </div>
                        <div className="planet" style={index === 2 ? { color: "#ec564b" } : {}} >
                            购物车
                            <HeadNumIcon display="" top="-0.92rem" left="1.5rem" totalnum={totalnum} />
                        </div>
                        
                    </Link>
                </li>
                .......
            </ul>
            {/* tabbar位置 */}
        </>
    )
}

const mapStateToProps = (state) => {
    console.log(state);
    return {
        totalnum: state.cart.totalnum,
        index: state.main.index
    }
}
// setIndex changeIndex

const mapDispatchToProps = (dispatch) => {
    return {
        setIndexDispatch(index) {
            dispatch(actionCreators.setIndex(index))
        }
    }
}

export default connect(mapStateToProps, mapDispatchToProps)(memo(Bottom))

移动端适配

使用lib-flexiblepostcss-pxtorempx单位转化为rem单位搭配实现移动端适配

  1. 安装lib-flexible postcss-pxtorem

    npm install postcss-pxtorem --save-dev npm install lib-flexible

  2. main.js文件中导入lib-fiexible

    import 'lib-flexible/flexible'

  3. 在根目录下建立postcss.config.js文件

module.exports = {
    "plugins": [
        require("postcss-pxtorem")({
            rootValue: 37.5,
            propList: ['*'],
            selectorBlackList: ['.norem']
        })
    ]
}

这样就完成了移动端适配啦🤗

数据流管理

本项目中我们拆分了reducer,每个页面都拥有一个独立管理状态的仓库 store ,然后再合并成一个总的store,每当我们在 store 上 dispatch 一个 actionstore 内的数据就会随之发生改变,数据驱动界面。

  • store/reducer.js
import { combineReducers } from 'redux'
import { reducer as mainReducer } from '../pages/Main/store/index'
import { reducer as cateReducer } from '../pages/Cate/store/index'
import { reducer as detailReducer } from  '../pages/details/store/index'
import { reducer as cartReducer } from '../pages/Cart/store/index'
import { reducer as userReducer } from '../pages/User/store/index'

export default combineReducers({
    main: mainReducer,
    cate: cateReducer,
    detail: detailReducer,
    cart: cartReducer,
    user: userReducer
});

Redux store 仅支持同步数据流。使用 thunk 等中间件可以帮助在 Redux 应用中实现异步性。

  • store/index.js
import thunk from 'redux-thunk';
import { createStore, compose, applyMiddleware } from 'redux';
import reducer from "./reducer";
// 使用 redux-devtolls-extensions 调试
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

其中一个页面结构如下:

├─ Main 
    ├─ store 
        actionCreators.js 
        constants.js 
        index.js 
        reducer.js
       Main.css
       Main.jsx
       
  • 初始化 state和 定义 reducer 函数
// reducer.js
import * as actionTypes from './constants';

const defaultstate = {
    maindata: [], // 页面数据
    index: 0 // 标记 tabbar 激活
}

const reducer = (state = defaultstate, action) => {
    switch (action.type) {
        case actionTypes.SET_INDEX:
            return {...state, index: action.data }
        case actionTypes.CHANGE_MAINDATA:
            return {...state, maindata: action.data }
        default:
            return state;
    }
}
export default reducer;
  • 定义 constans
// constans.js
export const CHANGE_MAINDATA = 'CHANGE_MAINDATA';
export const SET_INDEX = 'SET_INDEX';
// actionCreators.js
import * as actionType from './constants.js'
import { reqmain } from '@/api/index'

//主页数据
export const changeMainData = (data) => {
    return {
        type: actionType.CHANGE_MAINDATA,
        data: data
    }
}

export const setIndex = (data) => {
    return {
        type: actionType.SET_INDEX,
        data: data
    }
}

export const getMainData = () => {
    // dispatch 
    return (dispatch) => {
        reqmain()
            .then((res) => {
                // console.log(res)
                dispatch(changeMainData(res.data.data))
            })
            .catch((e) => {
                console.log('出错了')
            })
    }
}
// api/index.js
// 所有接口方法的列表
import Ajax from './ajax.js'

export const reqmain = () => {
    return Ajax('/home/main')
} 

然后就可以连接 Redux 了, 连接视图和数据层最好的办法是使用 connect 函数,本质上 Provider 就是给 connect 提供 store 用的。

// App.jsx
import { Provider } from 'react-redux'
import store from './store/index.js'
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from './routes/index'

function App() {

  return (
    // 使得每一个路由都可以提取到总仓库里面的数据
    <Provider store={store}>
      <div className='App'>
        <BrowserRouter>
          {renderRoutes(routes)}
        </BrowserRouter>
      </div>
    </Provider>
  )
}

export default App

接下来每个页面使用 connect 函数包裹组件,就可以使用store中的数据,也就是说页面就可以从后端接口的拿到想要的数据了。

import React, { useState, useEffect, memo } from 'react'
import { connect } from 'react-redux'

const Main = (props) => {
    
    return (
        <div className="main">
            ......
        </div>
    )
}
const mapStateToDispatch = (dispatch) => {
    return {
        getMainDataDispatch() {
            dispatch(actionTypes.getMainData())
        }
    }
}
const mapStateToProps = (state) => {
    return {
        maindata: state.main.maindata
    }
}
export default connect(mapStateToProps, mapStateToDispatch)(memo(Main))

页面开发

首页

QQ录屏20220222232511.gif

这里封装了一个Scroll组件,用Scroll包裹样式组件,就可以在手机上滑动了。 搜索框的跑马灯以及轮播图都是使用 Swiper7 实现的。

import { Swiper, SwiperSlide } from "swiper/react"
import { Autoplay } from 'swiper'
import 'swiper/css'
import 'swiper/css/autoplay'

<Swiper
    modules={[Autoplay]}
    autoplay={{ delay: 1000 }}
    direction="vertical"
    loop
    >
        {    
            searchPlaceholder.map((item, index) => {
                return (
                    <SwiperSlide key={index} className='home__search-placeholder'>
                        {item.text}</SwiperSlide>
                )
            })
        }
</Swiper>
  • 要修改默认样式,我们需要修改其默认标签的类名 .swiper-pagination-bullet 和 .swiper。
.swiper-pagination-bullets {
    bottom: -6px !important;
 }
.swiper-pagination-bullet {
    width: 0.6481rem  !important;
    height: 0.0926rem  !important;
    margin: 0 !important;
    border-radius: 10px !important;
}
.swiper-pagination-bullet-active {
    background-color: #f64949;
    height: 0.0926rem;
}
  
.swiper {
  --swiper-pagination-color: #fdb3a9;
  width: 9.388rem;
}

我们的商品数据是一次性请求20条,当滑动到最后一个商品的时候,这时候再上拉,会去向后台请求数据,然后商品会一直显示出来,这就需要用useEffect监听page ,上拉page会发生改变,然后向后台请求数据,就把商品列表显示出来。上拉加载商品列表时,我们做了防抖处理,防止短时间内大量的重复请求被发送。

const Main = (props) => {
    ......
    // 请求数据页数
    let [page, setPage] = useState(1)
    // 请求数据 加页
    const fetchList = () => {
        api
            .reqlist(page)
            .then(res => {
                // console.log(res);
                setList([
                    ...list,
                    ...res.data.data.list
                ])
            })
    }
    // 刷新数据
    const fetchListUpdate = () => {
        api
            .reqlist(page)
            .then(res => {
                setList([...res.data.data.list])
            })
    }
    useEffect(() => {
        if (!maindata.length) {
            getMainDataDispatch()
        }
        fetchList()
    }, [])

    useEffect(() => {
        fetchList()
    }, [page])

    // 上拉加载更多
    const handlePullUp = () => {
        console.log('上啦')
        setPage(++page)
    }
    // 下拉刷新
    const handlePullDown = () => {
        console.log('下拉刷新')
    }
    const handleOnclick = () => {
        setType(type + 1)
    }
    useEffect(() => {
        fetchListUpdate()
    }, [type])

分类页面

QQ录屏20220222233332.gif

我们项目的分类页面就是一个兄弟组件传值的问题,在父组件中useState一个值作为默认值然后通过prop传给两个子组件,点击左边组件的menu不同选项来改变默认值,MVVM数据双向绑定,右边的数据就跟着改变了。

<div className="cate-menu">
                    {
                        cateMenu.map((item, index) => {
                            const active = item.id === curNav
                            return (
                                <div key={index} onClick={setCurNav.bind(null, index)} className={classNames("cate-menu__item", active && "cate-menu__item--active")}>
                                    <p className={classNames("cate-menu__item-name", active && "cate-menu__item-name--active")}>
                                        {item.text}
                                    </p>
                                </div>
                            )
                        })
                    }
                </div>

购物车页面

QQ录屏20220222233512.gif

我们用redux数据流来实现购物车,购物车进行的每一个操作都 dispatch 一个 action ,然后store中的数据就随之发生改变。以下是购物车的逻辑代码:

// Cart/store/pai.js
import { floatAdd } from "../../../api/utils"
// 解决小数精度问题
export const change_logo = (cartItem, cartdata = []) => {
    const {id} = cartItem
    let index = cartdata.findIndex((item) => item.id == id)
    cartdata[index].isChecked = ! cartdata[index].isChecked
    return cartdata
}
// 总价格
export const allmoney = (cartdata) => {
    let arr = cartdata.filter(item => item.isChecked)
    return arr.reduce((sum, cur) => floatAdd(sum, cur.price * cur.num), 0)
}
// 减
export const reduce_num = (id, cartdata) => {
    let index = cartdata.findIndex((item) => item.id == id)
    cartdata[index].isChecked = true;

    // 业务,当商品的num变为1时,不能再减少,把按钮改为disabel:false
    if (cartdata[index].num == 1) {
            cartdata[index].isChecked = false
            return cartdata
    }
    cartdata[index].num--;
    if(cartdata[index].num == 1)  cartdata[index].isChecked = false
    return cartdata
}
// 加
export const add_num = (id, cartdata) => {
    let index = cartdata.findIndex((item) => item.id == id)

    // 业务逻辑 点击数量增加后,isChecked改为true
    cartdata[index].isChecked = true;
    cartdata[index].num++;
    return cartdata
}

export const change_num = (data,cartdata) => {

    let {num,id} =data
    let index = cartdata.findIndex((item) => item.id == id)
    
    cartdata[index].num = num
    cartdata[index].isChecked = true
    return cartdata
}

export const allSelected = (cartdata) => {
    // 判断如果index == -1 则全选
    let index = cartdata.findIndex((item) => !item.isChecked)
    if(index == -1) return true
}
// 全选
export const SelectedAll = (cartdata) =>{
    let index = cartdata.findIndex((item) => !item.isChecked) 
    if(index == -1) {    // 表示全选
        cartdata.map(item => 
        item.isChecked = !item.isChecked)
    }
    else{ // 没选或部分选
        cartdata.map(item => 
          item.isChecked = true)
    }
    return cartdata
}
// 去到购物车界面
export const goToCart = (data,cartdata) =>{
    return cartdata;
}
// 删除
export const deleteItem = (id,data) =>{
    let index = data.findIndex(item => item.id == id)
    data.splice(index,1)
    return data
}

// 详情页点击进入购物车
export const goToCart_btn = (data,cartdata) =>{
    let {id} = data
    let index = cartdata.findIndex(item => item.id == id)

    if(index == -1){
        cartdata.push(data)
        console.log(cartdata);
        return cartdata
    }
   return cartdata
}

// tabbar 的总数量
export const totalnum = (data) =>{
    let num = data.reduce((acc,item) => acc+item.num,0)
    return num
}

有兴趣的点这里可以详看源码嗷

  • 在做购物车总价格时遇到浮点数精度丢失问题 这里使用了mathjs库解决精度丢失问题。
import * as math from 'mathjs'
const floatAdd = (arg1, arg2) => {
  // 加 解决精度问题
  const ans = math.add(arg1, arg2)
  return math.format(ans, {precision: 14})
}

后端部分

Koa搭建后台

后端我们需要的部分数据以json格式存储,另外使用 mockjs 来模拟一部分数据,为了解决跨域问题,我们使用了 cors

// server/index.js
const Koa = require('koa')
const router = require('koa-router')()
const app = new Koa()
const MainData = require('./Data/mainData/mainData.json')
const cors = require('koa2-cors')
const Mock = require('mockjs')
const Random = Mock.Random

app.use(cors({
    origin: function(ctx) { //设置允许来自指定域名请求
        // if (ctx.url === '/test') {
        return '*'; // 允许来自所有域名请求
        // }
        // return 'http://localhost:3000'; //只允许http://localhost:8080这个域名的请求
    },
    maxAge: 5, //指定本次预检请求的有效期,单位为秒。
    credentials: true, //是否允许发送Cookie
    allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], //设置所允许的HTTP请求方法
    allowHeaders: ['Content-Type', 'Authorization', 'Accept'], //设置服务器支持的所有头信息字段
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'] //设置获取其他自定义字段
}))

router.get('/home/main', async (ctx) => {
    ctx.response.body = {
        success: true,
        data: MainData
    }
})
router.get('/home/list', async (ctx) => {
    let { limit = 20, page = 1 } = ctx.request.query
    // console.log(limit, page);
    let data = Mock.mock({
        'list|20': [{
            'id': '@increment',
            'title': '@ctitle(12, 15)',
            'price': '@float(60, 1000, 0, 1)',
            'imgsrc': Random.image('160x160')
        }]
    })
    ctx.body = {
        success: true,
        data
    }
})
router.get('/detail/:id', async (ctx) => {
    // console.log(ctx.params);
    const { id } = ctx.params;
    if (!id) {
        ctx.response.body = {
            success: false,
            mag: '请求数据'
        }
    }
    // to be continue
    ctx.response.body = {
        success: true,
        data: Mock.mock({   // 详情页数据
            id,
            title: '@ctitle(5, 10)',
            price: '@float(60, 1000, 0, 2)',
            rate: '@float(60, 100, 0, 1)',
            desc: '@csentence(6, 12)',
            attrValue: '@ctitle(2,6)'
        }), DetailData
    }
})

app
    .use(router.routes())
    .use(router.allowedMethods())
// 1. http服务
// 2. 简单的路由模块
// 3. cors
// 4. 返回数据
app.listen(9000, () => {
    console.log('server is running 9000');
})

优化

alias

当项目逐渐变大之后,文件与文件直接的引用关系会很复杂,这时候就需要使用 alias 了,src 都使用 @来替代,可以有效提高开发效率。

// 未配置alias
import MainBanner from '../../components/main/mainBanner/MainBanner'

// 配置alias后
import MainBanner from '@/components/main/mainBanner/MainBanner'

alias配置如下:

// 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()],
  alias: {
    '@': path.resolve(__dirname, './src')
  }
})

memo

通过React.memo包裹的组件props相同的情况下,会复用最近一次执行的结果,React.memo帮我们缓存了组件,避免组件不必要的重复渲染。

import { memo } from 'react'

const Main = () => {}

export default memo(Main)

懒加载

懒加载也叫延迟加载,指的是在长网页中延迟加载图像,是一种非常好的优化网页性能的方式。

当可视区域没有滚到资源需要加载的地方时候,可视区域外的资源就不会加载。避免一次性加载过多的图片导致请求阻塞,可以减少服务器负载,这样就可以提高网站的加载速度,提高用户体验。

  • 使用 react-lazyload 库懒加载图片
import LazyLoad from 'react-lazyload'
import loading from '@/assets/loading.gif'

<div className="ListItem-content__img">
    <LazyLoad style={{ 'height': '160px', 'width': '160px' }}   
        placeholder={<img width="100%" height="100%" src={loading} alt=""/>}>
        <img style={{ 'borderRadius': '9px' }} src={item.imgsrc} alt="" />
    </LazyLoad>
</div>
  • 使用 lazy 和 suspense 来实现懒加载组件
import React, { lazy, Suspense } from 'react';
const Main = lazy(()=> import('../pages/Main/Main'));

const SuspenseComponent = Component => props => {
    return (
        <Suspense fallback={null}>
            <Component {...props}></Component>
        </Suspense>
    )
}
    .......
    {
        path: '/home/main',
        component: SuspenseComponent(Main),
    }
    .......

总结

第一次实战一个较完整的react项目,虽然业务还不是很齐全,但是还是收获蛮大的!也是对这段时间学习的一个总结,希望也能帮助到掘友们噢!!

源码