从零开始学前端 | 第三十五章:记账板筛选切换、统计卡片与组件通信

📅 2026/7/22 11:53:20
从零开始学前端 | 第三十五章:记账板筛选切换、统计卡片与组件通信
本章定位上一章我们已经把记账板第一版最关键的录入闭环打通了。你已经真正做出了这些东西表单可以录入账单信息。提交前可以做基础校验。提交成功后可以新增一条记录。列表可以根据状态渲染出来。没有数据时会显示空状态提示。也就是说到现在为止你已经不只是有一个“页面骨架”而是已经拥有了一个能把用户输入转换成真实记录并显示到界面上的 React 小应用雏形。但如果你继续往下用很快就会遇到几个新的现实问题记录一多怎么只看收入或只看支出顶部统计卡片里的数字怎么接上真实数据筛选按钮点下去后为什么会影响别的组件这些变化到底是谁控制的谁又只是负责显示你会发现这三个问题虽然看起来分别属于筛选功能统计功能组件通信但它们背后其实都在围绕同一件事页面里的多个组件如何围绕同一份状态协作。所以这一章我们会正式把记账板从“表单新增 列表渲染”继续推进到筛选切换、统计卡片和组件通信真正接入项目。本章学习目标学完这一章后你应该能做到理解为什么筛选、统计和组件通信会在同一阶段出现。知道当前筛选类型为什么适合作为页面层状态。学会为记账板补充FilterType和统计结果类型。理解什么是派生数据。知道为什么筛选结果通常不一定需要单独做成状态。学会根据recordList和filterType计算筛选结果。学会根据账单列表计算收入、支出和结余。理解SummaryCards和FilterTabs为什么更适合做展示型组件。掌握父传子、子通过回调影响父的基础通信方式。理解筛选变化后列表和提示文案为什么会一起更新。建立“共享状态放在父层派生结果按需计算”的项目意识。为下一篇继续实现编辑、删除或持久化能力做好准备。一、这一篇要把项目推进到哪一步上一章我们完成的是录入 - 新增 - 列表渲染这一章要继续补齐的是筛选 - 统计 - 组件联动具体来说这一篇希望你真正做出来这些能力可以在“全部 / 收入 / 支出”之间切换列表会根据当前筛选结果变化顶部统计卡片会显示真实数字筛选按钮、统计卡片、列表组件之间能围绕同一份页面状态协作这一步非常重要。因为从这里开始你会第一次非常明确地看到React 项目不是“一个组件一个功能”这么简单而是多个组件一起围绕数据流工作。二、为什么筛选、统计和组件通信总是会一起出现这一点特别值得先讲清楚。很多初学者会觉得筛选是按钮逻辑统计是计算逻辑组件通信是 React 概念好像是三件互不相干的事情。但在真实页面里它们经常会同时出现。例如在记账板里你点击“只看收入”当前筛选状态变化列表显示的内容变了空状态文案可能也变了顶部某些数字可能也要重新计算这说明什么说明它们并不是三条平行线而是某个共享状态一变多个组件会一起响应。而组件通信本质上就是在解决当多个组件都和同一份页面状态有关时它们怎么协作。三、先补两个这一篇会用到的重要类型这一篇继续用 React TypeScript所以正式开始前我们先把两个很关键的类型补上。exporttypeFilterTypeall|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] useStateFilterType(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(filterTypeall){returnrecordList;}returnrecordList.filter(function(record){returnrecord.typefilterType;});}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{constincomeTotalrecordList.filter(function(record){returnrecord.typeincome;}).reduce(function(total,record){returntotalrecord.amount;},0);constexpenseTotalrecordList.filter(function(record){returnrecord.typeexpense;}).reduce(function(total,record){returntotalrecord.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 classNamesummary-grid article classNamepanel summary-card span classNamesummary-label总收入/span strong classNamesummary-number{summaryData.incomeTotal}/strong /article article classNamepanel summary-card span classNamesummary-label总支出/span strong classNamesummary-number{summaryData.expenseTotal}/strong /article article classNamepanel summary-card span classNamesummary-label当前结余/span strong classNamesummary-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修改筛选状态的方法setFilterType2. 子组件做了什么子组件并不自己决定页面最终显示什么。它只是收到当前激活值把点击产生的新值通过回调传回去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 classNamepanel filter-tabs {filterOptions.map(function (option) { const isActive option.value activeFilter; return ( button key{option.value} typebutton className{isActive ? tab-button active : tab-button} onClick{function () { onFilterChange(option.value); }} {option.label} /button ); })} /section ); }1. 这个组件当前阶段最值得你观察什么最值得观察的是它没有自己的筛选状态但它能根据父层给的数据正确高亮并把用户动作反馈回去。2. 这对理解组件职责有什么帮助它会帮助你慢慢建立一种非常关键的感觉某个组件能很有用不一定是因为它管理了很多状态也可能只是因为它把展示和交互入口组织得很清楚。十七、筛选一变为什么列表会跟着变现在我们把前面的链路真正串起来看。当用户点击FilterTabs里的“收入”按钮时FilterTabs调用onFilterChange(income)父组件里的filterType更新成incomefilteredRecordList被重新计算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: 可以切换筛选条件或者继续新增新的账单记录。 };然后再传给RecordListRecordList 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 classNameapp-shell header classNamehero p classNameeyebrow第四阶段综合实战/p h1记账板/h1 p classNamehero-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当前筛选类型filterType2. 页面不是直接保存、而是算出来的内容有哪些例如当前筛选结果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 综合项目的数据流组织主线了。二十八、下一篇预告下一篇我们会继续推进记账板综合实战进入从零开始学前端 | 第三十六章记账板编辑记录、删除交互与本地存储到那时你会继续把这个项目往前推进如何修改已有记录如何删除一条账单为什么本地存储值得接进项目页面刷新后数据怎样保留下来也就是说下一篇开始我们会从“筛选、统计与组件通信”继续走到记账板更接近真实可用状态的完整交互体验。