ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 type="checkbox" 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 指向 B,B 又指向 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 省事得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进