写在前面
本项目来源于黑马的公开课,比较简单,适合对于react半知半解的小白,本文也会面向小白去讲解,目的是帮助大家更好的理解react,更好的使用react,相信对大家会有所帮助【,,ԾㅂԾ,,】。
诸位,让我们步入正题
一、项目功能展示
1.拉取并启运行项目
源码GitHub地址 github.com/Objecteee/B…
在随便一个文件夹的终端运行下面代码即可拉取项目并运行
git clone https://github.com/Objecteee/Bill.git //拉取项目
cd Bill // 进入项目文件夹
npm i // 下载项目依赖
npm run server // 运行数据库
// -------------------------------
// 此时重新打开一个项目的终端
npm start //启动项目
设备窗口调整为iPhone SE体验更佳
2.功能展示——月度账单查看
该功能可以查看用户每月的收入支出等账单详情,并且可以看到具体是哪一天的消费
3.日消费明细查看
该功能可以查看用户的日收入支出明细
4.添加账单
该功能可以让用户添加收入和支出,并可以选择收入支出项和日期
上述功能是不是很简单?但是其中蕴含的知识点是海量的,下面我将如抽丝剥茧带大家解析这个项目
二、项目要点解析
1.项目结构设计
下面是核心目录结构
我只简单的介绍一下
components
:存放可复用的 React 组件,例如Icon
可能存放图标组件。constants
:用于存放项目中的常量,这里比较简单,文章中不会讲img
:存放项目中使用的图片资源,如月度背景图.jpg
。pages
:存放应用的页面组件,例如Layout
可能是页面布局组件,Month
、New
和Year
可能是不同功能的页面组件。router
:存放路由配置文件,如index.js
可能定义了应用的路由规则。store
:存放 Redux 状态管理相关的文件,如index.js
配置了 Redux store,modules
目录下的billStore.js
定义了账单数据的 reducer 和 actions。
2.项目组件设计
我以截图的形式,形象的给大家展示组件结构
3.项目路由设计
这一部分,我会讲解项目中的路由设计,该项目的路由设计是比较清晰的
分为一级路由和二级路由,一级路由包括New、Layout组件,一级路由New下包含二级路由Month、Year组件,结构如下
import { createBrowserRouter } from "react-router-dom";
import Layout from "@/pages/Layout";
import New from "@/pages/New";
import Month from "@/pages/Month";
import Year from "@/pages/Year";
const router=createBrowserRouter([
{
path:'/',
element:<Layout/>,
children:[
{index:true,element:<Month/>},
{path:'/year',element:<Year/>}
]
},
{
path:'/new',
element:<New/>
}
])
export default router;
其中,/路径下默认展示的是组件。
这里解决大家可能出现的一个疑问,为什么要分一级二级路由,不直接每个路由一个单独的Path呢?
(1) 共享布局,减少重复代码
-
如果所有路由都是独立的(如
/
、/month
、/year
、/new
),每个页面都要单独引入<Layout />
,导致代码冗余:// 独立路径的冗余写法(每个路由都要重复 Layout) { path: '/', element: <Layout><Month /></Layout> }, { path: '/year', element: <Layout><Year /></Layout> }, { path: '/new', element: <New /> }
-
嵌套路由让
/
和/year
自动继承<Layout />
,只需定义一次:// 嵌套路由的简洁写法 { path: '/', element: <Layout />, // 父路由定义 Layout children: [ { index: true, element: <Month /> }, // 自动包裹在 Layout 中 { path: '/year', element: <Year /> } // 自动包裹在 Layout 中 ] }
(2) 逻辑分层更清晰
-
一级路由
/
:代表整个应用的基础布局(如导航栏、页脚)。 -
二级路由:
index:true
(默认路由):显示<Month />
(如首页)。/year
:显示<Year />
(年度视图)。
-
独立路由
/new
:不需要共享布局(如登录页、弹窗页)。
这样分层后,代码结构更符合实际业务逻辑,而不是所有路径都平铺在一起。
(3) 动态路由和权限控制更灵活
-
如果未来需要在
/user/:id
下嵌套多个子页面(如/user/:id/profile
、/user/:id/settings
),嵌套路由可以轻松扩展:{ path: '/user/:id', element: <UserLayout />, children: [ { path: 'profile', element: <Profile /> }, // 访问 /user/123/profile { path: 'settings', element: <Settings /> } // 访问 /user/123/settings ] }
-
权限控制:可以在父路由 (
/user/:id
) 统一校验用户权限,子路由自动继承。 -
虽然我们这个简单的项目中并没有用到这一点
此外,使用子路由模式还有其他好处,比如懒加载等,这里不再一一列举,感兴趣的读者可以自行查询
4.项目数据处理
这一部分,我会讲解项目中数据的获取和处理以及展示,这是react的核心,也是我们讲解的核心
①数据获取
项目的数据部署在json-server之中——json-server 是一个零配置的 RESTful API 模拟工具,通过 JSON 文件快速搭建本地测试服务器。 项目中的server包下的data.json即是我们项目的数据
之后数据由Redux获取到前端。 Redux把获取到的数据存储在全局状态树里。项目里的各个组件能够从这个全局状态树中获取所需数据,以此保证数据的一致性和可维护性,下面我们具体分析一下Redux获取数据的代码,这是项目十分重要的一部分
先来分析src\store\modules\billStore.js中的部分,这部分代码是定义账单数据的状态管理逻辑,包括同步更新方法(设置/添加账单)和异步操作(获取/提交账单数据)。最终导出 reducer 和异步方法供全局 store 和组件使用。
import { createSlice } from '@reduxjs/toolkit';
import axios from "axios";
const billStore=createSlice({
name:'billStore',
initialState:{
billList:[]
},
reducers:{
setBillList(state,action){
state.billList=action.payload;
},
addBill(state,action){
state.billList.push(action.payload);
}
}
})
const {setBillList,addBill}=billStore.actions;
const getBillList=()=>{
return async (dispatch)=>{
const res=await axios.get('http://localhost:8888/ka')
dispatch(setBillList(res.data))
}
}
const addBillList=(data)=>{
return async (dispatch)=>{
const res=await axios.post('http://localhost:8888/ka',data)
dispatch(addBill(res.data))
}
}
export {getBillList,addBillList};
const reducer=billStore.reducer;
export default reducer;
这里很遗憾的告诉各位,这里并不会深入到react的底层去讲解Redux,或许之后我单独写一篇文章去讲底层?
但是让各位理解代码还是必要的,下面是对代码中关键部分的分析
1.createSlice
: Redux Toolkit 的核心 API,用于创建状态切片,大家如何去理解切片呢,暂且理解为应用中某个功能模块的状态和逻辑的封装,或者更通俗一些 —— 想象整个应用的全局状态是一个大蛋糕🍰,而 每个切片就是从这个蛋糕上切下来的一块,专门负责管理某个独立功能的数据和逻辑。理解不了也没关系,反正要有这个东西
2.createSlice
中定义了很多“东西”,我们可以这样理解name是切片的名称,initialState是切片中的变量,且有初始值,reducers是切片的一些方法的集合,里面有很多方法
3.现在我们来理解一下切片中的方法
setBillList
中是state.billList=action.payload;
即是把billList中的数据全部替换,一般用于初始化数据addBill
中是state.billList.push(action.payload);
即是在billList中添加数据
4.const {setBillList,addBill}=billStore.actions;``export {getBillList,addBillList};
将这两个方法解构并导出,使别的组件可以使用这两个方法去更改billList中的数据
5.下面的代码大家理解为实例化setBillList、addBill方法,dispatch是什么?
dispatch
是 Redux 中唯一能触发状态更新的方法,它把 action 发送给 reducer 来修改 store 中的数据。
const getBillList=()=>{
return async (dispatch)=>{
const res=await axios.get('http://localhost:8888/ka')
dispatch(setBillList(res.data))
}
}
const addBillList=(data)=>{
return async (dispatch)=>{
const res=await axios.post('http://localhost:8888/ka',data)
dispatch(addBill(res.data))
}
}
6.billStore.reducer它是 Redux 中真正修改 billList
数据的规则函数,会根据不同的 action(如 setBillList
/addBill
)决定如何更新状态。
const reducer=billStore.reducer;
export default reducer;
其他先不要管,就这样理解
再来分析src\store\index.js中的部分,这部分代码是整合所有业务模块的 reducer,创建全局 store 实例。自动配置 Redux DevTools 和中间件,使应用支持状态管理和调试。
import { configureStore } from "@reduxjs/toolkit";
import billReducer from "./modules/billStore";
const store=configureStore({
reducer:{
bill:billReducer,
}
})
export default store;
首先,各位可能会问billReducer
是哪位,我记得没有导出它呀?这只是对我们默认导出的reducer的重命名,不用管这段代码什么意思,这段代码让我们可以在全局使用store了,其中包括billStore.js中的内容
②数据处理
1.在Layout组件中,进行了数据的初始化
useEffect(() => {
dispatch(getBillList())
}, [dispatch])
const navigate=useNavigate()
这里dispatch(getBillList())
调用了getBillList()
方法,使前文提到的data.json中的数据传入到了billList数组中
2.在Nwe组件中调用了数据更新的方法
const saveBill=()=>{
const data={
type:billType,
money:billType==='pay'?-money:+money,
date:date? dayjs(date).toISOString() : dayjs().toISOString(),
useFor:useFor
}
dispatch(addBillList(data))
}
当用户点击保存按钮之后,可以将data中的数据加入到billList和data.json
3.Month组件中数据的分组
因为我们项目中页面展示的是某月的数据,所以要对数据进行月份分组,以便于方便展示并总汇月收入支出等
const monthGroup=useMemo(()=>{
return _.groupBy(billList,(item)=>dayjs(item.date).format('YYYY-MM')) ;
},[billList])
同理,我们要汇总每一天的收入和支出,所以要对月下的天进行分组
const dayGroup=useMemo(()=>{
const groupData=_.groupBy(currentMonthList,(item)=>dayjs(item.date).format('YYYY-MM-DD')) ;
const keys=Object.keys(groupData)
return {
groupData,
keys
}
},[currentMonthList])
这些代码使用了一个鲁大师的库,里面有分组的方法,第一个参数是要分组的内容,第二个参数是依据什么分组,总体还是很好理解的
对了,还有一个dayjs的库,方便日期格式的转换
4.数据的计算
我们要计算一些数据,如收入、支出、结余的计算
const monthResult=useMemo(()=>{
const pay=currentMonthList.filter(item=>item.type==='pay').reduce((a,c)=>a+c.money,0)
const income=currentMonthList.filter(item=>item.type==='income').reduce((a,c)=>a+c.money,0)
return {
pay,
income,
total:income+pay
}
},[currentMonthList])
这里举一个例子,这里使用的是useMemo
,什么是useMemo?
useMemo
是 React 的一个 Hook,用于缓存计算结果,避免组件重新渲染时重复执行复杂运算,仅在依赖项变化时重新计算。
5.项目核心组件逻辑
1.日期选择器的处理
我们先来看一下日期选择器的效果
可以看到有一个^的箭头我们点击可以改变形状,点击“确定”、“取消”或者“没有聚焦的地方”都可以隐藏日期选择器,而且选择的结果可以渲染在相应的位置,这是如何实现的呢?
看下面的代码
const [dateVisible, setDateVisible] = useState(false)
const [currentDate, setCurrentDate]=useState(()=>{
return dayjs().format('YYYY-MM')
}
useEffect(()=>{
const nowDate=dayjs().format('YYYY-MM')
setCurrentMonthList(monthGroup[nowDate]||[])
},[monthGroup])
const onConfirm=(date)=>{
setDateVisible(false)
const dateStr=dayjs(date).format('YYYY-MM')
setCurrentMonthList(monthGroup[dateStr]||[])
setCurrentDate(dateStr)
}
// 下面是return的部分
{/* 时间切换区域 */}
<div className="date" onClick={() => setDateVisible(true)}>
<span className="text">
{currentDate+"月账单"}
</span>
<span className={classNames('arrow', dateVisible && 'expand')}></span>
</div>
{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
precision="month"
visible={dateVisible}
onCancel={() => setDateVisible(false)}
onConfirm={onConfirm}
onClose={() => setDateVisible(false)}
max={new Date()}
/>
</div>
正是这些代码实现了时间选择器的功能,下面我们一起详细分析一下这些代码
1. 状态管理
这段代码通过useState
定义了两个状态变量,这两个变量作用很大
dateVisible
用于控制日期选择器的显示隐藏状态,初始值为false表示默认不显示;currentDate
存储当前选择的年月,使用dayjs().format('YYYY-MM')
初始化值为当前月份,格式化为"2023-10"这样的字符串形式。
2. 数据初始化
代码使用useEffect
在组件挂载或monthGroup
数据变化时执行初始化操作,首先获取当前月份的格式化字符串,然后从monthGroup
对象中取出对应月份的账单数据,如果没有数据则设置为空数组,最后通过setCurrentMonthList
更新状态,这个副作用函数的依赖项是monthGroup
确保数据源更新时重新初始化。
这样便完成了时间的初始化(当前日期)和更新
3. 日期确认逻辑
onConfirm
函数处理日期选择确认后的操作流程,先关闭日期选择器弹窗,然后将选择的日期对象格式化为"YYYY-MM"字符串,接着从monthGroup
中获取新日期对应的账单数据如果没有则使用空数组,最后更新当前日期和账单列表两个状态,触发界面重新渲染显示最新数据。
4. 日期显示区域
这部分UI代码实现了一个可点击的日期显示区域,点击时会打开日期选择器,显示内容包括当前年月和一个小箭头图标,其中年月文本通过拼接currentDate
状态和固定文字"月账单"组成,箭头图标使用classNames
库根据dateVisible
状态动态添加expand
类名实现旋转效果。
5. 日期选择器组件
代码中配置了一个功能完整的日期选择器组件,设置precision="month"
限定只能选择年月,max={new Date()}
禁止选择未来日期,提供了确认、取消和关闭三种操作方式的事件回调,其中确认操作触发onConfirm
函数,其他操作都执行关闭弹窗的逻辑。
结语
通过这个记账项目,我们完整实践了React的核心开发模式:组件化思维、状态管理和数据驱动UI。 从路由设计到Redux状态管理,再到日期选择器的交互实现,每个环节都体现了React开发的精髓。
建议初学者可以基于此项目进行扩展练习,比如添加账单分类统计、优化加载状态等。记住,掌握React的关键在于理解其"状态-UI"的绑定关系,这个项目为你提供了绝佳的实践案例。愿你在React的学习道路上越走越远!