主题
记账本项目实战(一)
本页对应 记账本项目实战,共 7 段视频,约 1 小时 10 分。
学习目标
- 能够搭建月度账单统计区域的静态结构,并准确说明时间切换区、统计区与时间选择器的模板对应关系。
- 能够使用
useState控制时间选择器显隐,并联动箭头类名及取消、确定、蒙层关闭事件。 - 能够使用
dayjs格式化选中时间并回显到统计区域,实现确认后时间显示切换。 - 能够使用
useMemo与lodash.groupBy将账单数据按月、按日分组,并产出日期列表与分组账单映射。 - 能够基于选中月份从分组数据中取账单数组,使用
filter、reduce计算支出、收入、结余并完成初始化渲染与空值边界处理。 - 能够拆分单日统计组件,通过
props传递日期与账单列表,并在子组件中完成单日统计计算与渲染。
知识点
本节要完成什么
本节笔记是“环境搭建”:用 CRA 创建项目,并安装后续月度账单功能所需的基础依赖。转写稿里后面反复用到路由进入月度账单、Redux 取 billList、dayjs 格式化年月、classnames 控制箭头类名、antd-mobile 的 DatePicker 弹框、axios 取接口数据。所以环境不是形式,而是给后续功能留好落点。本节笔记原清单如下:
- Redux状态管理 -
@reduxjs/toolkit、react-redux - 路由 -
react-router-dom - 时间处理 -
dayjs - class类名处理 -
classnames - 移动端组件库 -
antd-mobile - 请求插件 -
axios
bash
# 使用 CRA 创建项目后,在项目根目录安装;项目名按实际替换
npx create-react-app my-app
cd my-app
npm i @reduxjs/toolkit react-redux react-router-dom dayjs classnames antd-mobile axiosRedux 状态管理
为什么需要:转写稿视频4要从 Redux 里拿 billList,视频5、6又用 useSelector 取账单数据做统计;没有 store 和 Provider,组件拿不到全局账单数据。怎么想:先明确“账单原始数据是全局共享”,再决定用 Redux 存原始列表,派生数据如 monthGroup 不塞 Redux。怎么做:安装 @reduxjs/toolkit、react-redux,建 store,在入口包 Provider,组件里写 useSelector(state => state.bill.billList)。易错点:只装包不包 Provider;selector 路径和 slice 的 name 不一致;把 monthGroup 也放进 Redux,转写稿其实是用 useMemo 做二次处理。
路由
为什么需要:转写稿多次“打开月度账单对应路由”。怎么想:页面级切换交给 react-router-dom。怎么做:安装后在入口配置 BrowserRouter、Routes、Route,把月度账单组件挂到对应 path。易错点:路由路径和菜单跳转不一致;忘记在入口包 Router,组件里用 useNavigate 会报错。
dayjs
为什么需要:转写稿视频3时间回显时直接渲染 Date 对象报错,后来用 dayjs 格式化成年月;视频4按月分组的 K 也格式化成 YYYY-MM。怎么想:标准 Date 对象用于计算,显示和分组键用字符串。怎么做:import dayjs from 'dayjs',写 dayjs(date).format('YYYY-MM');显示处不要直接渲染 Date。易错点:转写稿里的“DJS”应纠正为 dayjs;格式大小写写错,MM 是月、mm 是分;DatePicker 回传值忘记格式化。
classnames
为什么需要:视频2里弹框打开时箭头朝上、关闭时朝下,本质是类名是否存在。怎么想:visible 布尔状态既控制弹框,也控制箭头类名。怎么做:import classNames from 'classnames',写 className={classNames('arrow', { up: visible })}。转写稿用短路运算也能实现,但 classnames 更清晰。易错点:把 classnames 拼成 className;状态为 false 时仍渲染 up 类;只改弹框状态不改箭头。
antd-mobile
为什么需要:视频2用 DatePicker 做底部时间选择框,涉及 visible、onCancel、onConfirm、onClose。怎么想:它是受控组件,visible 由 useState 控制。怎么做:
jsx
const [visible, setVisible] = useState(false)
<DatePicker
visible={visible}
onCancel={() => setVisible(false)}
onConfirm={(date) => {
// 格式化并更新显示时间
setVisible(false)
}}
onClose={() => setVisible(false)}
/>易错点:转写稿里的“uncon/unconfirm/unclose”应纠正为 onCancel/onConfirm/onClose;visible 写死;onConfirm 里忘记关闭弹框;直接渲染 Date 对象报错。
axios
为什么需要:转写稿说 billList 从接口获取,首次 monthGroup 可能是空对象,用 K 取值取不到,需要边界判断。怎么想:请求层独立,组件只消费数据。怎么做:安装 axios,封装请求;拿到数据后存 Redux;初始化 useEffect 里取当前月账单,若 monthGroup[now] 为 undefined 不要 setCurrentMonthList。易错点:渲染期发请求;不处理空数组、空对象;猜错接口返回结构。
转写稿里的调试顺序
先搭静态结构,再做 DatePicker 弹框,再做时间回显,再做按月分组,再做统计,再做初始化渲染,最后做按日列表。调试时先看 React DevTools 里的 visible、currentMonthList、monthGroup、dayGroup。视频3报错是因为直接渲染 Date 对象,格式化后解决;视频6首次 monthGroup 为空,需判断 undefined。后续按月、按日分组会用到 lodash 的 groupBy,但不在本节基础清单里,转写稿视频4是单独安装的,写法是 groupBy(array, item => dayjs(item.date).format('YYYY-MM')),再用 Object.keys 取日期列表。
易错点汇总
依赖装到全局或子目录;漏装 react-redux 导致 Provider 找不到;antd-mobile 事件名写错;dayjs 格式和分组键不一致;classnames 状态与 visible 不同步;Redux selector 路径与 slice name 不一致;初始化不判空。环境搭好后,后续月度账单统计区域才可复现。
材料核对:无法按“配置别名路径”直接扩写
你给出的本节笔记标题是“配置别名路径”,但配套转写稿的实际内容是“月度账单-统计区域”的系列实现,包括统计区域结构搭建、点击切换时间选择框、点击确定切换时间显示、数据按月分组、计算选择月份统计数据、初始化渲染统计数据、单日统计列表实现等。转写稿中没有任何关于“配置别名路径”的讲解,例如没有出现 vite.config、webpack、resolve.alias、@ 路径别名、jsconfig.json、tsconfig.json、编辑器路径提示等配置内容。因此,如果直接写成“配置别名路径”的教程正文,就只能编造,而你的要求明确写了“不得编造”,所以我不能把月度账单内容硬改成别名路径教程。
为什么不能直接套用转写稿
“配置别名路径”通常要讲清楚:为什么需要别名路径、项目里如何配置、构建工具如何识别、编辑器如何识别、代码里如何替换相对路径、常见错误如何排查。转写稿里没有这些推导和调试过程。它讲的是月度账单统计区域:用状态控制弹框、用 useMemo 做二次计算、用 lodash 的 groupBy 分组、用 dayjs 格式化时间、用 useEffect 做初始化渲染等。两者不是同一节内容。若强行扩写,会把“统计区域”写成“别名路径”,违背“用转写稿补推导和调试过程”和“不得编造”。
转写稿中可确认的同音字与术语问题
转写稿里有多处同音字或识别错误,如果后续要整理成月度账单教程,应纠正为:
- “线象网站”应为“线上网站/项目预览”;
- “安迪拉”应为“Ant Design”;
- “萌从区域/蒙层区域”应为“蒙层区域”;
- “优it/use state”应为
useState; - “variable/vis”应为
visible; - “包/className”应为
classNames; - “rodas/lodas/娄大斯”应为
lodash; - “DJS”应为
dayjs; - “bi list/bill list”应为
billList; - “配”在账单类型中应为
pay; - “income”表示收入;
- “uncon/confirm/un close”应为
onCancel、onConfirm、onClose一类组件事件; - “usememo/us effects”应为
useMemo、useEffect。
但这些都属于月度账单统计区域,不是“配置别名路径”。
图片和代码缺失说明
你的要求是“保留本节笔记里的图片(alt 改中文)和代码”。但当前输入中只有转写稿文字,没有提供任何图片、图片链接、代码块或完整源码。转写稿虽然多次提到“复制配套代码”“新增样式文件”“复制组件模板”,但没有给出具体代码内容。因此我无法保留图片,也无法保留代码。若我自行补代码,就属于编造。
需要补充的材料
如果要继续完成“配置别名路径”这一节教程,请补充以下任一组材料:
- 本节正确转写稿:包含为什么配置别名、在哪个配置文件里配置、别名怎么写、代码里怎么用、报错如何排查;
- 本节笔记中的图片和代码:包括配置文件片段、使用别名前后的 import 对比、编辑器报错截图等;
- 若实际想写的是“月度账单-统计区域”,请把本节标题改为对应标题,并提供转写稿中提到的组件模板、样式文件、分组与统计代码。
拿到这些材料后,我可以严格按你的格式输出:用###小标题分段,不输出##;保留图片并把 alt 改为中文;保留代码;每个知识点按“为什么需要 → 怎么想 → 怎么做 → 易错点”展开;用转写稿补推导和调试过程;纠正同音字;不编造;控制在 1000–1800 字。
建议的下一步
当前最稳妥的处理不是硬写,而是先核对材料。你可以回复我一句:“请按月度账单统计区域扩写”,并补充图片和代码;或者回复:“请按配置别名路径扩写”,并补充正确转写稿。否则无论写哪一个标题,都会与现有材料冲突。
本节要解决的两个问题
本节笔记只有两个背景知识点:一是通过 webpack 配置路径解析,把 @/ 解析到 src/;二是通过 VSCode 配置路径联想,在输入 @/ 时自动提示 src/ 下的子级目录。二者经常一起出现,但并不是同一件事。

需要先明确:本节转写稿实际讲的是“月度账单统计区域”的功能演示、静态结构、弹框切换、按月分组等内容,与“路径解析配置、路径联想配置”不是同一主题。因此下面不把转写稿里的业务代码硬套到路径配置上,只围绕笔记中的两个背景点扩写。这样做的原因是避免把“月度账单”的推导误当成“路径别名”的推导,导致知识点串线。
为什么需要路径别名
当项目目录变深后,常见写法会变成:
js
import Header from '../../../components/Header'这种写法有三个问题:第一,层级一多就难读;第二,目录调整后相对路径容易断;第三,不同文件里同一模块的写法不一致,维护成本高。
路径别名的目标是:用稳定的逻辑路径代替脆弱的相对路径。例如统一写成:
js
import Header from '@/components/Header'这里的 @/ 不是浏览器原生语法,也不是 JavaScript 原生语法,它需要由构建工具解析。webpack 的 resolve.alias 就是做这件事:在打包解析模块时,把 @ 映射到项目里的 src 目录。这样代码里写 @/,webpack 能找到真实文件。
怎么想:构建解析和编辑器联想是两条链路
这是最容易混淆的地方。@/ 要正常工作,通常需要两套配置:
第一套是 webpack 的解析配置。它决定“项目能不能跑起来”。如果只配了 VSCode 联想,编辑器可能不报红,但 webpack 打包时仍可能提示找不到模块。
第二套是 VSCode 的路径联想配置。它决定“写代码时编辑器能不能提示”。如果只配了 webpack,项目能构建,但 VSCode 不一定知道 @/ 对应 src/,输入时可能没有子目录提示。
所以思考顺序应是:先保证 webpack 能解析,再让 VSCode 理解同一套别名。不要认为“编辑器能联想”就等于“构建一定成功”,也不要认为“构建成功”就等于“编辑器一定智能提示”。
怎么做:webpack 路径解析配置
常见配置形态如下,具体放在项目的 webpack 配置文件中,以实际项目结构为准:
js
const path = require('path')
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
}这里的核心是 resolve.alias:把 @ 映射到 src 的绝对路径。这样 @/components/Header 在解析时就会落到 src/components/Header。
做的时候要注意:path.resolve(__dirname, 'src') 依赖配置文件所在位置。如果配置文件不在项目根目录,__dirname 可能不是你以為的项目根,别名就会指错。另一个易错点是 alias 的键和编辑器配置的写法要对应:webpack 里常见写 '@',VSCode 的 paths 里常见写 '@/*',不要机械照抄成不一致的形态。
怎么做:VSCode 路径联想配置
VSCode 对 JavaScript/TypeScript 项目的路径联想,通常通过项目根目录的 jsconfig.json 或 tsconfig.json 中的 compilerOptions.baseUrl 与 paths 来识别。常见配置形态如下:
json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}这段配置表达的是:以项目根目录为基准,@/* 对应 src/*。配置后,输入 @/ 时,VSCode 的语言服务才有机会提示 src/ 下的子级目录。
操作时要注意文件位置:jsconfig.json 一般应放在 VSCode 打开的项目根目录。如果放在子目录,或者 VSCode 打开的是 monorepo 的更大工作区,语言服务可能按另一个根目录解析,导致联想不生效。配置后如果没反应,可以先检查文件是否被 VSCode 识别,再执行“重启 TS Server”或重开窗口。不要一开始就怀疑 webpack,因为这一步只影响编辑器提示。
调试顺序与易错点
建议按“先构建、后联想”的顺序排查。
先验证 webpack:在代码里写 import '@/...',运行构建或启动开发服务,看是否报“模块找不到”。如果报错,检查 resolve.alias 是否存在、path.resolve 是否指到真实 src、别名键是否写对、配置文件是否被当前构建流程加载。
再验证 VSCode:新建或打开一个 JS/TS 文件,输入 @/,观察是否出现 src 下目录提示。如果没有,检查 jsconfig.json/tsconfig.json 是否在项目根、baseUrl 是否为 "."、paths 是否写成 "@/*": ["src/*"]、VSCode 是否打开了正确根目录。若项目同时存在 jsconfig.json 和 tsconfig.json,还要注意二者是否冲突,避免一个文件生效、另一个文件被忽略。
常见易错点还有:别名值末尾多写 /*、把 @ 和 @/ 混用、大小写不一致、Windows 路径分隔符处理不当、ESLint 或 TypeScript 的模块解析没有同步配置。它们不一定影响 webpack 运行,但会影响编辑器提示或检查结果。
本节小结
本节是两个背景配置:webpack 的 resolve.alias 负责让 @/ 在构建时解析到 src/;VSCode 的 baseUrl 与 paths 负责让编辑器在输入 @/ 时联想 src/ 下的子级目录。记住“构建解析”和“编辑器联想”是两条链路,配置要成对理解、分别调试。图片保留为配置位置和效果的参照;本节笔记没有给出具体代码,因此上面给出的是常见配置形态,落地时应以项目实际构建工具和目录结构为准。
代码示例
代码一:统计区域静态结构 + 点击切换时间选择框(含箭头朝向)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>月度账单-统计区域</title>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #f5f5f5; font-family: -apple-system, "PingFang SC", sans-serif; padding: 12px; }
/* 月度收支(顶部) */
.header { display: flex; align-items: center; gap: 8px; padding: 12px 0; }
.header .date { font-size: 16px; font-weight: 500; cursor: pointer; }
.header .arrow { font-size: 12px; color: #666; transition: transform .3s; }
/* 打开弹框时箭头朝上:由 expand 类名是否存在决定 */
.header .arrow.expand { transform: rotate(180deg); }
/* 统计区域 */
.statistics { display: flex; background: #fff; border-radius: 8px; padding: 16px; }
.statistics .item { flex: 1; text-align: center; }
.statistics .item .label { font-size: 12px; color: #999; }
.statistics .item .value { margin-top: 6px; font-size: 18px; font-weight: 600; }
/* 时间选择弹框(模拟 antd-mobile DatePicker) */
.mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); display: none; }
.mask.show { display: block; }
.popup {
position: fixed; left: 0; right: 0; bottom: 0; background: #fff;
border-radius: 12px 12px 0 0; transform: translateY(100%); transition: transform .3s;
}
.popup.show { transform: translateY(0); }
.popup .title { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #eee; }
.popup .title .cancel { color: #999; cursor: pointer; }
.popup .title .confirm { color: #1677ff; cursor: pointer; }
.popup .picker { padding: 16px; }
.popup .picker input { padding: 6px 10px; font-size: 16px; }
</style>
</head>
<body>
<!-- 月度收支 -->
<div class="header">
<span class="date" id="dateText"></span>
<span class="arrow" id="arrow">▼</span>
</div>
<!-- 统计区域 -->
<div class="statistics">
<div class="item"><div class="label">支出</div><div class="value" id="pay">0.00</div></div>
<div class="item"><div class="label">收入</div><div class="value" id="income">0.00</div></div>
<div class="item"><div class="label">结余</div><div class="value" id="total">0.00</div></div>
</div>
<!-- 时间选择弹框 -->
<div class="mask" id="mask"></div>
<div class="popup" id="popup">
<div class="title">
<span class="cancel" id="cancel">取消</span>
<span class="confirm" id="confirm">确定</span>
</div>
<div class="picker">
<input type="month" id="monthInput" />
</div>
</div>
<script>
/* ---------- 状态(对应 React 里的 useState) ---------- */
// 控制弹框打开/关闭
let dateVisible = false;
// 控制时间显示,初始为当前时间,并格式化为 YYYY-MM
let currentDate = dayjs().format('YYYY-MM');
const mask = document.getElementById('mask');
const popup = document.getElementById('popup');
const arrow = document.getElementById('arrow');
const dateText = document.getElementById('dateText');
const monthInput = document.getElementById('monthInput');
/* ---------- 渲染:数据驱动视图 ---------- */
function render() {
mask.classList.toggle('show', dateVisible);
popup.classList.toggle('show', dateVisible);
// 箭头:打开时有 expand 类名(朝上),关闭时没有(朝下)
arrow.classList.toggle('expand', dateVisible);
dateText.textContent = currentDate;
monthInput.value = currentDate;
}
/* ---------- 点击月份区域:打开弹框 ---------- */
dateText.addEventListener('click', () => {
dateVisible = true;
render();
});
/* ---------- 取消:关闭弹框 ---------- */
document.getElementById('cancel').addEventListener('click', () => {
dateVisible = false;
render();
});
/* ---------- 确定:关闭弹框 + 把选择的时间同步到顶部显示 ---------- */
document.getElementById('confirm').addEventListener('click', () => {
// 拿到用户选择的时间,用 dayjs 格式化成 YYYY-MM
currentDate = dayjs(monthInput.value).format('YYYY-MM');
dateVisible = false;
render();
});
/* ---------- 蒙层:关闭弹框 ---------- */
mask.addEventListener('click', () => {
dateVisible = false;
render();
});
render();
</script>
</body>
</html>关键行与易错点
dateVisible就是 React 里的const [dateVisible, setDateVisible] = useState(false),弹框的visible、箭头的类名、蒙层的显隐全部由它驱动,这是"数据驱动视图"的核心。- 箭头朝向不要用两套 DOM 切换,而是用一个类名是否存在来控制:
classNames('arrow', { expand: dateVisible }),这里用classList.toggle('expand', dateVisible)等价实现。转写稿里强调的就是"由类名是否存在决定朝上还是朝下"。 - 关闭弹框有三个入口:
onCancel/onConfirm/onClose(蒙层),三者都要把状态置为false,少一个就会出现"点了蒙层关不掉"。 - 易错点:直接把
dayjs()返回的 Date 对象渲染到页面上会报错(React 不能渲染对象),必须先.format('YYYY-MM')转成字符串,转写稿中就是这么处理的。 - 易错点:确定回调里如果将来还要做别的业务(比如重新拉数据),不要写在行内,要单独抽
onConfirm函数,转写稿中专门演示了这种改写。
代码二:按月分组 + 计算选择月份的统计数据(含初始化渲染)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>月度账单-按月分组与统计</title>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js"></script>
<style>
body { background: #f5f5f5; font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 12px; }
.header { display: flex; align-items: center; gap: 8px; padding: 12px 0; }
.header .date { font-weight: 500; cursor: pointer; }
.header .arrow { font-size: 12px; color: #666; transition: transform .3s; }
.header .arrow.expand { transform: rotate(180deg); }
.statistics { display: flex; background: #fff; border-radius: 8px; padding: 16px; }
.statistics .item { flex: 1; text-align: center; }
.statistics .item .label { font-size: 12px; color: #999; }
.statistics .item .value { margin-top: 6px; font-size: 18px; font-weight: 600; }
.btns { margin-top: 16px; display: flex; gap: 8px; }
.btns button { padding: 6px 12px; }
</style>
</head>
<body>
<div class="header">
<span class="date" id="dateText"></span>
<span class="arrow" id="arrow">▼</span>
</div>
<div class="statistics">
<div class="item"><div class="label">支出</div><div class="value" id="pay">0.00</div></div>
<div class="item"><div class="label">收入</div><div class="value" id="income">0.00</div></div>
<div class="item"><div class="label">结余</div><div class="value" id="total">0.00</div></div>
</div>
<div class="btns">
<button id="prevMonth">上一个月</button>
<button id="nextMonth">下一个月</button>
</div>
<script>
/* ========== 1. 模拟后端返回的平铺账单数据(不是按月划分好的) ========== */
const billList = [
{ id: 1, type: 'pay', money: 100, date: '2023-03-05' },
{ id: 2, type: 'pay', money: 80, date: '2023-03-18' },
{ id: 3, type: 'income', money: 10000, date: '2023-03-28' },
{ id: 4, type: 'pay', money: 200, date: '2022-10-02' },
{ id: 5, type: 'income', money: 3000, date: '2022-10-20' },
{ id: 6, type: 'pay', money: 60, date: '2023-02-11' }
];
/* ========== 2. 按月分组(对应 useMemo + lodash.groupBy) ========== */
let cacheDeps = null; // 依赖项
let cacheValue = null; // 计算后的值
function useMemoMonthGroup(list) {
if (cacheDeps === list) return cacheValue; // 依赖没变,复用缓存
// K 用 dayjs 格式化成 YYYY-MM,必须和顶部显示的时间格式保持一致
cacheValue = _.groupBy(list, item => dayjs(item.date).format('YYYY-MM'));
cacheDeps = list;
return cacheValue;
}
/* ========== 3. 计算某月的统计数据:先 filter 过滤,再 reduce 累加 ========== */
function calcStatistics(list) {
const pay = list
.filter(item => item.type === 'pay')
.reduce((a, c) => a + c.money, 0);
const income = list
.filter(item => item.type === 'income')
.reduce((a, c) => a + c.money, 0);
return { pay, income, total: pay + income }; // 结余 = 支出 + 收入
}
/* ========== 4. 状态 ========== */
let currentDate = dayjs().format('YYYY-MM'); // 初始为当前月
const monthGroup = useMemoMonthGroup(billList); // 按月分组后的数据
/* ========== 5. 渲染(初始化时执行一次,等价于 useEffect 里渲染统计数据) ========== */
function render() {
document.getElementById('dateText').textContent = currentDate;
// 边界值处理:首次 monthGroup 可能是空对象,取值会 undefined,兜底成 []
const currentMonthList = monthGroup[currentDate] || [];
const { pay, income, total } = calcStatistics(currentMonthList);
document.getElementById('pay').textContent = pay.toFixed(2);
document.getElementById('income').textContent = income.toFixed(2);
document.getElementById('total').textContent = total.toFixed(2);
}
/* ========== 6. 切换月份(相当于点击确定后把新月份赋给状态) ========== */
document.getElementById('prevMonth').addEventListener('click', () => {
currentDate = dayjs(currentDate).subtract(1, 'month').format('YYYY-MM');
render();
});
document.getElementById('nextMonth').addEventListener('click', () => {
currentDate = dayjs(currentDate).add(1, 'month').format('YYYY-MM');
render();
});
/* 初始化渲染:不用用户点确定,一进页面就把当前月统计显示出来 */
render();
</script>
</body>
</html>关键行与易错点
_.groupBy(list, item => dayjs(item.date).format('YYYY-MM')):第一个参数是原数组,第二个是分组依据;返回值是对象,key是月份字符串,value是该月所有账单组成的数组。分组后就能用monthGroup[currentDate]直接拿到当前月数据。- K 的格式必须和顶部显示的时间格式一致(都是
YYYY-MM),否则monthGroup[currentDate]永远取不到值——转写稿中专门强调"为啥要处理成这个格式,因为后面要联动"。 - 统计用
filter筛出type === 'pay'(支出)/'income'(收入),再reduce((a, c) => a + c.money, 0)累加,0这个初始值不能省,否则空数组会报错。 - 结余
total = pay + income,不是相减。 - 渲染时用
toFixed(2)保留两位小数。 - 易错点(边界值):首次进入时
billList为空数组,groupBy得到空对象,monthGroup[currentDate]是undefined,直接传给计算或渲染会报错,必须|| []兜底——这正是转写稿里"加一个边界值的控制,不为 undefined 才做赋值"。 - 初始化的渲染不需要用户点确定,等价于在
useEffect(() => { ... }, [])里拿到当前月 → 取数组 → 交给计算逻辑,转写稿中的做法就是初始化时setCurrentMonthList(monthGroup[nowDate])。
代码三:当前月按日分组 + 单日统计列表渲染
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>月度账单-单日统计列表</title>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js"></script>
<style>
body { background: #f5f5f5; font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 12px; }
.day-item { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.day-item .day-title { font-size: 14px; font-weight: 600; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
.day-item .day-body { display: flex; justify-content: space-between; padding-top: 8px; font-size: 13px; color: #666; }
</style>
</head>
<body>
<!-- 单日账单统计列表 -->
<div id="dayList"></div>
<script>
/* ========== 1. 当前月的账单数据(已由 redux 中的 currentMonthList 拿到) ========== */
const currentMonthList = [
{ id: 1, type: 'pay', money: 100, date: '2023-03-05' },
{ id: 2, type: 'pay', money: 20, date: '2023-03-05' },
{ id: 3, type: 'pay', money: 80, date: '2023-03-18' },
{ id: 4, type: 'income', money: 10000, date: '2023-03-28' }
];
/* ========== 2. 当前月数据按日分组(对应 useMemo + groupBy) ========== */
const dayGroup = _.groupBy(currentMonthList, item => dayjs(item.date).format('YYYY-MM-DD'));
/* ========== 3. 日期列表:用 Object.keys 把分组的 K 拿出来组成数组 ========== */
const groupKeys = Object.keys(dayGroup);
/* ========== 4. 单日统计:过滤 + 累加 ========== */
function calcDayResult(list) {
const pay = list
.filter(item => item.type === 'pay')
.reduce((a, c) => a + c.money, 0);
const income = list
.filter(item => item.type === 'income')
.reduce((a, c) => a + c.money, 0);
return { pay, income, total: pay + income };
}
/* ========== 5. 遍历日期列表,渲染每一天的统计卡片 ========== */
const dayListEl = document.getElementById('dayList');
groupKeys.forEach(date => {
// 通过 K 从分组对象中取出当日的账单数组
const billList = dayGroup[date];
// 子组件内部基于 billList 做统计(对应子组件里再写一次 useMemo)
const dayResult = calcDayResult(billList);
const el = document.createElement('div');
el.className = 'day-item';
el.innerHTML = `
<div class="day-title">${date}</div>
<div class="day-body">
<span>支出 ${dayResult.pay.toFixed(2)}</span>
<span>收入 ${dayResult.income.toFixed(2)}</span>
<span>结余 ${dayResult.total.toFixed(2)}</span>
</div>
`;
dayListEl.appendChild(el);
});
</script>
</body>
</html>关键行与易错点
- 分组逻辑和"按月分组"完全一致,只是把格式从
YYYY-MM换成YYYY-MM-DD:_.groupBy(currentMonthList, item => dayjs(item.date).format('YYYY-MM-DD'))。 - 除了分组数据,还要一份日期列表:因为页面要显示"当前是哪一天",而分组对象本身不能直接遍历渲染日期,所以用
Object.keys(dayGroup)把所有 K 取出来组成数组,两份数据一起产出(转写稿中return { groupDate, keys }就是这个意思)。 - 遍历时用
dayGroup[date]以 K 取值拿到当日账单数组,再传给子组件;子组件用同样的filter + reduce算pay / income / total,逻辑直接复用,不要重写一套。 - 易错点:
Object.keys返回的 key 顺序依赖对象的键插入顺序,日期字符串不是整数键,所以能保持插入顺序;但如果你把 K 改成纯数字(比如dayjs().date()),顺序会变成数字升序,可能和预期不一致。 - 易错点:子组件里计算依赖的是父组件传下来的
billList,不是原始的currentMonthList,依赖项写错会导致每天都算出全月的数据。 - 每个卡片都要
toFixed(2),否则会出现1.9999999999998这类浮点误差。
常见错误
页面白屏并报 “Objects are not valid as a React child”
- 现象:一切换月份就崩溃,控制台指向渲染时间的那一行。
- 原因:把
Date对象(或dayjs()返回对象)直接写进 JSX 做子节点。 - 修法:渲染前统一调用
.format('YYYY-MM')转成字符串;初始值和onConfirm回调两处都要转,别只改一处。
首屏统计全是 0,甚至直接报错
- 现象:刷新后支出/收入/结余显示
0.00,或控制台出现对undefined取值的错误。 - 原因:
useEffect里用当前月 key 去monthGroup取值时,billList还是空数组、分组结果是空对象,取到undefined仍setState。 - 修法:取值后先判空再
set(if (list) setCurrentMonthList(list));同时把monthGroup补进依赖数组,消除 lint 提示。
- 现象:刷新后支出/收入/结余显示
切了月份,下方统计数字纹丝不动
- 现象:日期栏变了、列表也变了,但支出/收入/结余还是上个月的值。
- 原因:计算
useMemo的依赖项写成了[]或漏写currentMonthList,缓存住了首次结果。 - 修法:依赖项写实际参与计算的那份数据(
[currentMonthList]);同理子组件里按日统计的useMemo依赖要换成billList,不能沿用父组件的变量名。
弹框“确定”能关、“取消/蒙层”关不掉(或反过来)
- 现象:三个关闭入口只有一个生效。
- 原因:
onCancel/onConfirm/onClose三个回调只绑了一个,或绑错事件名(把onClose写成onCancel);也有把setVisible(false)误写成true的。 - 修法:三个事件分别绑,统一收口到一个
close()函数;后续有业务逻辑时再拆出独立的onConfirm具名函数。
箭头方向不跟随弹框开合
- 现象:箭头永远朝下,或永远朝上。
- 原因:
classnames第二个参数传成了字符串'visible'(恒为真),或直接把状态变量名当类名拼进去。 - 修法:写成
classNames('arrow', { arrow: visible })或classNames('arrow', visible && 'arrow'),第二参必须是布尔值。
按月分组取不到数据,key 对不上
- 现象:
monthGroup[formatDate]恒为undefined。 - 原因:分组时 key 用了原始
item.date(带日、带时间),而选中月格式是YYYY-MM。 - 修法:分组回调里同样走
dayjs(item.date).format('YYYY-MM'),保证“造 key”和“查 key”用同一个格式化表达式。
- 现象:
按日分组的列表渲染出来顺序乱 / 拿错数据
- 现象:日期和金额对不上号,或某个日期重复渲染同一批账单。
- 原因:
map时用索引去dayGroup里取值,或Object.keys之后又单独排序导致 key 与对象取值错位。 - 修法:始终用当前遍历到的 key 去
dayGroup[key]取列表,列表项key也用这个日期字符串,不要用 index。
useEffect无限循环- 现象:页面卡死,Network/Console 疯狂刷新。
- 原因:把
useEffect里set的那个 state 本身放进了依赖数组。 - 修法:依赖只放“外部来源”(如
monthGroup),初始化 effect 保持[]并在内部做边界处理。
练习
弹框三入口收口:把
onCancel/onConfirm/onClose三个回调合并成一个closePicker(),并额外在onConfirm里加一行console.log验证“先取日期再关闭”的执行顺序,说明为什么不能把取日期逻辑写在关闭之后。格式化统一化改造:找出项目里所有
dayjs调用点,抽出一个formatMonth(date)和formatDay(date)两个工具函数,替换掉散落的.format(...)字面量,验证切换月份与首屏渲染均不受影响。边界值加固:人为把
billList初始值改成[]并延迟 3 秒注入数据,验证首屏不崩、数据到达后统计自动刷新;再故意删掉useEffect里的判空,记录报错信息并说明触发链路。依赖项排查:把两处
useMemo的依赖数组分别改成[],观察“切换月份不更新”和“单日列表金额不变”两个现象,逐个恢复并写出每个依赖项对应的因果。按日列表健壮性:给
dayGroup遍历结果做一次排序(按日期倒序),并给每个子组件补上稳定的key;再用一个有跨月数据的账号验证“切月后列表不串数据”。(可选)抽取复用:把父组件里的“支出/收入/结余”计算逻辑抽成一个
useBillSummary(list)自定义 Hook,父组件与单日子组件共用,验证两处渲染结果一致。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 月度账单-统计区域-功能演示和结构搭建 | 05:10 |
| 2 | 月度账单-统计区域-点击切换时间选择框 | 09:40 |
| 3 | 月度账单-统计区域-点击确定切换时间显示 | 07:40 |
| 4 | 月度账单-统计区域-数据按月分组实现 | 11:59 |
| 5 | 月度账单-统计区域-计算选择月份之后的统计数据 | 11:39 |
| 6 | 月度账单-统计区域-初始化渲染统计数据 | 08:07 |
| 7 | 月度账单-列表区域-单日统计列表实现 | 16:04 |
小结
- 弹框开合、箭头方向、日期回显三件事共用同一个布尔/字符串 state,状态一旦分裂就会出现“框关了箭头还朝上”的错位。
- 所有日期在进入渲染层之前就必须是字符串;
Date对象只允许存在于计算层。 - 分组的意义不是“整理数据”,而是让 key 可被检索——分组用的 key 格式与查询用的 key 格式必须由同一个格式化函数产出。
useMemo的依赖数组是正确性的一部分,不是性能优化选项;漏写依赖 = 缓存了错误结果。useEffect做初始化时,数据可能还没到,取不到就set是首屏崩溃的最常见来源,判空是必需步骤而非保险。- 统计计算(filter + reduce)与数据来源解耦后可以整体复制到子组件,只需替换依赖变量名,这是拆组件时最省事的复用方式。
- 数据流是单向的:
接口数据 → 分组 → 按 key 取列表 → useMemo 计算 → 渲染,任何一环用错格式,问题都会延迟到最末端才暴露,排查时应从末端倒推回分组层。