React 实战:构建 Todos 任务清单,掌握状态提升与组件通信

3 阅读4分钟

在 React 开发中,Todos 任务清单是一个经典且实用的入门项目。它涵盖了列表渲染、条件展示、表单处理、状态统计、批量操作以及数据持久化等常见场景。更重要的是,通过这个项目,我们可以深刻理解 React 的核心思想:单向数据流状态提升

不同于 Vue 的双向绑定,React 强调数据从父组件向下流动(props),事件从子组件向上冒泡(回调函数)。这种设计让数据流向可预测,调试更简单,适合大规模应用。

本文将基于 React 18 + Vite + Stylus,完全实现一个功能完整的 Todos 应用。主要功能包括:

  • 添加任务
  • 切换完成状态
  • 删除任务
  • 清除已完成任务
  • 统计活跃/已完成数量
  • localStorage 自动持久化

重点讲解:

  • 状态提升(lifting state up)
  • 父子组件通信(props + 回调)
  • 兄弟组件间接通信
  • 单向数据流的优势
  • useState 与 useEffect 的实用技巧

项目完整代码结构

先给出整体结构和关键代码

App.jsx(父组件,主状态管理)

jsx

import { useState, useEffect } from 'react';
import './styles/app.styl';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
import TodoStats from './components/TodoStats';

function App() {
  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: text.trim(),
      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(todo => !todo.completed).length;
  const completedCount = todos.length - activeCount;
  const totalCount = todos.length;

  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

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

export default App;

TodoInput.jsx(输入组件)

jsx

import { useState } from 'react';

export default function TodoInput({ onAdd }) {
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      onAdd(inputValue.trim());
      setInputValue('');
    }
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="输入任务,按回车或点击添加"
      />
      <button type="submit">Add</button>
    </form>
  );
}

TodoList.jsx(列表组件)

jsx

export default function TodoList({ todos, onDelete, onToggle }) {
  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' : ''}
          >
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => onToggle(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => onDelete(todo.id)}>X</button>
          </li>
        ))
      )}
    </ul>
  );
}

TodoStats.jsx(统计组件)

jsx

export default function TodoStats({ total, active, completed, onClearCompleted }) {
  return (
    <div className="todo-stats">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted}>Clear Completed</button>
      )}
    </div>
  );
}

React 单向数据流的精髓

React 的核心哲学是:数据从上向下流动,事件从下向上冒泡

在传统原生开发或 jQuery 时代,我们习惯直接操作 DOM:

JavaScript

// 传统方式
document.querySelector('input').addEventListener('submit', () => {
  const li = document.createElement('li');
  // ...大量 DOM 操作
});

这种方式在复杂应用中极易失控。React 通过声明式渲染解决了这个问题:我们只描述“数据是什么样子”,React 会自动计算差异并更新 DOM。

在本项目中,所有状态都集中在父组件 App 中:

  • todos 数组是唯一数据源
  • 子组件只接收 props(数据 + 回调函数)
  • 子组件不能直接修改 todos,只能调用父组件传来的回调(如 onAdd、onDelete)

这正是 状态提升(lifting state up) 的典型应用。当多个子组件需要共享或修改同一份数据时,最简单的可靠方式就是把状态提升到最近的共同父组件。

父子组件通信详解

父 → 子:通过 props 传递数据和方法

jsx

<TodoList todos={todos} onDelete={deleteTodo} onToggle={toggleTodo} />
  • todos 是只读数据
  • onDelete 和 onToggle 是父组件定义的修改方法

子组件接收后直接使用,不能修改 todos(因为是不可变数据模式)。

子 → 父:通过回调函数上报事件

以 TodoInput 为例:

jsx

const handleSubmit = (e) => {
  e.preventDefault();
  onAdd(inputValue);  // 调用父组件传入的回调
  setInputValue('');
};

子组件只负责收集用户输入,然后“上报”给父组件,由父组件决定如何更新状态。这种设计确保了数据的唯一可信来源。

兄弟组件通信:间接通过父组件

TodoInput、TodoList、TodoStats 三个组件互为兄弟,它们都需要访问或影响 todos 数据。

直接让兄弟组件互相引用是反模式,会造成耦合。正确的做法是:

  1. 父组件持有共享状态
  2. 通过 props 将数据/方法下发给需要的子组件
  3. 子组件通过回调修改状态,父组件更新后重新渲染所有子组件

这样,TodoStats 中的统计数字会自动随着 TodoInput 添加或 TodoList 删除而更新,无需额外通信代码。

单向绑定 vs 双向绑定

React 没有像 Vue 的 v-model 那样的内置双向绑定,而是采用“受控组件”模式:

jsx

<input
  value={inputValue}
  onChange={(e) => setInputValue(e.target.value)}
/>

这种方式看似繁琐,但性能更好(避免不必要的双向同步),并且状态完全可控。所有 UI 状态都有明确来源,便于调试和测试。

数据持久化:useEffect + localStorage

jsx

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

每次 todos 变化时自动保存。初始化时从 localStorage 读取,避免刷新丢失数据。这是一个典型的“副作用”场景,useEffect 是最佳选择。

总结

通过这个 Todos 项目,我们完整实践了 React 中最核心的几个概念:

  • 状态提升:共享状态上移到共同父组件
  • 单向数据流:props down, events up
  • 受控组件:明确的状态来源
  • 副作用管理:useEffect 处理持久化

这些原则不仅是 Todos 项目的基础,也是构建任何复杂 React 应用的关键。掌握它们,你就能写出可维护、可预测的高质量代码。