:React 组件与 Hooks 实战)
上一篇固定了运行时、依赖与质量门禁本篇开始在同一任务看板中实现可交互列表。目标不是记住 Hook 名称而是学会用单向数据流划组件边界用状态快照解释更新并把外部同步与纯计算分开。一、痛点组件拆分不是切文件一个组件同时读取接口、过滤任务、处理输入、展示列表时任何变化都会触碰整块代码。合理边界来自“变化原因”筛选栏因查询条件变化任务项因单条任务变化列表因集合变化。父组件持有最小共享状态通过 props 下发数据、通过回调上报事件不要在父子两处复制同一任务状态。React 渲染读取的是某次渲染的状态快照。事件处理器闭包也看到创建它的那次快照所以连续执行setCount(count 1)不等于累加三次。新值依赖旧值时使用函数式更新setCount(value value 1)。对象与数组要创建新引用禁止原地push后把原数组传回否则既破坏历史快照也让基于引用的跳过渲染失效。划边界时可以连续问三个问题这段内容能否仅凭输入渲染这个事件由谁拥有失败或加载状态属于谁。任务项只需要任务与切换回调因此可保持纯净创建表单拥有草稿和字段错误任务页拥有集合及服务端状态。组件不是越小越好若拆出的文件仍共享大量隐式条件反而增加跳转成本。好的边界让一个需求变化只触碰少数职责明确的单元。二、原理渲染必须保持纯净组件函数应只由 props、state 和 context 推导 JSX。同样输入得到同样输出React 才能安全重试、中断或批处理渲染。网络、定时器、DOM 订阅属于外部系统同步放进 Effect 并返回清理函数从任务数组算出“未完成数量”只是派生值应直接计算而不是用 Effect 再存一份。“纯净”不代表组件不能创建临时数组或格式化文本而是渲染不能改变组件外世界。排序时尤其容易误伤 propstasks.sort()会原地修改父组件传入的数组应使用复制后的数组或非变异方法。日期、随机数等看似普通的读取也会让同一输入产生不同结果需要稳定值时在事件中生成并写入状态再让渲染消费它。这个原则同样适用于 Vue 的计算属性和其他声明式 UI。下面用不可变 reducer 演示事件如何产生新状态。事件描述“发生了什么”reducer 决定“状态如何改变”这使逻辑能脱离 UI 独立测试。fromdataclassesimportdataclass,replacedataclass(frozenTrue)classTask:id:inttitle:strdone:boolFalsedefreducer(tasks,action):kindaction[type]ifkindadded:titleaction[title].strip()ifnottitle:returntasks next_idmax((task.idfortaskintasks),default0)1returntasks(Task(next_id,title),)ifkindtoggled:targetaction[id]returntuple(replace(task,donenottask.done)iftask.idtargetelsetaskfortaskintasks)raiseValueError(funknown action:{kind})state(Task(1,搭建环境),)statereducer(state,{type:added,title:编写组件})statereducer(state,{type:toggled,id:1})print([(task.title,task.done)fortaskinstate])print(remaining,sum(nottask.donefortaskinstate))运行输出[(搭建环境, True), (编写组件, False)] remaining 1三、实现从受控输入到自定义 Hook任务输入框使用受控模式value来自 stateonChange更新 state提交时校验并发出onAdd(title)。稳定 ID 来自服务端或创建时生成绝不能用数组索引作为可排序列表的 key索引会让 React 把旧组件实例错误复用到另一条任务输入焦点和局部状态随之串位。自定义 Hook 提取的是状态逻辑而非共享状态本身。useTaskFilter(tasks)可以持有 query 并返回 filtered调用两次会得到两份独立状态。如果多个远距离组件必须共享筛选条件应提升状态或使用 context而不是误以为 Hook 自动建了全局单例。Effect 的依赖列表要包含读取的响应式值若不断重连先判断对象或函数是否可以移入 Effect 内部而非关闭 lint 规则。Hook 的返回契约也值得设计。比起返回一个无边界的大对象可返回明确的query、setQuery、status与visibleTasks调用方无需知道内部是否用 reducer。若 Hook 发起订阅测试其挂载、依赖改变和卸载三个阶段确保旧订阅先清理。异步请求要让旧响应失效不能只靠“组件还挂载着”因为同一组件的查询参数已经可能变化。下一篇引入路由后筛选会移入 URLHook 仍可复用纯过滤部分。fromdataclassesimportdataclassdataclass(frozenTrue)classTask:id:inttitle:strdone:booltasks(Task(1,搭建工程环境,True),Task(2,实现任务组件,False),Task(3,阅读 Hooks 文档,False),Task(4,部署预览环境,False),)defvisible(items,query,statusall):needlequery.strip().casefold()result[]fortaskinitems:matches_textneedleintask.title.casefold()matches_status(statusallor(statusdoneandtask.done)or(statusopenandnottask.done))ifmatches_textandmatches_status:result.append(task)returnresult selectedvisible(tasks,query组件,statusopen)print([task.idfortaskinselected])print([task.titlefortaskinselected])运行输出[2] [实现任务组件]四、踩坑不要用 Effect 修正建模错误常见反模式是useEffect(() setFiltered(...), [tasks, query])它先用旧结果渲染再额外更新一次制造重复渲染与短暂不一致。直接在渲染中计算只有昂贵且依赖稳定时才考虑useMemo它是性能提示不是正确性工具。useCallback同理只有传给记忆化子组件或作为其他 Hook 依赖且确有收益时才使用。不要在渲染期间请求数据或修改全局变量。开发模式下 Strict Mode 可能重复调用以暴露不纯逻辑这不是“React 多执行一次的 bug”。订阅必须对称清理网络请求要处理竞态或交给数据缓存库。Context 也不应成为默认状态仓库它适合低频、跨层级数据频繁变化的大对象会扩大重渲染范围。另一个坑是为了减少重渲染而过早缓存一切。memo比较、useMemo依赖维护和useCallback闭包本身都有成本也可能隐藏错误依赖。先保证数据模型正确再用 Profiler 找到昂贵提交。若一个表单击键导致整张看板更新优先把草稿状态下沉到表单这通常比给几十个任务项添加缓存更简单、更稳定。五、验证按用户行为测试状态逻辑先给 reducer 写表驱动测试空标题不新增、未知 ID 不改变任务、切换两次回到原值、原状态引用内容不被修改。组件测试应查询可访问角色和文本模拟输入与提交再断言用户可见结果而不是读取内部 state。React DevTools Profiler 可确认是哪一项重渲染但在测量前不要加入 memo。再增加一次身份稳定性验收先在第二项输入局部内容随后排序或删除第一项确认内容仍属于原任务这能直接暴露索引 key。用假定时器验证订阅清理用快速切换查询验证旧结果不会覆盖新结果。迁移到别的组件框架时可以保留同一组行为用例因为它们描述的是状态所有权、列表身份和副作用生命周期而不是 React 内部实现。本篇形成了纯渲染、事件更新、外部同步三层心智模型并完成本地任务列表。下一篇把列表迁入 Next.js App Router让 URL 承担可分享状态并比较预渲染、请求时渲染与客户端交互的边界。参考来源React组件与 HooksReact选择状态结构React你可能不需要 Effect 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《现代前端框架实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。