手把手教你用 React 实现一个完整的 Todo 应用:深入理解组件通信与状态管理

48 阅读6分钟

今天我花了几个小时,从零开始搭建了一个 React + Vite + Stylu 的 Todo 应用。这个看似简单的待办事项列表,其实藏着 React 开发中最核心的两个概念:

  • 组件之间的通信机制
  • 状态(State)的统一管理和响应式更新

别看它功能简单 —— 添加任务、完成任务、删除任务、统计数量、清空已完成……但正是这些“基础操作”,构成了我们日常开发中绝大多数交互逻辑的骨架。

这篇文章,我会带你一步步拆解这个 Todo 应用,不讲花哨术语,只讲真正能落地的知识点。如果你是 React 初学者,或者对“父子组件怎么传数据”、“兄弟组件如何协作”还一头雾水,那这篇文一定适合你。


一、技术栈选择:为什么是 React + Vite?

先简单说下项目结构:

├── App.jsx
├── components/
│   ├── TodoInput.jsx
│   ├── TodoList.jsx
│   └── TodoStats.jsx
└── styles/
    └── app.jsx

使用的工具链如下:

  • React:构建用户界面的声明式 JavaScript 库。
  • Vite:新一代前端构建工具,启动快如闪电,热更新几乎无延迟。
  • Stylus:CSS 预处理器之一,语法简洁灵活,支持嵌套写法,比原生 CSS 更高效。

虽然现在很多人用 Tailwind 或 Sass,但我想告诉你:掌握一种预处理器的本质比追逐潮流更重要。Stylus 的嵌套和变量机制,已经足够让我们写出清晰可维护的样式代码。


二、父组件:App.jsx —— 数据的“中央仓库”

在 React 中,有一个非常重要的原则:

数据流向是单向的,状态提升到共同祖先。

什么意思?来看我们的 App 组件:

export default function App() {
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  });

  // 添加新任务
  const addTodo = (text) => {
    setTodos([...todos, {
      id: Date.now(),
      text,
      completed: false,
    }]);
  };

  // 删除任务
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 切换完成状态
  const onCompleted = (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.filter(todo => todo.completed).length;

  // 持久化存储
  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} 
        onCompleted={onCompleted} 
      />
      <TodoStats 
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
}

关键点解析

✅ 1. 状态集中管理(Single Source of Truth)

所有的 todos 数据都保存在 App 组件中,子组件不能直接修改它。这就像一个公司的“人事档案室”,只有 HR(父组件)可以改档案,员工(子组件)只能提交申请。

✅ 2. 自定义事件传递(Prop Drilling)

父组件把修改方法通过 props 传给子组件:

<TodoInput onAdd={addTodo} />

这里的 onAdd 就是一个“自定义事件”。当用户输入并回车时,TodoInput 调用这个函数,相当于向上级“汇报工作”。

这种模式叫做 “回调提升(Callback Lifting)” —— 子组件触发行为,父组件处理逻辑。

✅ 3. 使用 useEffect 实现持久化

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

只要 todos 变化,就自动保存到本地存储。下次打开页面还能恢复数据 —— 用户体验直接拉满!


三、子组件详解:各司其职,协同作战

1. TodoInput.jsx:负责“新增任务”

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

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

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
      />
      <button>Add</button>
    </form>
  );
}

核心知识点:

  • 受控组件(Controlled Component)value 由 React 状态控制,每次输入都会触发 onChange 更新状态。
  • 阻止默认行为:表单提交会刷新页面,必须调用 e.preventDefault()
  • 防空提交:检查是否为空字符串,避免无效数据污染状态。

💡 这里有个常见误区:有人喜欢用 ref 直接操作 DOM 获取值。但在 React 中,我们应该优先使用状态驱动视图,而不是反过来。


2. TodoList.jsx:展示任务列表

export default function TodoList({ todos, onDelete, onCompleted }) {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">暂无待办事项</li>
      ) : (
        todos.map(todo => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onCompleted(todo.id)}
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>X</button>
          </li>
        ))
      )}
    </ul>
  );
}

重点说明:

  • key 属性的重要性:React 需要 key 来识别每个元素的身份,确保高效的 diff 算法。这里用 Date.now() 虽然简单,但在高并发场景可能重复,生产环境建议使用 nanoiduuid
  • 条件渲染:没有任务时显示提示信息,提升用户体验。
  • 类名动态绑定:根据 completed 状态添加 .completed 类,方便样式控制。
// app.styl 示例
li.completed span
  text-decoration line-through
  color #aaa

3. TodoStats.jsx:统计面板

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

巧妙之处:

  • 按需渲染清除按钮:只有存在已完成任务时才显示“清空”按钮,避免多余操作入口。
  • 语义化展示:让用户一眼看清当前任务分布情况。

四、组件通信全解析:父子、子父、兄弟之间如何“对话”?

这是本文最核心的部分。很多新手搞不清“谁该管什么”,导致状态混乱、组件难以复用。

🔄 三种通信方式总结

类型方式示例
父 → 子Props 传递数据<TodoList todos={todos} />
子 → 父回调函数(自定义事件)<TodoInput onAdd={addTodo} />
兄弟 ↔ 兄弟通过共同父级中转TodoInput 修改状态 → TodoList 自动更新

🧠 举个生活化的例子:

想象你在公司写周报:

  • 你(子组件) 写完内容后点击【提交】
  • 触发的是 主管(父组件) 的审批流程
  • 主管处理完后,通知财务(另一个子组件)准备报销

你看,你并没有直接联系财务,而是通过主管协调。这就是典型的“兄弟组件通信靠父级”。


五、最佳实践建议:写出更健壮的 React 代码

  1. ✅ 状态尽可能提升到需要共享它的最近公共父组件

    不要把 todos 放在 TodoList 里!否则 TodoStats 就拿不到数据了。

  2. ✅ 方法命名要有意义

    • onAdd 表示“即将添加”
    • onDelete 表示“将要删除”
    • 前缀 on 是约定俗成的习惯,表示这是一个回调
  3. ✅ 避免不必要的 re-render

    当前实现中,每次 todos 改变,所有子组件都会重新渲染。未来可以用 React.memo 优化性能。

  4. ✅ 错误边界和边界情况处理

    • 输入为空时不提交
    • 列表为空时友好提示
    • ID 生成尽量唯一(可用 crypto.randomUUID() 替代 Date.now()
  5. ✅ 本地存储加异常处理(进阶)

    useEffect(() => {
      try {
        localStorage.setItem('todos', JSON.stringify(todos));
      } catch (err) {
        console.error('Failed to save todos:', err);
      }
    }, [todos]);
    

六、结语:小应用,大智慧

这个 Todo 应用虽然只有不到 200 行代码,但它涵盖了 React 开发中最基础也最重要的思想:

  • 单向数据流
  • 组件职责分离
  • 状态提升
  • 回调函数实现反向通信
  • 副作用管理(useEffect)
  • 本地持久化

你可以把它当作你的“React 入门样板工程”。每当你想练习一个新的概念(比如 Context、Redux、useReducer),都可以基于它进行改造。


❤️ 如果你觉得有收获…

不妨点赞 + 收藏 + 关注,让更多人看到这份“接地气”的 React 教程。

也欢迎留言讨论:你是怎么管理复杂状态的?有没有遇到过组件通信的坑?我们一起交流进步!


📌 记住一句话:伟大的应用,都是从一个小小的 Todo 开始的。