React待办清单进阶:嵌套子任务、状态管理与性能优化实战

发布时间:2026/10/14 0:56:46
React待办清单进阶:嵌套子任务、状态管理与性能优化实战 简介这是一份面向 React 初学者与前端进阶者的待办事项清单实战项目源码基于 React.js 构建配合 Node.js、Webpack、SCSS 与 HTML5 完成工程化配置适合用来练习组件拆分、状态管理与列表增删交互。项目以目标添加与删除为核心场景用户可输入目标内容后点击 ADD 提交也可在目标渲染到页面后点击删除按钮移除完整覆盖了受控表单、列表渲染与事件处理等常见知识点。压缩包共 15 个文件约 280KB其中 7 个 jsx 承载组件与页面逻辑2 个 js 与 2 个 json 负责入口及依赖配置2 个 html 提供页面骨架另有 1 个 scss 与 1 个 md 分别处理样式与说明文档目录结构清晰便于按模块阅读。目前已有 129 人学习下载读者可借此快速理解 React 项目的组织方式、Webpack 打包流程与 SCSS 样式写法并在此基础上扩展筛选、持久化等功能是入门到进阶过渡阶段较为实用的练习素材。1. 一个待办清单为什么值得用 React 重写三遍很多人第一次接触 React都是从 todo-list 开始的。但真正把它当回事的人不多——大多数人写完增删改查就收工觉得这玩意儿没什么可挖的。我一开始也这么想直到某次在一个模拟项目里需求方要求「待办事项本身也能有待办事项」也就是嵌套子任务我才发现这个看似玩具的题目其实把 React 的核心能力全考了一遍状态提升、不可变更新、列表 key、受控组件、派生状态、性能优化一个都跑不掉。这篇文章不讲「React 是什么」而是把 react-todo-list 当成一个正经工程来做。我会从数据结构设计讲到状态管理选型从最小可跑代码讲到嵌套渲染和持久化最后把我在这个方向上踩过的坑一条条摊开。适合已经会写 JSX 和 useState、但想让自己的 React 代码从「能跑」变成「能维护」的人。读完你应该能独立写出一个支持嵌套、可持久化、渲染不卡的待办清单并且知道每一步为什么这么做。2. 先把数据结构定下来扁平数组还是嵌套树动手写组件之前最该花时间的不是 UI而是想清楚数据长什么样。待办清单看起来简单但一旦支持子任务数据结构的选择会直接决定后面所有代码的复杂度。这一章把两种主流方案摆出来对比再给出我实际会用的那一种。2.1 扁平数组 parentId为什么我最终选它最直觉的做法是嵌套树每个 todo 对象里带一个children数组子任务直接塞进去。渲染的时候递归就行看起来很美。但问题出在更新上——你要修改第三层某个子任务的完成状态就得写一个递归函数去遍历、定位、重建整条路径上的所有对象。React 强调不可变更新这意味着每一层父节点都要浅拷贝一次代码很快就变成一团递归。扁平数组的思路是所有任务放在同一个数组里用parentId字段表达层级关系。根任务的parentId为null子任务指向父任务的id。这样更新任何一个任务都只是对数组做一次map找到 id 匹配的那一项替换掉其余原样返回。层级关系在渲染时通过一次分组计算出来而不是存在数据里。// 扁平结构所有任务同层用 parentId 表达父子关系 const initialTodos [ { id: 1, text: 准备周会材料, done: false, parentId: null }, { id: 2, text: 整理上周数据, done: false, parentId: 1 }, { id: 3, text: 写会议纪要模板, done: true, parentId: 1 }, { id: 4, text: 回复邮件, done: false, parentId: null }, ];这段数据里id 为 2 和 3 的任务是 id 为 1 的子任务。注意done字段是每个任务独立的父任务的完成状态不由子任务自动推导——这一点后面会专门讲因为它是很多人翻车的地方。选扁平结构的核心理由是更新路径恒定。不管任务嵌套多深更新逻辑都是同一个map不需要递归不需要关心层级。代价是渲染前要做一次分组但这个计算量很小而且可以用useMemo缓存。相比之下嵌套树每次更新都要处理「这条路径上所有祖先」的拷贝代码量和出错概率都随深度增长。2.2 用 useMemo 把扁平数组转成渲染树扁平数据不能直接渲染成层级 UI中间需要一步转换按parentId把任务分组再从根任务开始递归组装。这一步放在组件里做但必须用useMemo包起来否则每次渲染都重算任务一多就卡。import { useMemo } from react; // 把扁平数组转成以 parentId 为键的分组表 function buildTree(todos) { const childrenMap new Map(); const roots []; for (const todo of todos) { if (todo.parentId null) { roots.push(todo); } else { if (!childrenMap.has(todo.parentId)) { childrenMap.set(todo.parentId, []); } childrenMap.get(todo.parentId).push(todo); } } // 递归组装返回带 children 的树 function attach(node) { const kids childrenMap.get(node.id) || []; return { ...node, children: kids.map(attach) }; } return roots.map(attach); } // 组件内使用todos 不变时不重算 const tree useMemo(() buildTree(todos), [todos]);buildTree分两步先遍历一次把所有任务按parentId归到各自的父节点下再从根节点递归挂载。时间复杂度是 O(n)因为每个任务只被访问常数次。useMemo的依赖数组是todos只有任务数组真正变化时才重新构建树。这里有个细节值得说childrenMap用Map而不是普通对象是因为任务 id 可能是数字也可能是字符串Map的键类型不会像对象那样被强制转成字符串避免 id 为1和1撞车这种玄学问题。2.3 不可变更新的三种写法与性能差异React 里更新状态必须返回新对象不能直接改原数组。围绕扁平结构更新一个任务有三种常见写法性能差别不小。第一种是全量map// 写法一全量 map最直观 function toggleTodo(todos, id) { return todos.map(todo todo.id id ? { ...todo, done: !todo.done } : todo ); }第二种是先找索引再切片替换// 写法二定位索引后切片避免遍历全部 function toggleTodo(todos, id) { const index todos.findIndex(t t.id id); if (index -1) return todos; const next todos.slice(); next[index] { ...next[index], done: !next[index].done }; return next; }第三种是用Map或对象做索引适合任务量大的场景// 写法三维护 id 到索引的映射O(1) 定位 // 适合任务数上千、频繁切换完成状态的场景 function toggleTodo(todos, indexMap, id) { const index indexMap.get(id); if (index undefined) return todos; const next todos.slice(); next[index] { ...next[index], done: !next[index].done }; return next; }三种写法都满足不可变要求区别在定位成本。写法一是 O(n) 遍历写法二也是 O(n) 但常数更小写法三把定位降到 O(1)代价是要额外维护一个索引映射并且每次增删任务都要同步更新它。我的经验是任务数在几百以内写法一完全够用代码可读性最好上千条且切换频繁再考虑写法三。过早优化索引映射反而容易在增删时忘记同步引入难查的 bug。3. 从零跑通最小版本组件拆分与状态归属数据结构定了接下来把它变成能跑的界面。这一章给出一个最小但结构清晰的可运行版本重点讲清楚状态放在哪、组件怎么拆以及为什么受控组件在这个场景里是必须的。3.1 状态放在哪个组件一次状态提升的决策最小版本需要三个组件输入框、列表、单条任务。状态任务数组和输入框的值应该放在哪答案是放在它们最近的共同父组件里也就是顶层。输入框的值如果放在输入框组件内部父组件就拿不到它提交时无法读取任务数组如果放在列表组件里输入框提交时又没法往里加数据。所以两者都提升到顶层。import { useState } from react; export default function TodoApp() { const [todos, setTodos] useState([]); const [input, setInput] useState(); // 新增根任务 function addTodo() { const text input.trim(); if (!text) return; // 空输入直接忽略避免产生空任务 setTodos(prev [ ...prev, { id: crypto.randomUUID(), text, done: false, parentId: null }, ]); setInput(); } return ( div input value{input} onChange{e setInput(e.target.value)} onKeyDown{e e.key Enter addTodo()} placeholder添加待办 / button onClick{addTodo}添加/button TodoList todos{todos} setTodos{setTodos} / /div ); }crypto.randomUUID()是浏览器原生 API用来生成唯一 id省去引入第三方库。setTodos用函数式更新prev [...]保证在连续快速添加时不会因为闭包拿到旧值而丢数据——这是 React 批处理更新下的常见坑后面避坑章节会展开。TodoList接收todos和setTodos负责渲染和把更新操作透传下去。把setTodos直接传下去在小型应用里可以接受但任务一多更好的做法是把具体操作toggle、remove、addChild封装成函数再传减少子组件对状态结构的依赖。3.2 受控输入与回车提交的完整实现输入框必须是受控的value绑定input状态onChange更新它。非受控输入用 ref 读值在这个场景里能用但一旦需要在提交前做校验、清空、或者根据输入内容实时禁用按钮受控就是唯一选择。回车提交通过onKeyDown判断e.key Enter实现。注意不要用onKeyPress它已被废弃且在部分输入法组合输入时会误触发。用onKeyDown配合e.nativeEvent.isComposing判断可以进一步避免中文输入法选词时回车被当成提交function handleKeyDown(e) { // 输入法组合中如中文选词不触发提交 if (e.nativeEvent.isComposing) return; if (e.key Enter) addTodo(); }isComposing这个属性是血泪经验不判断它中文用户选词按回车会直接提交半截内容体验很差。这个坑在英文环境下测不出来只有中文输入法才会暴露。3.3 列表渲染与 key 的正确选择渲染任务列表时key必须用任务自己的id不能用数组索引。用索引做 key在删除或重排时 React 会复用错误的 DOM 节点导致输入框内容错位、动画错乱这类难查的问题。function TodoList({ todos, setTodos }) { const tree useMemo(() buildTree(todos), [todos]); function toggle(id) { setTodos(prev prev.map(t (t.id id ? { ...t, done: !t.done } : t)) ); } function remove(id) { // 删除任务时连同它的所有后代一起删 setTodos(prev { const toRemove new Set([id]); let changed true; while (changed) { changed false; for (const t of prev) { if (t.parentId toRemove.has(t.parentId) !toRemove.has(t.id)) { toRemove.add(t.id); changed true; } } } return prev.filter(t !toRemove.has(t.id)); }); } return ( ul {tree.map(node ( TodoItem key{node.id} node{node} onToggle{toggle} onRemove{remove} / ))} /ul ); }remove函数里那段while循环是为了级联删除删掉一个父任务它的子任务、孙任务都得跟着走否则会留下parentId指向已删除任务的孤儿节点渲染时它们既不在根列表里也挂不到任何父节点上直接消失但数据还在属于典型的黑匣子 bug。用Set记录待删 id循环直到没有新节点被加入保证任意深度都能清干净。4. 嵌套子任务递归渲染与完成状态的传递支持子任务是这个标题里「有待办事项的待办事项」的核心。这一章讲递归组件怎么写、父子完成状态怎么联动以及为什么我不建议让父任务状态自动由子任务推导。4.1 递归组件 TodoItem 的写法TodoItem渲染自己如果它有children就对每个子节点再渲染一个TodoItem。递归的终止条件是节点没有子节点。function TodoItem({ node, onToggle, onRemove, depth 0 }) { return ( li style{{ marginLeft: depth * 16 }} input typecheckbox checked{node.done} onChange{() onToggle(node.id)} / span style{{ textDecoration: node.done ? line-through : none }} {node.text} /span button onClick{() onRemove(node.id)}删除/button {node.children.length 0 ( ul {node.children.map(child ( TodoItem key{child.id} node{child} onToggle{onToggle} onRemove{onRemove} depth{depth 1} / ))} /ul )} /li ); }depth参数只用来控制缩进不影响逻辑。递归渲染的关键是每个子节点都要带上完整的操作函数这样任意深度的任务都能被切换和删除。注意node.children是在buildTree阶段就挂好的渲染时不需要再查表。递归组件有一个隐患如果数据里出现循环引用A 的 parentId 指向 BB 又指向 A递归会无限展开直到栈溢出。正常操作不会产生循环但如果从外部导入数据最好在buildTree里加一个访问集合做防护。4.2 父任务完成时子任务要不要跟着变这是需求层面最容易被忽略的问题。产品经理说「父任务完成了子任务应该也完成吧」听起来合理但实现上有两种截然不同的做法。第一种是级联更新切换父任务时把所有后代任务的done一起改掉。实现上要收集所有后代 id然后批量更新。好处是数据一致坏处是用户如果只想标记父任务完成、保留子任务状态就做不到。第二种是独立状态 派生显示每个任务的done独立存储父任务的显示状态由「自身 done」和「子任务是否全完成」共同决定。这种做法更灵活但显示逻辑复杂用户也容易困惑。我一般选第一种因为符合大多数人的直觉实现也直接function toggleWithChildren(todos, id) { // 先收集所有后代 id const ids new Set([id]); let changed true; while (changed) { changed false; for (const t of todos) { if (t.parentId ids.has(t.parentId) !ids.has(t.id)) { ids.add(t.id); changed true; } } } // 找到目标任务的当前状态取反后应用到所有后代 const target todos.find(t t.id id); const nextDone !target.done; return todos.map(t (ids.has(t.id) ? { ...t, done: nextDone } : t)); }这段逻辑和级联删除结构一样只是最后一步从过滤变成映射。nextDone取自目标任务取反后的值保证父任务和所有后代状态一致。4.3 添加子任务的入口设计子任务的添加入口通常放在每个任务项上点一下在该任务下插入一个parentId指向它的新任务。这里要注意新任务的插入位置直接push到数组末尾渲染时因为buildTree按parentId分组它会正确出现在父任务下面不受数组顺序影响。function addChild(todos, parentId, text) { return [ ...todos, { id: crypto.randomUUID(), text, done: false, parentId }, ]; }数组顺序不影响渲染层级这是扁平结构的一个额外好处。但如果你希望同级任务按添加时间排序就要么保证插入顺序要么在buildTree里对每个分组做一次排序。我一般不加排序保持插入顺序即可简单且可预测。5. 避坑与排查那些让待办清单翻车的细节这个方向看起来简单但真正上线或长期使用后暴露的问题不少。这一章列五条我实际遇到过的坑每条按现象、原因、解决写清楚。5.1 连续快速添加时任务丢失现象快速连按回车添加任务偶尔有一两条没进去。原因setTodos([...todos, newTodo])这种写法直接引用了闭包里的todos。React 的状态更新是异步批处理的连续调用时后一次拿到的todos可能还是旧值导致前一次添加被覆盖。解决一律用函数式更新setTodos(prev [...prev, newTodo])让 React 把更新排队每次基于最新的prev计算。这个习惯应该贯穿所有状态更新不只是添加。5.2 用索引做 key 导致输入框内容错位现象列表中间删掉一项后剩下某项的输入框里显示的是别的任务的内容。原因key用了数组索引。删除后索引整体前移React 认为索引 2 还是索引 2复用了同一个 DOM 节点但对应的数据已经换了。解决key永远用数据自身的稳定 id。如果数据没有 id在创建时就生成一个不要用索引凑合。这个坑在纯展示列表里不明显一旦列表项带输入框或内部状态就会暴露。5.3 中文输入法回车误提交现象用中文输入法打字选词时按回车任务被提前提交内容只有半截拼音或半个词。原因输入法组合输入过程中回车用于确认候选词但onKeyDown也会收到这个回车事件。解决在键盘处理函数里判断e.nativeEvent.isComposing为true时直接返回不触发提交。这个属性在组合输入期间为true输入完成后变回false。5.4 删除父任务留下孤儿节点现象删掉一个有子任务的任务后子任务从界面上消失了但刷新后如果做了持久化又冒出来或者数据里还能查到它们。原因删除只过滤了目标 id没有处理parentId指向它的后代。这些后代既不在根列表里也挂不到任何存在的父节点上渲染时被buildTree忽略但数据还在。解决删除时级联收集所有后代 id 一起过滤用第 3 章那段while循环。持久化场景下尤其要注意否则数据会越积越脏。5.5 持久化后 id 冲突现象从本地存储恢复数据后新增任务的 id 和已有任务重复切换完成状态时一次改掉两条。原因如果 id 用的是自增数字恢复数据后计数器从零开始就会和已有 id 撞车。解决用crypto.randomUUID()这类全局唯一 id不依赖本地计数器。如果必须用自增恢复数据后要把计数器初始化为已有最大 id 加一。持久化本身用useEffect监听todos变化写入localStorage初始化时从localStorage读取作为useState的初始值即可。6. 进阶持久化、性能与一个可复用的自定义 Hook基础功能跑通后真正拉开差距的是持久化和性能。这一章给出一个把待办逻辑抽成自定义 Hook 的写法顺带解决持久化和大列表渲染的问题。6.1 用自定义 Hook 收敛状态逻辑把todos相关的所有操作抽到useTodos里组件只负责渲染。这样逻辑可测试、可复用组件也清爽。import { useState, useEffect, useCallback } from react; const STORAGE_KEY react-todo-list; export function useTodos() { // 初始化时从 localStorage 读取读不到就用空数组 const [todos, setTodos] useState(() { try { const raw localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; // 解析失败时降级为空避免整个应用白屏 } }); // todos 变化时写入 localStorage useEffect(() { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }, [todos]); const add useCallback((text, parentId null) { const trimmed text.trim(); if (!trimmed) return; setTodos(prev [ ...prev, { id: crypto.randomUUID(), text: trimmed, done: false, parentId }, ]); }, []); const toggle useCallback(id { setTodos(prev { const ids collectDescendants(prev, id); const target prev.find(t t.id id); if (!target) return prev; const nextDone !target.done; return prev.map(t ids.has(t.id) ? { ...t, done: nextDone } : t ); }); }, []); const remove useCallback(id { setTodos(prev { const ids collectDescendants(prev, id); return prev.filter(t !ids.has(t.id)); }); }, []); return { todos, add, toggle, remove }; } // 收集某任务及其所有后代的 id function collectDescendants(todos, rootId) { const ids new Set([rootId]); let changed true; while (changed) { changed false; for (const t of todos) { if (t.parentId ids.has(t.parentId) !ids.has(t.id)) { ids.add(t.id); changed true; } } } return ids; }useState的初始化函数只在首次渲染执行所以从localStorage读取只发生一次。try/catch是必要的存储里的数据可能被手动改坏或版本不兼容解析失败时降级为空数组比整个应用崩掉好。useCallback包住操作函数保证引用稳定配合React.memo可以减少子组件无谓重渲染。6.2 大列表下的渲染优化取舍任务上千条时每次切换一个任务的完成状态都会触发整个列表重渲染。优化手段有两个层次。第一层是React.memo包住TodoItem让 props 没变的项跳过渲染。但要注意onToggle和onRemove必须是稳定引用用useCallback否则每次父组件渲染都生成新函数memo直接失效。import { memo } from react; const TodoItem memo(function TodoItem({ node, onToggle, onRemove, depth }) { // ...渲染逻辑同上 });第二层是虚拟滚动只渲染视口内的项。但虚拟滚动和递归嵌套结构天然冲突——嵌套列表的高度不固定滚动位置计算复杂。我的建议是任务数在几百以内memo加useCallback足够真要上千条先考虑产品上是否该把已完成任务折叠或分页而不是硬上虚拟滚动。为了性能把代码复杂度拉满维护成本往往比收益高。6.3 一个验证清单怎么确认你的实现没退化改完代码后用下面这张表快速自检。每一条都对应前面提过的一个具体问题过一遍能挡掉大部分回归。检查项操作预期结果快速连续添加连按回车 5 次5 条任务全部出现无丢失中文输入法提交用拼音输入后按回车选词不触发提交选完词再回车才提交删除父任务删除带子任务的父项子任务一并消失刷新后不复活切换父任务勾选带子任务的父项所有后代同步勾选刷新持久化添加任务后刷新页面任务和层级关系完整保留空输入提交输入框为空时按回车不产生空任务这张表我每次改完useTodos都会跑一遍尤其是级联删除和持久化这两块改动一处很容易影响另一处。养成改完就过清单的习惯比事后 debug 省事得多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询