本章定位
上一章,我们已经把记账板第一版最关键的录入闭环打通了。
你已经真正做出了这些东西:
- 表单可以录入账单信息。
- 提交前可以做基础校验。
- 提交成功后可以新增一条记录。
- 列表可以根据状态渲染出来。
- 没有数据时会显示空状态提示。
也就是说,到现在为止,你已经不只是有一个“页面骨架”,而是已经拥有了一个:
能把用户输入转换成真实记录,并显示到界面上的 React 小应用雏形。
但如果你继续往下用,很快就会遇到几个新的现实问题:
- 记录一多,怎么只看收入或只看支出?
- 顶部统计卡片里的数字,怎么接上真实数据?
- 筛选按钮点下去后,为什么会影响别的组件?
- 这些变化到底是谁控制的,谁又只是负责显示?
你会发现,这三个问题虽然看起来分别属于:
- 筛选功能
- 统计功能
- 组件通信
但它们背后其实都在围绕同一件事:
页面里的多个组件,如何围绕同一份状态协作。
所以这一章,我们会正式把记账板从“表单新增 + 列表渲染”继续推进到:
筛选切换、统计卡片和组件通信真正接入项目。
本章学习目标
学完这一章后,你应该能做到:
- 理解为什么筛选、统计和组件通信会在同一阶段出现。
- 知道当前筛选类型为什么适合作为页面层状态。
- 学会为记账板补充
FilterType和统计结果类型。 - 理解什么是派生数据。
- 知道为什么筛选结果通常不一定需要单独做成状态。
- 学会根据
recordList和filterType计算筛选结果。 - 学会根据账单列表计算收入、支出和结余。
- 理解
SummaryCards和FilterTabs为什么更适合做展示型组件。 - 掌握父传子、子通过回调影响父的基础通信方式。
- 理解筛选变化后,列表和提示文案为什么会一起更新。
- 建立“共享状态放在父层,派生结果按需计算”的项目意识。
- 为下一篇继续实现编辑、删除或持久化能力做好准备。
一、这一篇要把项目推进到哪一步
上一章我们完成的是:
录入 -> 新增 -> 列表渲染
这一章要继续补齐的是:
筛选 -> 统计 -> 组件联动
具体来说,这一篇希望你真正做出来这些能力:
- 可以在“全部 / 收入 / 支出”之间切换
- 列表会根据当前筛选结果变化
- 顶部统计卡片会显示真实数字
- 筛选按钮、统计卡片、列表组件之间能围绕同一份页面状态协作
这一步非常重要。
因为从这里开始,你会第一次非常明确地看到:
React 项目不是“一个组件一个功能”这么简单,而是多个组件一起围绕数据流工作。
二、为什么筛选、统计和组件通信总是会一起出现
这一点特别值得先讲清楚。
很多初学者会觉得:
- 筛选是按钮逻辑
- 统计是计算逻辑
- 组件通信是 React 概念
好像是三件互不相干的事情。
但在真实页面里,它们经常会同时出现。
例如在记账板里:
- 你点击“只看收入”
- 当前筛选状态变化
- 列表显示的内容变了
- 空状态文案可能也变了
- 顶部某些数字可能也要重新计算
这说明什么?
说明它们并不是三条平行线,而是:
某个共享状态一变,多个组件会一起响应。
而组件通信,本质上就是在解决:
当多个组件都和同一份页面状态有关时,它们怎么协作。
三、先补两个这一篇会用到的重要类型
这一篇继续用 React + TypeScript,所以正式开始前,我们先把两个很关键的类型补上。
exporttypeFilterType="all"|"income"|"expense";exportinterfaceSummaryData{incomeTotal:number;expenseTotal:number;balance:number;}1.FilterType在表达什么
它表达的是:
当前页面正在使用哪一种筛选方式。
第一版我们先只保留三种:
allincomeexpense
这已经足够支撑当前阶段的筛选交互。
2.SummaryData在表达什么
它表达的是统计卡片真正需要的结果:
- 总收入
- 总支出
- 当前结余
这一点很重要,因为统计卡片真正关心的不是整条记录数组,而是:
从数组里算出来的几个结果值。
四、为什么当前筛选类型适合放在App层
这一步非常关键。
很多初学者第一次写筛选时,会自然地想:
既然按钮在
FilterTabs组件里,那筛选状态是不是就放在FilterTabs里?
看起来好像挺合理,但当前阶段更稳的思路是:
哪个状态会影响多个组件,哪个状态就更适合放在它们共同的父层。
而filterType明显会影响:
FilterTabs自己的高亮显示RecordList的列表结果- 空状态提示文案
所以它更适合放在App层。
1. 这体现了 React 哪条非常核心的思路
就是:
共享状态上移到共同父组件。
2. 当前阶段怎么理解最够用
你可以先记住一句话:
谁掌握了会影响全局展示的状态,谁就更像这个页面的数据协调者。
在这个项目里,App就在扮演这个角色。
五、第一版先把filterType状态接进来
当前阶段我们先加一个最基础的筛选状态就够了。
const [filterType, setFilterType] = useState<FilterType>("all");1. 为什么默认值先用all
因为页面初次打开时,最自然的体验通常是:
先看到全部记录。
这样用户对整个页面的数据范围会更容易建立认知。
2. 这一条状态后面会影响什么
至少会影响这几件事:
- 哪个筛选按钮高亮
- 列表该显示哪些记录
- 空状态要显示什么文案
所以别看它只是一条短短的状态,它其实是这一章非常关键的一条主线。
六、什么是派生数据,为什么这一章一定要理解它
这一节非常重要。
因为从这一章开始,你会开始频繁遇到一种很常见的数据:
它不是用户直接输入的,也不是你直接手动保存的,而是根据已有状态算出来的。
这类数据,就很适合先理解为:
派生数据
例如在这一章里:
- 当前筛选后的记录列表
- 当前统计卡片显示的数据
都属于这种感觉。
1. 为什么这类数据特别值得单独讲
因为初学者很容易犯一个常见问题:
只要页面上要显示一个结果,就下意识想再开一个状态。
但实际上,有些结果完全可以:
根据已有状态直接算出来。
这会让你的状态更少、结构更清楚。
2. 当前阶段最值得你先记住什么
如果一个结果只是根据已有状态计算出来的,而且没有必要独立保存,那么你可以先想一想:
它是不是更适合做成派生数据,而不是新的
useState?
七、为什么filteredRecordList通常不一定需要单独做状态
这是本章最关键的认知之一。
很多人第一次做筛选时,最容易走到这样的想法:
- 先有
recordList - 再开一个
filteredRecordList - 每次切换筛选时手动去改它
这不是绝对不行,但当前阶段更稳的思路通常是:
只保留最原始的状态,再根据它们算出筛选结果。
在这个项目里:
- 原始数据是
recordList - 筛选条件是
filterType
那么筛选后的结果完全可以理解成:
recordList + filterType的计算结果
1. 这样做有什么好处
- 原始状态更少
- 不容易出现两份列表不同步
- 数据流更清楚
2. 当前阶段最怕什么
最怕的是:
你一边维护原列表,一边维护筛选列表,最后两边更新时机越来越乱。
所以这一章很值得建立这个意识:
能算出来的结果,先尽量别重复存。
八、先写一个获取筛选结果的函数
这一节我们就把“筛选结果是派生数据”这件事真正写出来。
importtype{FilterType,RecordItem}from"../types/record";exportfunctiongetFilteredRecordList(recordList:RecordItem[],filterType:FilterType):RecordItem[]{if(filterType==="all"){returnrecordList;}returnrecordList.filter(function(record){returnrecord.type===filterType;});}1. 这个函数到底在做什么
它的逻辑其实很直白:
- 如果当前筛选是
all,直接返回全部记录 - 否则,只保留类型匹配的记录
2. 为什么这个函数值得单独提出来
因为它已经不是 JSX 本身的一部分了。
它更像是:
一段和页面逻辑相关、但和具体界面结构无关的纯计算逻辑
这种函数单独放出来,后面会更容易读,也更容易复用。
九、现在回头看,filteredRecordList应该怎么得到
当你已经有了:
recordListfilterTypegetFilteredRecordList
那当前筛选结果就可以很自然地写成:
const filteredRecordList = getFilteredRecordList(recordList, filterType);1. 为什么这个写法特别值得你建立感觉
因为它非常清楚地表达了:
当前要显示的列表,不是另外保存的一份状态,而是根据已有数据推导出来的。
2. 这对后面做更复杂功能有什么好处
比如后面你想继续加:
- 关键词搜索
- 日期筛选
- 排序逻辑
你会更容易把它们都组织成:
若干条件共同推导出最终显示结果
这比维护很多平行状态稳得多。
十、统计卡片为什么也适合走“派生结果”这条路
当我们讲清楚筛选结果之后,再看统计卡片就会更容易。
很多初学者一看到统计数字,也会下意识想:
那我是不是还要开三个状态:
incomeTotal、expenseTotal、balance?
当前阶段通常也先不用。
因为它们本质上也是:
根据账单记录数组计算出来的结果。
例如:
- 总收入 = 所有收入记录金额之和
- 总支出 = 所有支出记录金额之和
- 结余 = 总收入 - 总支出
所以它们也非常适合先走:
原始列表状态 -> 统计结果
这条路。
十一、先写一个统计函数,把卡片数据算出来
下面我们先写一个当前阶段很够用的统计函数。
importtype{RecordItem,SummaryData}from"../types/record";exportfunctioncalculateSummaryData(recordList:RecordItem[]):SummaryData{constincomeTotal=recordList.filter(function(record){returnrecord.type==="income";}).reduce(function(total,record){returntotal+record.amount;},0);constexpenseTotal=recordList.filter(function(record){returnrecord.type==="expense";}).reduce(function(total,record){returntotal+record.amount;},0);return{incomeTotal,expenseTotal,balance:incomeTotal-expenseTotal};}1. 这个函数在做什么
它做了三件事:
- 先把收入金额加总
- 再把支出金额加总
- 最后用收入减去支出得到结余
2. 为什么这里先用全部记录,而不是筛选结果
因为对记账板来说,统计卡片最常见的一种展示方式是:
显示整个账单板当前的整体收支情况
也就是说:
- 筛选主要影响列表
- 统计先看全局结果
这是一种非常自然、也比较适合当前阶段理解的做法。
3. 如果以后你想让统计卡片跟着筛选变怎么办
那也很简单。
你只需要把:
calculateSummaryData(recordList)换成:
calculateSummaryData(filteredRecordList)就能切换成“当前筛选结果统计”。
这也进一步说明:
派生函数一旦设计清楚,后面改展示策略会轻松很多。
十二、统计结果怎样真正接到SummaryCards
现在我们已经有了统计函数,就可以把真实数据接到统计组件里了。
先看在App层里怎么得到它:
const summaryData = calculateSummaryData(recordList);然后SummaryCards的 props 可以先这样设计:
import type { SummaryData } from "../types/record"; interface SummaryCardsProps { summaryData: SummaryData; }1. 为什么这里传summaryData对象也很合适
因为统计卡片本来就是一组相关的结果:
- 收入
- 支出
- 结余
把它们打包成一个对象传进去,会更像一组完整数据。
2. 这也在练哪种意识
这也在帮你慢慢建立:
组件接收的 props,不一定永远是单个简单值,也可以是一组有意义的结果对象。
十三、SummaryCards第一版更适合做展示型组件
这一点也非常重要。
当前阶段更稳的做法是让SummaryCards只负责:
接收数据并展示
例如:
export function SummaryCards({ summaryData }: SummaryCardsProps) { return ( <section className="summary-grid"> <article className="panel summary-card"> <span className="summary-label">总收入</span> <strong className="summary-number">¥{summaryData.incomeTotal}</strong> </article> <article className="panel summary-card"> <span className="summary-label">总支出</span> <strong className="summary-number">¥{summaryData.expenseTotal}</strong> </article> <article className="panel summary-card"> <span className="summary-label">当前结余</span> <strong className="summary-number">¥{summaryData.balance}</strong> </article> </section> ); }1. 为什么不把统计逻辑直接写在SummaryCards里
因为这样会把:
- 页面数据组织
- 统计计算
- 组件展示
三件事混在一起。
当前阶段更稳的分工是:
父层先算好结果,子组件专心展示结果。
2. 这种分工对后面有什么好处
后面如果你要换:
- 样式布局
- 数字格式
- 卡片文案
SummaryCards会更容易改,因为它更专注。
十四、FilterTabs第一版应该怎样设计
现在轮到筛选组件。
当前阶段FilterTabs最核心的职责有两个:
- 展示当前有哪些筛选选项
- 把用户点击的筛选值告诉父组件
也就是说,它不是页面最终数据的拥有者,而是:
页面筛选动作的触发者
先看 props 设计:
import type { FilterType } from "../types/record"; interface FilterTabsProps { activeFilter: FilterType; onFilterChange: (nextFilter: FilterType) => void; }1.activeFilter在做什么
它告诉组件:
当前哪个选项处于激活状态。
这样按钮才能正确高亮。
2.onFilterChange在做什么
它告诉组件:
当用户点击某个选项时,应该把新的筛选值往父层传回去。
这就是一个很典型的 React 回调传递场景。
十五、这就是一次最典型的“父传子 + 子回调父”
这一节非常关键。
因为这其实就是“组件通信”在真实项目里的样子。
先看父组件给子组件传什么:
<FilterTabs activeFilter={filterType} onFilterChange={setFilterType} />1. 父组件传下去了什么
传下去了两样东西:
- 当前筛选状态
filterType - 修改筛选状态的方法
setFilterType
2. 子组件做了什么
子组件并不自己决定页面最终显示什么。
它只是:
- 收到当前激活值
- 把点击产生的新值通过回调传回去
3. 为什么这正是组件通信的核心例子
因为它非常完整地体现了:
- 父组件掌握共享状态
- 子组件通过
props拿到状态 - 子组件通过回调影响父组件状态
- 父组件状态变化后,再反过来影响其他子组件
这就是 React 里非常经典的一条通信链路。
十六、先把FilterTabs真正写出来
下面看一个当前阶段很适合作为第一版的写法。
const filterOptions = [ { label: "全部", value: "all" }, { label: "收入", value: "income" }, { label: "支出", value: "expense" } ] as const; export function FilterTabs({ activeFilter, onFilterChange }: FilterTabsProps) { return ( <section className="panel filter-tabs"> {filterOptions.map(function (option) { const isActive = option.value === activeFilter; return ( <button key={option.value} type="button" className={isActive ? "tab-button active" : "tab-button"} onClick={function () { onFilterChange(option.value); }} > {option.label} </button> ); })} </section> ); }1. 这个组件当前阶段最值得你观察什么
最值得观察的是:
它没有自己的筛选状态,但它能根据父层给的数据正确高亮,并把用户动作反馈回去。
2. 这对理解组件职责有什么帮助
它会帮助你慢慢建立一种非常关键的感觉:
某个组件能很有用,不一定是因为它管理了很多状态,也可能只是因为它把展示和交互入口组织得很清楚。
十七、筛选一变,为什么列表会跟着变
现在我们把前面的链路真正串起来看。
当用户点击FilterTabs里的“收入”按钮时:
FilterTabs调用onFilterChange("income")- 父组件里的
filterType更新成"income" filteredRecordList被重新计算RecordList收到新的recordList- 界面重新渲染出“只有收入”的列表
这整个过程非常重要。
因为它让你真正看到:
子组件自己没有直接改列表,但它通过改变父层共享状态,间接影响了列表结果。
这就是组件通信和数据流协作的实际样子。
十八、现在让RecordList改成接收筛选结果
上一章里,RecordList还是直接接recordList。
到了这一章,更自然的做法是:
<RecordList recordList={filteredRecordList} />1. 这一步最关键的变化是什么
变化不在组件内部,而在于:
RecordList不再负责决定“显示哪些数据”,它只负责“把收到的数据展示出来”。
2. 为什么这种分工特别稳
因为这样你会越来越清楚:
- 数据怎么筛,是父层决定
- 数据怎么显示,是列表组件决定
这就是非常典型的“数据决策在上层,展示逻辑在下层”。
十九、筛选后空状态文案,为什么也可以跟着一起变
这一点很适合拿来练“页面联动感”。
记账板里会有两种很常见的空状态:
1. 一种是页面根本还没有任何记录
这时更适合提示:
先录入第一条账单记录。
2. 另一种是页面本来有记录,但当前筛选下没有匹配结果
这时更适合提示:
当前筛选下还没有对应类型的记录。
3. 为什么这一步很值得做
因为它会让你很直观地感受到:
同一个列表组件,收到的数据和上下文一变,展示文案也可以更贴近真实场景。
这不是为了“花哨”,而是为了让页面反馈更准确。
二十、怎么给RecordList补上更合适的空状态提示
当前阶段可以先在App层推导一个空状态配置,再传给列表组件。
例如:
const emptyStateConfig = recordList.length === 0 ? { title: "还没有账单记录", description: "先在上方录入第一条收入或支出记录。" } : { title: "当前筛选下没有记录", description: "可以切换筛选条件,或者继续新增新的账单记录。" };然后再传给RecordList:
<RecordList recordList={filteredRecordList} emptyTitle={emptyStateConfig.title} emptyDescription={emptyStateConfig.description} />1. 这一步又在练什么
这一步其实又在练两件事:
- 派生结果
- 父组件统一组织页面上下文
2. 为什么这比把判断塞进RecordList更清楚
因为RecordList只需要知道:
如果空了,要显示什么
而“为什么空”“当前属于哪种空”,父层更容易判断清楚。
二十一、把这一章的App主体真正串起来
到这里,我们已经有了:
recordListformValuemessagefilterTypefilteredRecordListsummaryData
所以App的主体会变得更像一个真正的页面协调中心。
例如:
const filteredRecordList = getFilteredRecordList(recordList, filterType); const summaryData = calculateSummaryData(recordList); return ( <main className="app-shell"> <header className="hero"> <p className="eyebrow">第四阶段综合实战</p> <h1>记账板</h1> <p className="hero-desc">让筛选、统计和列表围绕同一份状态协作起来。</p> </header> <SummaryCards summaryData={summaryData} /> <FilterTabs activeFilter={filterType} onFilterChange={setFilterType} /> <RecordForm formValue={formValue} message={message} onFormChange={handleFormValueChange} onSubmit={handleSubmitRecord} /> <RecordList recordList={filteredRecordList} /> </main> );1. 为什么这时的App看起来更像“总控”
因为它已经开始掌握:
- 原始数据
- 共享状态
- 派生结果
- 子组件之间的衔接关系
2. 这其实就是哪种能力在成长
这其实就是:
页面级状态组织能力
它是做 React 小项目时非常关键的一步。
二十二、把这一条数据流翻译成人话
这一节非常重要。
因为只要你能把这条链路用自己的话讲清楚,说明你已经真的开始理解项目,而不是只是在照着写。
1. 页面真正保存的原始状态有哪些
例如:
- 所有账单记录
recordList - 当前表单值
formValue - 当前筛选类型
filterType
2. 页面不是直接保存、而是算出来的内容有哪些
例如:
- 当前筛选结果
filteredRecordList - 当前统计卡片数据
summaryData - 当前空状态文案
3. 用户点筛选按钮时发生了什么
发生的是:
子组件通过回调改了父组件状态,父组件状态变化后,列表和按钮高亮一起更新。
4. 用户新增记录时发生了什么
发生的是:
原始列表状态更新了,所以统计卡片、列表结果和空状态判断也都跟着重新计算。
5. 这一章最关键的一句话是什么
就是:
页面真正保存的是原始状态,很多展示结果其实是围绕原始状态推导出来的。
这句话非常关键。
二十三、这一章最容易踩的几个坑
这一节建议你认真看。
因为这一章开始,页面里的联动明显变多了,初学者很容易被“功能都不难,但怎么老是绕”这种感觉卡住。
1. 坑一:把筛选结果也单独存成状态
如果你已经有:
recordListfilterType
那很多时候filteredRecordList完全可以直接算出来。
2. 坑二:把统计数字也一项项开成状态
如果统计结果只是来源于当前列表,就先别急着单独保存。
3. 坑三:把筛选状态放进FilterTabs自己内部
这样一来,别的组件就很难共享这条状态。
4. 坑四:父组件和子组件都在做同一份判断
例如父组件已经决定了当前筛选结果,子组件里又再筛一次。
这样会越来越乱。
5. 坑五:组件既想算数据,又想管状态,又想管展示
这通常会让组件越来越胖。
当前阶段更稳的方向是:
谁负责组织数据,谁负责展示结果,要慢慢分开。
6. 坑六:只管列表变化,不管空状态变化
筛选一加进来后,空状态就不再只有“完全没数据”这一种情况了。
这一点非常容易漏掉。
二十四、本章实践练习
这一章的练习重点,是把“共享状态 + 派生结果 + 父子通信”真正练熟。
1. 练习 1:给页面加 3 条不同类型的模拟账单
请你先准备:
- 至少 1 条收入
- 至少 2 条支出
然后手动切换筛选按钮,观察:
- 列表结果是否变化
- 按钮高亮是否变化
- 统计卡片是否显示正确
这个练习会帮助你:
真正看到筛选和统计是怎么接上真实数据的。
2. 练习 2:把统计卡片改成显示两位小数
例如把:
88
显示成:
88.00
这个练习会帮助你继续思考:
统计逻辑和展示格式是不是应该放在同一层处理。
3. 练习 3:让筛选后的空状态文案更具体
例如:
- 当前是收入筛选但没有收入记录
- 当前是支出筛选但没有支出记录
这个练习会帮助你把:
页面上下文和展示反馈联系得更紧。
4. 练习 4:尝试把筛选函数和统计函数都放进utils/
如果你现在还把它们写在App.tsx里,可以尝试继续做一次小步整理。
这个练习会帮助你继续建立:
页面、组件、工具函数慢慢分层的手感。
二十五、学习重点提示
这一章请你重点记住下面这些话:
- 筛选、统计和组件通信在项目里经常会一起出现,因为它们本质上都围绕共享状态展开。
- 会影响多个组件的状态,通常更适合放在共同父组件中。
- 当前筛选结果和统计卡片数据,很多时候都属于派生数据。
- 能根据已有状态算出来的结果,先不要急着重复保存成新状态。
- 父组件更适合组织共享状态和派生结果,子组件更适合负责展示和触发动作。
FilterTabs是非常典型的“父传子 + 子回调父”通信场景。SummaryCards更适合做展示型组件,而不是自己持有统计逻辑。- 列表数据一变,统计、筛选结果和空状态都可能跟着联动,这正是 React 项目数据流的价值。
- 先把原始状态想清楚,再去组织派生结果,页面会轻松很多。
如果你只记一句话,请记住:
这一章真正要建立的,不只是“会做筛选和统计”,而是“会围绕原始状态组织多个组件一起协作”。
二十六、本章小结
这一章,我们正式把记账板从“新增记录与列表渲染”推进到了“筛选、统计和组件联动”阶段。
你已经理解了:
- 为什么当前筛选类型适合放在
App层 - 什么是派生数据
- 为什么
filteredRecordList和summaryData往往不一定需要单独做状态 - 如何根据原始列表和筛选条件得到真正要展示的结果
SummaryCards和FilterTabs如何通过props接入真实项目- 为什么这正是一次很典型的 React 组件通信场景
- 为什么筛选切换后,列表、按钮高亮和空状态会一起更新
更重要的是,你开始真正建立一种很关键的页面级理解:
一个 React 页面真正强大的地方,不是某个按钮会不会变色,而是多个组件能不能围绕同一份状态清楚地协作。
这一步非常关键。
因为从这里开始,你已经不只是在给项目加功能,而是在开始真正理解:
React 小项目的数据流组织方式。
二十七、课后思考题
请你认真思考下面这些问题:
- 为什么说筛选、统计和组件通信在项目里经常会一起出现?
- 为什么
filterType更适合放在App层,而不是放在FilterTabs自己内部? - 什么样的数据更适合理解成“派生数据”?
- 为什么很多时候
filteredRecordList不一定需要单独开一个状态? - 为什么
SummaryCards更适合接收结果并展示,而不是自己去算所有数据? - 为什么说
FilterTabs是一个典型的“子通过回调影响父”的通信例子? - 如果以后要继续加关键词搜索或日期筛选,这一章建立的“原始状态 + 派生结果”思路为什么会特别有用?
建议你把这些问题用自己的话写下来。
只要你能把这些问题讲清楚,说明你已经真正开始进入 React 综合项目的数据流组织主线了。
二十八、下一篇预告
下一篇我们会继续推进记账板综合实战,进入:
从零开始学前端 | 第三十六章:记账板编辑记录、删除交互与本地存储
到那时,你会继续把这个项目往前推进:
- 如何修改已有记录
- 如何删除一条账单
- 为什么本地存储值得接进项目
- 页面刷新后数据怎样保留下来
也就是说,下一篇开始,我们会从“筛选、统计与组件通信”继续走到:
记账板更接近真实可用状态的完整交互体验。