前言
学习了一段时间的react,打算仿写了一个项目来练练手,也为春招做准备,接下来分享一下我的项目以及遇到的难点。
项目介绍
-
技术栈 : React hooks + Redux + Koa
-
使用 Redux 集中管理数据,Koa 搭建后台,Mockjs 模拟后端数据接口
-
使用 styled-components 样式组件编写样式
-
使用 React-Router v5 编写前端路由
-
坚守 MVVM 、组件化、模块化思想,纯手写函数式组件编写页面
项目线上地址
项目展示
项目结构
├─ 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
这是我们项目的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-flexible
和postcss-pxtorem
将px
单位转化为rem
单位搭配实现移动端适配
-
安装
lib-flexible postcss-pxtorem
npm install postcss-pxtorem --save-dev npm install lib-flexible
-
在
main.js
文件中导入lib-fiexible
import 'lib-flexible/flexible'
-
在根目录下建立
postcss.config.js
文件
module.exports = {
"plugins": [
require("postcss-pxtorem")({
rootValue: 37.5,
propList: ['*'],
selectorBlackList: ['.norem']
})
]
}
这样就完成了移动端适配啦🤗
数据流管理
本项目中我们拆分了reducer
,每个页面都拥有一个独立管理状态的仓库 store ,然后再合并成一个总的store,每当我们在 store
上 dispatch
一个 action
,store
内的数据就会随之发生改变,数据驱动界面。
- 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))
页面开发
首页
|
这里封装了一个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])
分类页面
|
我们项目的分类页面就是一个兄弟组件传值的问题,在父组件中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>
购物车页面
|
我们用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项目,虽然业务还不是很齐全,但是还是收获蛮大的!也是对这段时间学习的一个总结,希望也能帮助到掘友们噢!!