Skip to content

记账本项目实战(一) ​

本页对应 记账本项目实战,共 7 段视频,约 1 小时 10 分。

学习目标 ​

  • 能够搭建月度账单统计区域的静态结构,并准确说明时间切换区、统计区与时间选择器的模板对应关系。
  • 能够使用 useState 控制时间选择器显隐,并联动箭头类名及取消、确定、蒙层关闭事件。
  • 能够使用 dayjs 格式化选中时间并回显到统计区域,实现确认后时间显示切换。
  • 能够使用 useMemo 与 lodash.groupBy 将账单数据按月、按日分组,并产出日期列表与分组账单映射。
  • 能够基于选中月份从分组数据中取账单数组,使用 filter、reduce 计算支出、收入、结余并完成初始化渲染与空值边界处理。
  • 能够拆分单日统计组件,通过 props 传递日期与账单列表,并在子组件中完成单日统计计算与渲染。

知识点 ​

本节要完成什么 ​

本节笔记是“环境搭建”:用 CRA 创建项目,并安装后续月度账单功能所需的基础依赖。转写稿里后面反复用到路由进入月度账单、Redux 取 billList、dayjs 格式化年月、classnames 控制箭头类名、antd-mobile 的 DatePicker 弹框、axios 取接口数据。所以环境不是形式,而是给后续功能留好落点。本节笔记原清单如下:

  1. Redux状态管理 - @reduxjs/toolkit、react-redux
  2. 路由 - react-router-dom
  3. 时间处理 - dayjs
  4. class类名处理 - classnames
  5. 移动端组件库 - antd-mobile
  6. 请求插件 - 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 axios

Redux 状态管理 ​

为什么需要:转写稿视频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 改中文)和代码”。但当前输入中只有转写稿文字,没有提供任何图片、图片链接、代码块或完整源码。转写稿虽然多次提到“复制配套代码”“新增样式文件”“复制组件模板”,但没有给出具体代码内容。因此我无法保留图片,也无法保留代码。若我自行补代码,就属于编造。

需要补充的材料 ​

如果要继续完成“配置别名路径”这一节教程,请补充以下任一组材料:

  1. 本节正确转写稿:包含为什么配置别名、在哪个配置文件里配置、别名怎么写、代码里怎么用、报错如何排查;
  2. 本节笔记中的图片和代码:包括配置文件片段、使用别名前后的 import 对比、编辑器报错截图等;
  3. 若实际想写的是“月度账单-统计区域”,请把本节标题改为对应标题,并提供转写稿中提到的组件模板、样式文件、分组与统计代码。
    拿到这些材料后,我可以严格按你的格式输出:用 ### 小标题分段,不输出 ##;保留图片并把 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 这类浮点误差。

常见错误 ​

  1. 页面白屏并报 “Objects are not valid as a React child”

    • 现象:一切换月份就崩溃,控制台指向渲染时间的那一行。
    • 原因:把 Date 对象(或 dayjs() 返回对象)直接写进 JSX 做子节点。
    • 修法:渲染前统一调用 .format('YYYY-MM') 转成字符串;初始值和 onConfirm 回调两处都要转,别只改一处。
  2. 首屏统计全是 0,甚至直接报错

    • 现象:刷新后支出/收入/结余显示 0.00,或控制台出现对 undefined 取值的错误。
    • 原因:useEffect 里用当前月 key 去 monthGroup 取值时,billList 还是空数组、分组结果是空对象,取到 undefined 仍 setState。
    • 修法:取值后先判空再 set(if (list) setCurrentMonthList(list));同时把 monthGroup 补进依赖数组,消除 lint 提示。
  3. 切了月份,下方统计数字纹丝不动

    • 现象:日期栏变了、列表也变了,但支出/收入/结余还是上个月的值。
    • 原因:计算 useMemo 的依赖项写成了 [] 或漏写 currentMonthList,缓存住了首次结果。
    • 修法:依赖项写实际参与计算的那份数据([currentMonthList]);同理子组件里按日统计的 useMemo 依赖要换成 billList,不能沿用父组件的变量名。
  4. 弹框“确定”能关、“取消/蒙层”关不掉(或反过来)

    • 现象:三个关闭入口只有一个生效。
    • 原因:onCancel / onConfirm / onClose 三个回调只绑了一个,或绑错事件名(把 onClose 写成 onCancel);也有把 setVisible(false) 误写成 true 的。
    • 修法:三个事件分别绑,统一收口到一个 close() 函数;后续有业务逻辑时再拆出独立的 onConfirm 具名函数。
  5. 箭头方向不跟随弹框开合

    • 现象:箭头永远朝下,或永远朝上。
    • 原因:classnames 第二个参数传成了字符串 'visible'(恒为真),或直接把状态变量名当类名拼进去。
    • 修法:写成 classNames('arrow', { arrow: visible }) 或 classNames('arrow', visible && 'arrow'),第二参必须是布尔值。
  6. 按月分组取不到数据,key 对不上

    • 现象:monthGroup[formatDate] 恒为 undefined。
    • 原因:分组时 key 用了原始 item.date(带日、带时间),而选中月格式是 YYYY-MM。
    • 修法:分组回调里同样走 dayjs(item.date).format('YYYY-MM'),保证“造 key”和“查 key”用同一个格式化表达式。
  7. 按日分组的列表渲染出来顺序乱 / 拿错数据

    • 现象:日期和金额对不上号,或某个日期重复渲染同一批账单。
    • 原因:map 时用索引去 dayGroup 里取值,或 Object.keys 之后又单独排序导致 key 与对象取值错位。
    • 修法:始终用当前遍历到的 key 去 dayGroup[key] 取列表,列表项 key 也用这个日期字符串,不要用 index。
  8. useEffect 无限循环

    • 现象:页面卡死,Network/Console 疯狂刷新。
    • 原因:把 useEffect 里 set 的那个 state 本身放进了依赖数组。
    • 修法:依赖只放“外部来源”(如 monthGroup),初始化 effect 保持 [] 并在内部做边界处理。

练习 ​

  1. 弹框三入口收口:把 onCancel / onConfirm / onClose 三个回调合并成一个 closePicker(),并额外在 onConfirm 里加一行 console.log 验证“先取日期再关闭”的执行顺序,说明为什么不能把取日期逻辑写在关闭之后。

  2. 格式化统一化改造:找出项目里所有 dayjs 调用点,抽出一个 formatMonth(date) 和 formatDay(date) 两个工具函数,替换掉散落的 .format(...) 字面量,验证切换月份与首屏渲染均不受影响。

  3. 边界值加固:人为把 billList 初始值改成 [] 并延迟 3 秒注入数据,验证首屏不崩、数据到达后统计自动刷新;再故意删掉 useEffect 里的判空,记录报错信息并说明触发链路。

  4. 依赖项排查:把两处 useMemo 的依赖数组分别改成 [],观察“切换月份不更新”和“单日列表金额不变”两个现象,逐个恢复并写出每个依赖项对应的因果。

  5. 按日列表健壮性:给 dayGroup 遍历结果做一次排序(按日期倒序),并给每个子组件补上稳定的 key;再用一个有跨月数据的账号验证“切月后列表不串数据”。

  6. (可选)抽取复用:把父组件里的“支出/收入/结余”计算逻辑抽成一个 useBillSummary(list) 自定义 Hook,父组件与单日子组件共用,验证两处渲染结果一致。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
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 计算 → 渲染,任何一环用错格式,问题都会延迟到最末端才暴露,排查时应从末端倒推回分组层。