从零开始学前端 | 第三十四章:记账板表单录入、记录新增与列表初步渲染 本章定位上一章我们已经把第四阶段综合实战的地基搭起来了。你已经完成了这些非常关键的准备工作明确了记账板项目为什么适合作为 React 阶段综合实战。拆清楚了项目的核心功能、数据、状态和界面区域。想清楚了第一版项目的目录结构和组件骨架。知道了这个项目最核心的数据模型应该长什么样。也就是说到现在为止你已经不再只是“准备做项目”而是已经进入了可以真正把第一版交互闭环做出来的阶段。这一篇我们会正式把上一章搭好的页面骨架填起来。但要特别注意我们这一篇不会一口气把筛选、统计、编辑、删除全部做完。这一篇只聚焦第一版里最关键的一条主线表单值怎么录入提交后如何新增一条账单记录新增后列表怎样渲染出来没有数据时空状态如何显示只要这条主线打通你就已经真正拥有了一个“能录入、能显示”的 React 小项目雏形。所以你可以把这一章理解成把记账板从“页面骨架”推进到“第一版核心交互跑通”。本章学习目标学完这一章后你应该能做到理解为什么综合实战第一版要先收窄目标。知道记账板第一版最核心的交互闭环是什么。学会为记账板补齐最基础的类型定义。理解为什么表单值和最终业务对象要分开看。掌握 React 表单值状态的第一版组织方式。学会用受控组件完成账单录入。理解提交前为什么要先做校验。学会在App层完成新增记录逻辑。掌握列表初步渲染与空状态切换的基础写法。建立“输入 - 校验 - 新增 - 渲染”的完整数据流意识。避开这一阶段最常见的几个坑。为下一篇继续实现筛选、统计与更多组件协作做好准备。一、这一篇要先把什么真正做出来很多人一进入综合实战就会不自觉地想既然都开始做项目了那是不是要一口气把所有功能都写完这通常不是最稳的节奏。对于当前这篇来说我们先只做一条最关键的闭环用户在表单里输入账单信息点击提交按钮页面校验输入是否合理把输入值转换成真正的账单对象把新账单加入列表状态列表自动渲染出最新结果如果列表原来为空空状态自动消失这条闭环一旦打通你就已经真正拥有了一个能“录入数据并显示结果”的 React 页面。这一步非常重要。因为它会把你前面学过的这些知识第一次真正串起来受控组件state提交事件条件渲染列表渲染组件通信二、先把第一版目标收窄不追求一步到位这一节非常重要。因为综合实战刚开始时最容易犯的错误不是“不会写”而是一次给自己安排了太多目标。当前阶段更稳的做法是先把第一版项目目标收窄。1. 这一篇必须完成的事情至少完成这些表单可以正常录入提交时能做基础校验成功后能新增一条记录页面能把记录渲染成列表没有记录时能显示空状态2. 这一篇先不急着做满的事情可以暂时留到下一篇或后续版本筛选切换统计卡片真实计算编辑记录删除记录本地存储3. 为什么这样更稳因为当前阶段最重要的不是“功能数量”而是先把第一条核心数据流跑顺。只有第一条主线清楚了后面的功能叠加才不会越来越乱。三、先把这一篇会用到的类型补齐因为这是 React TypeScript 项目所以正式开始交互前最稳的做法还是先把核心类型写清楚。下面先看这一篇最基础的一组类型。exporttypeRecordTypeincome|expense;exportinterfaceRecordItem{id:number;type:RecordType;category:string;amount:number;date:string;note:string;}exportinterfaceRecordFormValue{type:RecordType;category:string;amount:string;date:string;note:string;}1. 这里最值得你注意什么重点有两件事最终账单对象里的amount是number表单值里的amount是string这不是写错了而是一个非常值得建立的意识。2. 为什么第一版就值得把它们分开因为输入框拿到的值天然更接近字符串真正进入账单列表的数据才更适合转成数字。这就是“表单值”和“业务对象”分开的价值。四、为什么amount在表单里先用字符串很多初学者第一次看到这里会有一个非常自然的问题金额明明是数字为什么不直接在表单状态里也写成数字这个问题问得非常好。当前阶段你可以先这样理解1. 输入框返回的内容本来就更接近字符串例如用户在输入框里输入88120.5空字符串这些值在表单阶段都更像是文本输入结果。2. 业务阶段才更适合转成数字例如真正要加入账单数组时用来做收入支出统计用来在列表中展示金额用来后面参与筛选和计算这时候amount更适合变成number。3. 当前阶段你最该建立的意识你可以先记住一句很关键的话表单阶段的数据更关注“用户输入了什么”业务阶段的数据更关注“系统最终要怎么用它”。五、先准备一个“初始表单值”这一节非常实用。因为很多人一开始会直接把初始对象写死在useState里但后面一到“重置表单”时就开始重复。更稳的做法是先准备一个函数。exportfunctioncreateInitialFormValue():RecordFormValue{return{type:expense,category:,amount:,date:,note:};}1. 为什么这一步很有价值因为后面至少有两个地方会用到它页面初始化时新增成功后重置表单时2. 当前阶段最值得记住什么你可以把它理解成只要一份默认结构可能会被重复用到就值得先把它单独提出来。这能明显减少重复代码。六、第一版App层为什么先掌握这几个状态当前阶段我们先不急着把所有状态都拆很细。第一版只要先把最影响主线的三个状态放在App层就够了。const [recordList, setRecordList] useStateRecordItem[]([]); const [formValue, setFormValue] useStateRecordFormValue(createInitialFormValue()); const [message, setMessage] useState(请先录入第一条账单记录。);1.recordList在负责什么它保存当前页面上所有账单记录。后面的列表渲染空状态显示统计计算都会和它相关。2.formValue在负责什么它保存当前表单里用户正在输入的内容。这是受控组件的核心状态。3.message在负责什么它先承担最基础的提示职责。例如表单校验失败时的提示新增成功后的提示初始页面的引导说明4. 为什么第一版先不急着上更多状态因为我们当前只是在打通第一条闭环。还没进入编辑模式筛选状态加载状态所以先把状态压在最小闭环里会更稳。七、先写一个统一的表单更新函数如果你给每个字段都单独写一套更新函数很快就会出现重复。当前阶段可以先用一个很够用的方式来统一处理。function handleFormValueChange(nextFieldValue: PartialRecordFormValue) { // 只更新当前传进来的字段其他字段保持不变 setFormValue(function (prevFormValue) { return { ...prevFormValue, ...nextFieldValue }; }); }1. 这个函数到底在做什么它的思路其实很简单你告诉它“这次哪个字段变了”它把变动合并到原来的表单状态里其他没变的字段继续保留2. 为什么这比每个字段单独写更稳因为表单字段一多如果每个字段都来一套handleCategoryChangehandleAmountChangehandleDateChange很快就会显得重复。这个统一方式对当前阶段非常够用。八、RecordForm第一版先负责什么现在我们开始把表单组件真正接上。这一版RecordForm最核心的职责只有三个展示当前表单值接收输入变化触发表单提交先看 props 结构import type { RecordFormValue } from ../types/record; interface RecordFormProps { formValue: RecordFormValue; message: string; onFormChange: (nextFieldValue: PartialRecordFormValue) void; onSubmit: (event: React.FormEventHTMLFormElement) void; }1. 为什么RecordForm不自己持有最终列表状态因为它的职责只是收集输入和触发提交而真正的账单列表属于页面级数据更适合由App掌握。2. 这体现了什么组件分工这正体现了 React 里很重要的一种分工页面层掌握共享状态子组件更专注于局部界面和局部交互。九、先把表单录入这条线跑通接下来先不要追求表单特别漂亮。先把受控组件真正跑起来更重要。例如在RecordForm里你可以先这样接一部分字段select value{formValue.type} onChange{function (event) { onFormChange({ type: event.target.value as RecordFormValue[type] }); }} option valueexpense支出/option option valueincome收入/option /selectinput value{formValue.category} onChange{function (event) { onFormChange({ category: event.target.value }); }} placeholder例如餐饮、工资、交通 /input typenumber value{formValue.amount} onChange{function (event) { onFormChange({ amount: event.target.value }); }} placeholder请输入金额 /1. 这一节最关键的目标是什么不是一次把所有字段写满而是先真正理解输入框显示什么完全由formValue决定输入变化以后再通过事件把新值送回状态。2. 这和上一阶段学的什么最相关这本质上还是React 受控组件只是现在它第一次出现在真正的项目表单里了。十、提交前为什么先做校验这是表单页面里非常关键的一步。如果不先做校验就会很容易出现金额为空分类没填日期没选金额不是有效数字这时候页面虽然“能提交”但数据质量会非常差。所以更稳的做法是先把校验逻辑单独提出来。importtype{RecordFormValue}from../types/record;exportfunctionvalidateRecordForm(formValue:RecordFormValue):string{if(formValue.category.trim()){return请输入账单分类;}if(formValue.amount.trim()){return请输入金额;}if(Number.isNaN(Number(formValue.amount))||Number(formValue.amount)0){return金额必须是大于 0 的数字;}if(formValue.date.trim()){return请选择日期;}return;}1. 为什么这里返回字符串而不是复杂对象因为当前阶段我们的目标是先把第一版单条提示跑顺所以先返回一个错误字符串已经足够让页面进入正常反馈闭环。2. 什么时候再考虑更复杂的字段级错误当你后面需要每个输入框单独显示错误更细致地标出某个字段状态再去扩展也完全来得及。十一、新增一条记录时到底发生了什么现在终于到了这一篇最核心的一段逻辑用户点击提交后页面到底发生了什么你可以先把流程拆成这几步阻止表单默认提交行为调用校验函数如果不通过更新提示信息并结束如果通过把表单值转换成真正的账单对象把新记录加入recordList重置表单更新成功提示先看一版很适合作为起点的写法function handleSubmitRecord(event: React.FormEventHTMLFormElement) { event.preventDefault(); const errorMessage validateRecordForm(formValue); if (errorMessage) { setMessage(errorMessage); return; } const newRecord: RecordItem { id: Date.now(), type: formValue.type, category: formValue.category.trim(), amount: Number(formValue.amount), date: formValue.date, note: formValue.note.trim() }; setRecordList(function (prevRecordList) { return [newRecord, ...prevRecordList]; }); setFormValue(createInitialFormValue()); setMessage(账单记录已新增可以继续录入下一条。); }1. 为什么这里用Date.now()做id因为当前阶段我们只是做一个本地前端练习项目。它已经足够帮助我们唯一标识一条记录在列表渲染时作为key2. 为什么这里把新记录放在数组前面因为对记账板来说很多时候最新记录更适合优先显示。所以我们这里用的是[newRecord, ...prevRecordList]也就是把新记录插到最前面。3. 为什么新增成功后要重置表单因为如果不重置用户下一次录入时就会继续看到上一次内容体验会很奇怪。这也是为什么前面我们先准备了createInitialFormValue()。十二、为什么新增逻辑更适合放在App层这一点很重要。很多初学者会本能地觉得表单提交逻辑当然就应该完全写在表单组件里。但当前阶段更稳的思路是提交动作可以由表单触发但真正的列表数据更新更适合由页面层完成。1. 为什么因为recordList属于整个记账板页面共享的数据。它后面不只是列表组件要用还会影响统计卡片筛选结果空状态2. 所以当前阶段怎么理解最稳你可以先记住表单组件负责“把用户操作抛出来”页面层负责“掌握最终数据结果”。这就是非常典型的 React 数据流分工。十三、RecordList第一版先解决哪两个问题现在我们开始接列表组件。第一版的RecordList不需要做得太复杂先解决两个最关键的问题就够了有数据时怎么渲染列表没数据时怎么显示空状态这两个问题一旦解决页面就已经具备了非常清楚的反馈能力。十四、先把空状态组件接上空状态非常值得尽早出现。因为在项目第一版里列表大概率一开始就是空的。如果这时候页面什么都不显示用户往往会有点懵。先看一个很简单的空状态组件interface EmptyStateProps { title: string; description: string; } export function EmptyState({ title, description }: EmptyStateProps) { return ( section classNamepanel empty-state h3{title}/h3 p{description}/p /section ); }1. 为什么空状态值得单独做成组件因为它很常见而且后面初始空列表当前筛选无结果这些场景都可能复用它。2. 当前阶段这也是在练什么这其实也在练通用组件的最小复用意识十五、再写RecordList的初步渲染接下来我们就把列表组件真正接起来。import type { RecordItem } from ../types/record; import { EmptyState } from ./EmptyState; interface RecordListProps { recordList: RecordItem[]; } export function RecordList({ recordList }: RecordListProps) { if (recordList.length 0) { return ( EmptyState title还没有账单记录 description先在上方录入第一条收入或支出记录。 / ); } return ( section classNamepanel {recordList.map(function (record) { return ( article key{record.id} classNamerecord-item span{record.type income ? 收入 : 支出}/span h3{record.category}/h3 strong{record.amount}/strong p{record.date}/p p{record.note || 暂无备注}/p /article ); })} /section ); }1. 这个组件当前阶段最重要的价值是什么它第一次真正把条件渲染列表渲染key这几件事接到了真实项目里。2. 为什么这里先不急着拆RecordCard因为当前阶段我们的重点是先把“列表能正确显示”这件事跑通。等记录项结构变复杂或者样式和交互更多时再进一步拆RecordCard完全来得及。十六、为什么空状态要尽早接上而不是最后补这一点很容易被忽略但非常重要。很多初学者做项目时会默认只盯着“有数据时怎么显示”却忽略页面一开始往往根本没有数据。如果没有空状态页面给人的感觉就会像是不是还没加载出来是不是我写坏了是不是列表区域漏了而空状态一出现用户就能马上明白当前不是出错了而是还没有数据。这就是条件渲染在真实项目里非常实用的一面。十七、把第一版页面真正串起来到这里我们已经有了App层状态表单值更新函数表单校验函数新增记录函数RecordFormRecordList现在就可以把它们真正串起来。例如App.tsx的主体可以先这样组织main classNameapp-shell header classNamehero p classNameeyebrow第四阶段综合实战/p h1记账板/h1 p classNamehero-desc先实现第一版录入和列表渲染闭环。/p /header SummaryCards / FilterTabs / RecordForm formValue{formValue} message{message} onFormChange{handleFormValueChange} onSubmit{handleSubmitRecord} / RecordList recordList{recordList} / /main1. 为什么这里还可以先保留SummaryCards和FilterTabs因为上一章我们已经把它们作为页面骨架的一部分搭出来了。这一篇虽然还没正式接上它们的真实逻辑但保留结构本身没有问题。2. 当前阶段最重要的不是所有区域都做满而是先让第一条主线真的工作起来表单新增 - 列表渲染 - 空状态切换十八、这一版目录结构可以怎样继续演进当这一篇开始真正写逻辑时目录通常会从上一章继续长成这样src/ ├── components/ │ ├── EmptyState.tsx │ ├── FilterTabs.tsx │ ├── RecordForm.tsx │ ├── RecordList.tsx │ └── SummaryCards.tsx ├── types/ │ └── record.ts ├── utils/ │ └── validateRecordForm.ts ├── App.tsx ├── index.css └── main.tsx1. 为什么这时开始适合加utils/因为表单校验函数已经不只是 JSX 的一部分了。它更像是和界面无关、但会被页面逻辑依赖的普通工具函数这时把它放进utils/就会很自然。2. 为什么现在还不急着上hooks/因为当前阶段还没有明显重复的状态逻辑。所以先别为了“结构好看”过早上自定义 Hook。当前阶段最重要的依然是先把功能主线跑顺再决定哪里值得抽。十九、把这一条数据流翻译成人话这一节非常重要。因为很多初学者代码能照着写出来但脑子里并没有真正形成完整的数据流。我们把这一篇做的事情翻译成最直白的话1. 第一步用户在表单里输入内容例如选择收入或支出输入分类输入金额选择日期输入备注2. 第二步输入一变formValue就跟着变也就是说表单显示什么不是浏览器自己记而是 React 状态在记。3. 第三步点击提交后先校验如果内容有问题就更新message提示用户。4. 第四步校验通过后生成真正的RecordItem这一步会把字符串金额去空格后的文本当前类型整合成一条正式记录。5. 第五步把新记录放进recordList只要列表状态变了页面就会自动重渲染。6. 第六步RecordList根据最新状态决定显示什么有记录就显示列表没记录就显示空状态这就是 React 项目最经典的一条主线之一用户操作更新状态状态变化驱动界面更新。二十、这一篇最容易踩的几个坑这一节建议你认真看。因为这类“第一版闭环”看起来不复杂但初学者非常容易在一些细节上绕进去。1. 坑一直接修改原数组而不是通过状态更新例如不要这样想我直接recordList.push(newRecord)不就行了吗在 React 里更稳的方式是返回一个新的数组让状态更新走完整流程。2. 坑二把amount一开始就当成数字处理如果你过早把它写死成数字输入过程里的空值和过渡状态就会更容易混乱。当前阶段先把它作为表单字符串再在提交成功前转换会更稳。3. 坑三忘记preventDefault()如果你不阻止表单默认提交行为页面可能会直接刷新。这样你就会看到我刚输入的内容怎么一下子没了4. 坑四列表渲染时忘了key这里只要你是在map渲染记录列表就要记得给每一项稳定的标识。5. 坑五新增成功后忘记重置表单这样用户录第二条数据时会很不顺手。6. 坑六空状态逻辑写反一定要先确认什么时候显示空状态什么时候显示列表不要让它们同时出现也不要都不出现。二十一、本章实践练习这一章的练习重点是把“录入 - 新增 - 渲染”这条第一版主线真正练熟。1. 练习 1让列表默认带两条模拟数据请你先手动准备两条RecordItem模拟数据放进初始recordList。这个练习会帮助你在真正开始录入前先观察列表组件是否渲染正确。2. 练习 2让新增的记录永远显示在最前面如果你现在还没特别留意这一点请主动观察新记录是加在前面还是后面这样做对记账板是不是更自然这个练习的重点是理解数组插入顺序也在影响页面体验。3. 练习 3给不同类型的记录加不同视觉提示例如收入用绿色标签支出用橙色或红色标签这个练习会帮你把数据差异和界面表现联系起来。4. 练习 4把校验函数彻底拆到utils/中如果你现在还是把校验逻辑写在App.tsx里可以尝试把它提到独立文件。这个练习会帮助你继续建立JSX、状态逻辑、普通工具函数应该逐渐分层。二十二、学习重点提示这一章请你重点记住下面这些话综合实战第一版最重要的是先打通核心闭环而不是一口气做完所有功能。表单值和最终业务对象不一定是同一种形态尤其是金额这类字段。受控组件依然是 React 表单录入的核心基础。提交前先校验会让页面的数据质量和用户反馈都更稳。页面层更适合掌握共享的列表状态表单组件更适合负责局部输入和触发提交。列表渲染和空状态切换应该尽早接入真实项目。工具函数、组件、页面状态应该慢慢分层但不要过早抽象。用户操作更新状态状态变化驱动界面更新这条主线在项目里依然成立。先把第一条数据流跑顺再去叠加筛选、统计和编辑会轻松很多。如果你只记一句话请记住这一篇真正要练熟的不是“多写几个组件”而是“把表单输入变成一条真实记录再让界面跟着更新”。二十三、本章小结这一章我们正式把记账板从“页面骨架”推进到了“第一版核心交互闭环”。你已经理解了为什么第一版项目应该先收窄目标记账板里表单值和最终记录对象为什么要分开看App层为什么先掌握recordList、formValue和message受控组件如何真正接到项目表单里提交前为什么要先做校验新增一条记录时页面内部到底发生了什么列表渲染和空状态切换怎样接入真实项目更重要的是你开始真正建立一种项目级的 React 感觉组件不是孤立存在的表单、状态、列表和提示信息会围绕一条清晰的数据流一起工作。这一步非常关键。因为从这里开始你已经不只是在搭页面而是在真正做一个可以录入和显示真实数据的 React 小应用。二十四、课后思考题请你认真思考下面这些问题为什么综合实战第一版更适合先收窄目标而不是一口气做满所有功能为什么RecordFormValue和RecordItem最好分开理解为什么amount在表单阶段更适合先用字符串表示为什么新增记录逻辑更适合放在App层而不是完全放在表单组件里为什么空状态应该尽早接进项目而不是最后再补为什么说“输入 - 校验 - 新增 - 渲染”是这一篇最重要的数据流如果以后要做筛选和统计为什么当前这版的recordList状态设计会很关键建议你把这些问题用自己的话写下来。只要你能把这些问题讲清楚说明你已经真正开始进入 React 综合项目实现阶段了。二十五、下一篇预告下一篇我们会继续推进记账板综合实战进入从零开始学前端 | 第三十五章记账板筛选切换、统计卡片与组件通信到那时你会继续把这个项目往前推进当前筛选类型怎么管理收入、支出和结余怎么计算统计卡片怎样接上真实数据筛选组件如何通过回调影响列表结果也就是说下一篇开始我们会从“第一版录入与列表渲染”继续走到记账板更完整的数据流协作与页面联动。