从零搭建一个 React Todo 应用:父子通信、状态管理与本地持久化

scss 复制代码
text
编辑
src/
├── App.jsx
├── components/
│   ├── TodoInput.jsx    // 添加任务输入框
│   ├── TodoList.jsx     // 任务列表展示
│   └── TodoStats.jsx    // 统计信息与清除已完成
└── styles/
    └── app.styl         // 全局样式(使用 Stylus)

整个应用围绕 "父组件持有状态,子组件通过 props 接收数据和回调" 的单向数据流原则构建。


📦 父组件:App.jsx ------ 状态管理中心

javascript 复制代码
jsx
编辑
import { useState, useEffect } from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList'
import TodoInput from './components/TodoInput'
import TodoStats from './components/TodoStats'

function App() {
  // ✅ 关键:初始化时从 localStorage 读取数据,形成持久化闭环的第一步
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos')
    return saved ? JSON.parse(saved) : []
  })

  const addTodo = (text) => {
    if (!text.trim()) return
    setTodos([...todos, {
      id: Date.now(),
      text,
      completed: false,
    }])
  }

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }

  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }

  const activeCount = todos.filter(t => !t.completed).length
  const completedCount = todos.length - activeCount

  // ⚠️ 注意:这段代码仅完成「写入」,必须配合初始化读取才能实现完整持久化
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos))
  }, [todos])

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList 
        todos={todos}  
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      <TodoStats 
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  )
}

export default App

🔍 本地存储的完整闭环:读 + 写缺一不可

你可能会问:仅靠 useEffect 中的 localStorage.setItem 能实现本地存储吗?

答案是:不能。

  • useEffect 部分只负责"写入" :当 todos 变化时,自动同步到 localStorage
  • 但缺少"读取"环节 :页面刷新后,若不从 localStorage 恢复数据,todos 会重置为空数组,导致数据丢失。

完整持久化 = 初始化读取 + 变化时写入

javascript 复制代码
js
编辑
// 1. 初始化读取(关键!)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

// 2. 变化时写入(你已有的代码)
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

💡 本质 :这是一个"内存 ↔ 本地存储"的双向同步闭环。只有两者都存在,才能实现"刷新不丢数据"

⚠️ 注意事项

  • JSON 序列化限制todos 中只能包含可序列化的数据(字符串、数字、布尔值、普通对象/数组),不能包含函数、Symbol 等。
  • 存储容量localStorage 通常限制为 5MB,适合轻量级数据。
  • 性能优化 :避免因状态引用变化导致 useEffect 频繁触发(可通过 useMemo 或深比较优化)。

✅ 状态管理的核心原则:子组件不能直接修改数据

你的理解完全正确:子组件不可以直接修改父组件的状态,只能"提交修改请求" 。这是 React 单向数据流的基石。

为什么这样设计?

  1. 状态不可变性(Immutability)
    React 要求状态更新必须通过 setState 返回新对象,而非直接修改原对象。若子组件直接操作 props.todos.push(...),既违反此原则,也无法触发重渲染。
  2. 状态变更可追溯
    所有修改逻辑集中在父组件(如 addTodo, deleteTodo),便于调试、测试和维护。
  3. 解耦与复用
    子组件只需关心"何时触发",无需关心"如何修改",降低耦合度。

工作流程比喻

  • 父组件 = 仓库管理员
    持有 todos(货物清单),掌握所有操作权限(增删改查),并负责同步到 localStorage(纸质台账)。
  • 子组件 = 前台接待员
    无权直接操作仓库,只负责接收用户指令(点击按钮),并通过预设的"热线电话"(回调函数如 onAdd)将请求转达给管理员。
  • 流程
    用户操作 → 子组件调用回调 → 父组件更新状态 → 触发重渲染 + 同步本地存储 → 子组件接收新 props 并更新视图。

✅ 这种设计让整个应用状态清晰、逻辑集中、易于扩展


📝 子组件 1:TodoInput ------ 输入新任务

javascript 复制代码
jsx
编辑
import { useState } from 'react'

const TodoInput = ({ onAdd }) => {
  const [inputValue, setInputValue] = useState('')

  const handleSubmit = (e) => {
    e.preventDefault() // 阻止表单默认提交(页面刷新)
    onAdd(inputValue)
    setInputValue('') // 清空输入框
  }

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  )
}

export default TodoInput

🔍 深入理解两个核心机制

1. 为什么需要 e.preventDefault()

  • 根本原因<form> 提交会触发浏览器默认行为------刷新页面
  • 若不阻止,刚添加的 todo 会因页面刷新而丢失。
  • 关键原则e.preventDefault() 的使用取决于事件是否有默认行为,与"是否是添加操作"无关。

不需要 preventDefault 的添加场景

ini 复制代码
jsx
编辑
// 按钮点击(无默认行为)
<button onClick={() => { onAdd(text); }}>添加</button>

// 输入框回车监听(非表单提交)
<input onKeyDown={(e) => {
  if (e.key === 'Enter') onAdd(inputValue);
}} />

2. 为什么 setInputValue('') 能清空输入框?

因为这是 受控组件(Controlled Component)

  • value={inputValue}:输入框显示由 React 状态驱动。
  • onChange:用户输入时同步更新状态。
  • 执行 setInputValue('') → 状态变空 → 组件重渲染 → 输入框显示为空。

❌ 非受控组件(用 ref)无法通过此方式清空,需直接操作 DOM。


📋 子组件 2:TodoList ------ 渲染任务项

ini 复制代码
jsx
编辑
const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li><p className="empty">No todos, yet!</p></li>
      ) : (
        todos.map(todo => (
          <li 
            key={todo.id} 
            className={todo.completed ? 'completed' : ''}
          >
            <label>
              <input 
                type="checkbox" 
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>×</button>
          </li>
        ))
      )}
    </ul>
  )
}

export default TodoList

🔍 删除逻辑深度解析

1. filter 如何实现"删除"?

  • filter 返回新数组,保留 todo.id !== id 的项。
  • 目标项因 id 相等被过滤掉 → 间接实现删除。
  • 符合 React 不可变更新原则。

2. 为什么依赖 id 的唯一性?

  • id: Date.now() 仅在创建时执行一次,作为该 todo 的永久标识。
  • 删除时传递的是原始 id,不是当前时间戳。
  • 因此 todo.id !== id 能精准匹配唯一目标。

⚠️ 风险 :若多个 todo 共享相同 id,会导致误删。生产环境建议用 uuid

3. "唯一 id" 与 "id 不同" 是否矛盾?

不矛盾!

  • 唯一 id:确保每个 todo 有唯一身份(目标项只有一个)。
  • id ≠ 目标 id :是 filter 的筛选条件(保留非目标项)。

🧩 类比:从 [小明(id=1), 小红(id=2)] 中删除小红 → 筛选"id ≠ 2" → 结果:[小明]


📊 子组件 3:TodoStats ------ 显示统计 & 清除操作

javascript 复制代码
jsx
编辑
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  )
}

export default TodoStats

✅ 条件渲染提升用户体验。


🔁 组件通信机制总结

通信方向 实现方式
父 → 子 props 传递数据
子 → 父 props 传递回调函数
兄弟组件 通过父组件状态中转

核心:数据自上而下,事件自下而上


🧪 注意事项与最佳实践

  1. 状态不可变性:始终返回新数组/对象。
  2. ID 生成可靠性 :高频场景用 uuid 替代 Date.now()
  3. 表单防空提交disabled={!inputValue.trim()}
  4. 性能优化React.memo + useCallback(按需)

🔍 拓展思考:状态管理方案对比

方案 适用场景 优缺点
状态提升 小型应用 ✅ 简单;❌ 状态集中
Context 跨多层组件 ✅ 避免 props drilling;❌ 更新粒度粗
Zustand/Redux 大型应用 ✅ 强大;❌ 成本高

✅ Todo 应用:状态提升 + localStorage 最佳。


✅ 总结要点

  • 本地存储 = 初始化读取 + 变化时写入,缺一不可。
  • e.preventDefault() 仅用于阻止浏览器默认行为(如表单刷新)。
  • 受控组件setInputValue('') 清空输入框的根本原因。
  • filter 删除 依赖 唯一且不变的 ID,本质是"保留非目标项"。
  • 子组件不能直接修改状态,只能通过回调"提交请求",父组件统一处理。
  • 父组件持有状态 是实现状态共享与持久化的简洁载体,但核心是完整的持久化闭环
相关推荐
3秒一个大2 小时前
React 父子组件数据传递:机制与意义解析
react.js
重铸码农荣光2 小时前
🎯 从零搭建一个 React Todo 应用:父子通信、状态管理与本地持久化全解析!
前端·react.js·架构
烟袅2 小时前
从零构建一个待办事项应用:一次关于组件化与状态管理的深度思考
前端·javascript·react.js
南山安4 小时前
React学习:通过TodoList,完整理解组件通信
javascript·react.js·前端框架
浮游本尊4 小时前
React 18.x 学习计划 - 第十天:React综合实践与项目构建
前端·学习·react.js
亚洲小炫风5 小时前
react 资源清单
前端·javascript·react.js
AAA阿giao6 小时前
React Hooks 详解:从 useState 到 useEffect,彻底掌握函数组件的“灵魂”
前端·javascript·react.js
打小就很皮...8 小时前
React 富文本图片上传 OSS 并防止 Base64 图片粘贴
前端·react.js·base64·oss
白兰地空瓶8 小时前
从 Todo 项目看 React 组件通信:核心逻辑与优化技巧
react.js