用 React 从零实现 Todo 应用:彻底搞懂状态管理与组件通信

63 阅读7分钟

用 React 构建一个 Todo 应用:从状态管理到组件通信的清晰实践

很多人在学习 React 时,常被 stateprops组件通信useEffect等概念搞得一头雾水。 其实,这些抽象概念完全可以通过一个具体的小项目来理解——比如一个 TodoList(待办事项)应用

本文将带你从零开始,用最基础的 React 特性(仅 useStateuseEffect),构建一个功能完整、结构清晰的 Todo 应用,并在过程中自然掌握 React 的核心思想。


先站在高处看全局:组件是怎么分工的?

整个 Todo 应用由 4 个组件组成:

App(父组件)  
├── TodoList 列表展示  
├── TodoInput 输入新增  
└── TodoStats 统计信息

可以把它想象成一家小公司:

  • App:老板,掌握所有核心数据
  • TodoInput:前台,负责接收新任务
  • TodoList:执行部门,展示和操作任务
  • TodoStats:财务部,负责统计数据

一个核心原则
数据只放在一个地方,由最上层的父组件统一管理。

🧱 整体结构:父组件是“中央控制器”

在这个 Todo 应用中,App.jsx 是整个应用的“大脑” —— 它负责:

  • 持有所有待办事项(todos 数组)
  • 提供修改这些事项的方法(如添加、删除、标记完成等)
  • 将数据和方法通过 props 分发给子组件

我们先从最简单的状态初始化开始:

// App.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() {
  const [todos, setTodos] = useState([]);
    
  return (
    <div className="todo-app">
      <h1>My Todos</h1>
    </div>
  );
}

此时,todos 是一个空数组,没有任何任务。接下来,我们要让这个列表“活”起来。

生活比喻:就像一张空白的计划表——内容还没填,但格式已经准备好,只等你动手添加。


📥 输入新任务:TodoInput 组件

用户需要一个地方输入新任务。我们创建 TodoInput 组件,它不持有任何全局状态,只管理自己的输入框内容:

// TodoInput.jsx
import { useState } from 'react'
const TodoInput = (props) => {
    const { onAdd } = props
    // 参数 props 是父组件传递给它的所有属性的集合(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)}
            />
            <button type="submit">Add</button>
        </form>
    )
}
export default TodoInput
  • 使用 useState 创建一个 局部状态(local state)

    • inputValue:当前输入框中的文本内容
    • setInputValue:更新该内容的函数
  • handleSubmit 是表单提交时触发的事件处理函数。

  • e.preventDefault() 阻止表单默认的页面刷新行为。

  • 调用父组件传入的 onAdd 函数,并将当前输入框的值 inputValue 作为参数传出去。

  • 然后清空输入框(通过 setInputValue(''))。

  • onChange={...}:每当用户输入内容时,更新 inputValue 状态。

关键点在于:onAdd 是父组件传进来的函数。这意味着 TodoInput 自己不能决定“往哪里加任务”,它只是“上报”用户想加什么。

App 中使用它:

// App.jsx
 const addTodo = (text) => {
    setTodos([...todos, {
      id: Date.now(), // 时间戳
      text,// 
      completed: false,
    }])

  }


 return (
    <div className="todo-app">
      <h1>My Todos</h1>
      <TodoInput onAdd={addTodo} />
    </div>
  );
}

这就是 “子 → 父”通信:子组件通过调用 props 中的函数,把用户意图传递给父组件。


📋 展示任务列表:TodoList 组件

有了任务,就要展示出来。TodoList 接收 todos 数组,并渲染每一项:

// TodoList.jsx
const TodoList = (props) => {
    const {  todos, onDelete,  onToggle } = props;
    return (
        <ul className="todo-list">
        {
            todos.length === 0 ?(
                <li className="empty">No todos yet!</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)}>X</button>
                    </li>
                ))
            )
        }
        </ul>
    )
}
export default TodoList

🔍 条件渲染:空状态 vs 有数据

{
    todos.length === 0 ? (
        <li className="empty">No todos yet!</li>
    ) : (
        todos.map(todo => ( /* 渲染每个任务 */ ))
    )
}
  • 使用 三元运算符 实现条件渲染:

    • 如果 todos 为空数组(length === 0),显示友好提示:“No todos yet!”
    • 否则,遍历 todos 并渲染每一项
  • 这是 React 中处理“空状态”的常见模式,避免用户面对空白屏幕感到困惑

🔄 列表渲染:使用 .map() 生成元素

todos.map(todo => (
    <li 
        key={todo.id} 
        className={todo.completed ? 'completed' : ''}
    >
        {/* 内容 */}
    </li>
))

关键点 1:key={todo.id}

  • React 要求列表中的每个元素必须有 唯一且稳定的 key
  • todo.id 通常由父组件在添加任务时生成(如 Date.now() 或 UUID),确保唯一性。
  • 作用:帮助 React 高效地 diff 和更新 DOM,避免不必要的重渲染。

⚠️ 错误做法:用数组索引 index 作 key(在列表可能增删时会导致 UI 错乱)。

关键点 2:动态类名

className={todo.completed ? 'completed' : ''}
  • 如果任务已完成(completed: true),给 <li> 添加 completed 类。

注意:

  • 每个任务都有复选框和删除按钮
  • 点击复选框 → 调用 onToggle(id)
  • 点 X → 调用 onDelete(id)

父组件提供这两个方法:

🔄 1. 切换任务完成状态:toggleTodo

const toggleTodo = (id) => {
  setTodos(todos.map(todo =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  ));
};
🔍 功能目标
  • 找到 id 匹配的任务项
  • 将其 completed 字段取反(truefalse
  • 更新整个 todos 状态

🗑️ 2. 删除任务:deleteTodo

const deleteTodo = (id) => {
  setTodos(todos.filter(todo => todo.id !== id));
};
🔍 功能目标
  • todos 中移除 id 匹配的任务项
  • 返回一个不包含该任务的新数组

这里再次强调:我们没有直接修改原数组,而是返回一个新数组。这是 React 状态更新的黄金法则——不可变性(Immutability)

App 中使用:

<TodoList
  todos={todos}
  onToggle={toggleTodo}
  onDelete={deleteTodo}
/>

这就是 “父 → 子”通信:父组件通过 props 把数据和行为传递下去。


📊 统计信息:TodoStats 组件

为了让用户清楚当前进度,我们加一个统计栏:

// TodoStats.jsx
const TodoStats = (props) => {
  const { total,active, completed, onClearCompleted } = props
  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

🔹 条件渲染清除按钮

{
  completed > 0 && (
    <button ...>Clear Completed</button>
  )
}
  • 使用 逻辑与(&& 实现条件渲染:

    • 如果 completed > 0true → 渲染按钮
    • 否则 → 不渲染(React 会忽略 falsenull
  • 用户体验优化:只有存在已完成任务时,才显示“清除”按钮,避免无效操作。

这些数字都来自 todos 的实时计算:

// App.jsx
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed))
}
const activeCount = todos.filter(todo => !todo.completed).length
const completedCount = todos.filter(todo => todo.completed).length

然后传给组件:

<TodoStats  
     total={todos.length} 
     active = {activeCount}
     completed = {completedCount}
     onClearCompleted={clearCompleted}
 />

🔗 组件通信全景图

现在,整个应用的数据流非常清晰:

  • 父组件 (App)

    • 持有 todos 状态
    • 定义所有修改状态的方法
  • 子组件 (TodoInput, TodoList, TodoStats)

    • 只接收 props
    • 不直接修改 todos
    • 通过调用 props 中的函数“请求”变更

这种模式叫 “状态提升” —— 把共享状态放到最近的共同父组件中,让数据流变得线性、可预测。

兄弟组件之间不直接通信!比如 TodoInput 添加任务后,TodoList 能自动更新,不是因为它们“对话”了,而是因为它们共用同一个 todos 状态,而这个状态由 App 管理。

展示结果:

未命名的设计 (7).gif


💾 最后一步:持久化到本地存储

现在应用功能完整了,但刷新页面后任务会消失。我们只需在最后加上一行代码,就能让它“记住”你的任务:

// App.jsx
import { useState, useEffect } from 'react';

function App() {
  const [todos, setTodos] = useState([]);

  // ✅ 新增:监听 todos 变化,自动保存
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  // ... 其他代码不变
}

同时,为了让首次加载时能读取之前保存的任务,我们稍作调整:

const [todos, setTodos] = useState(() => {
  // 初始化时尝试读取 localStorage
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

因为本地存储是副作用(side effect) ,不应该干扰核心状态逻辑。先把“内存中的 Todo 应用”做对,再考虑“持久化”,这是良好的开发习惯。

这时我们刷新页面就能看到上次保存的数据:

未命名的设计 (1).gif


✅ 总结:清晰、可控、可扩展

通过这个小项目,我们掌握了 React 开发的核心思想:

✅ 总结:Todo 应用教会我们的 React 核心思想

  • 状态集中:所有数据由 App 统一管理,子组件只读不改。
  • 单向数据流:父传数据/方法(props),子通过回调上报操作。
  • 不可变更新:用 mapfilter 等生成新数组,绝不直接修改原状态。
  • 组件各司其职:输入、列表、统计互不干扰,高内聚低耦合。
  • 持久化最后加:先做对逻辑,再用 localStorage 保存数据。

一个 Todo,串起 React 的最佳实践——简单,但完整。

你不需要复杂的工具,仅用 useStateuseEffect,就能构建一个结构清晰、易于维护的应用。而这,正是 React 的魅力所在。