从零构建 React Todo 应用:组件化设计与数据流深度解析

从零构建 React Todo 应用:组件化设计与数据流深度解析

一份代码背后的 React 核心思想与工程实践笔记


引言

如果你正在学习 React,或者已经写过一些 Demo,但始终觉得"组件通信""状态管理"只是会抄、会改,而没有真正理解为什么这样设计,那么这篇文章很适合你。我会从一个极简的 Todo 应用出发,结合我自己的学习笔记和实际代码,把 React 中 组件化响应式数据驱动HooksJSX组件通信组件树规划 等知识点串起来,并且深入到源码级别的思考,让你不仅知道"怎么写",更知道"为什么这么写"。

本文的所有代码都是可直接运行的,它们来自我的一个练习项目,注释完整,风格清晰。我们将逐文件、逐行解析,并且把 README 中的笔记一一展开。


一、React 核心知识在 Todo 应用中的映射

React 的官方口号是 "用于构建用户界面的 JavaScript 库",但真正让它与众不同的,是它对 组件化响应式数据驱动 的彻底贯彻。我们的 Todo 应用正是这三个理念的完美缩影。

1.1 组件化

组件化就是把界面拆分成独立、可复用的小块。在 Todo 中,我们很自然地划分出四个组件:

  • App:根组件,管理全局状态,承载布局。
  • TodoInput:负责输入和添加任务。
  • TodoList:负责展示所有任务列表。
  • TodoStats:负责统计和清除已完成任务。

每个组件只关心自己的渲染逻辑和交互,彼此通过明确的接口(props)协作。这正是组件化的核心价值:高内聚,低耦合

1.2 响应式与数据驱动

React 的 响应式 不是指 CSS 媒体查询,而是指 UI 自动随数据变化而变化。在 React 中,我们只关心数据(state),不关心 DOM 操作。当数据变化时,React 会重新渲染组件,并高效地更新真实 DOM。

在 Todo 中,todos 数组是唯一的数据源。任何对任务的增、删、改、清空,都通过 setTodos 修改这个数组,然后界面自动刷新。这就是 数据驱动 ------ 数据决定视图,视图是数据的映射(UI = f(state))。

1.3 JSX 与 Fragment

JSX 是 JavaScript 的语法扩展,它让我们在 JavaScript 中写类似 HTML 的标记。在 App.jsx 中,我们返回一个 <div> 包裹所有子组件,但实际业务中,如果不需要额外的 DOM 节点,可以使用 <Fragment>(或空标签 <>)来避免引入多余的包裹元素。虽然本例没有显式使用 Fragment,但 TodoList 返回的 <ul> 天然就是列表容器,也体现了碎片化的思想。

1.4 Hooks:useState 与 useEffect

useState ------ 让函数组件拥有状态

useState 是 React 最基础的 Hook,它返回一个数组 [state, setState]。在 App 中,我们使用:

javascript 复制代码
const [todos, setTodos] = useState([
  { id: 1, text: '吃饭', completed: false },
  { id: 2, text: '睡觉', completed: false },
  { id: 3, text: '打豆豆', completed: true }
]);

初始状态是一个包含三个对象的数组。setTodos 是更新函数,每次调用都会触发组件重新渲染,并用新状态替换旧状态。

TodoInput 中,我们也使用 useState 管理输入框的文本:

javascript 复制代码
const [inputValue, setInputValue] = useState("");

这个状态只属于 TodoInput,不需要提升到父组件,因为它只影响输入框自身的显示。

useEffect ------ 处理副作用(未使用但值得提及)

虽然本例没有用到 useEffect,但它是处理异步请求、订阅、DOM 操作等副作用的标准方式。在真实项目中,你可能会在 useEffect 中加载初始数据或持久化 todos 到 localStorage。这里提前点出,是为了强调 Hooks 的完整性。


二、组件通信机制:单向数据流与"向上汇报"

React 采用 单向数据流,数据从父组件流向子组件(通过 props),子组件不能直接修改父组件的数据。这是 React 设计哲学的核心,保证了数据变化的可预测性。

2.1 父子组件通信:Props 传递

App 中,我们通过 props 将数据和回调函数传递给子组件:

jsx 复制代码
<TodoInput onAdd={addTodo}/>
<TodoList 
  todos={todos} 
  onToggle={toggleTodo}
  onDelete={deleteTodo}
/>
<TodoStats 
  total={todos.length}
  active={activeCount}
  completed={completedCount}
  onClearCompleted={clearCompleted}
/>
  • todos 是数据,子组件只读。
  • onAddonToggleonDeleteonClearCompleted 是回调函数,子组件在合适时机调用它们,通知父组件修改状态

2.2 为什么子组件不能直接修改父状态?

因为如果子组件可以直接修改父状态,那么数据流向就变得混乱:任何组件都可能改变任何数据,调试将极其困难。React 强制通过事件(回调)将修改请求向上传递,父组件统一处理,然后新的数据再流下来。这种 "状态提升" 模式让数据源头单一,逻辑集中,易于追踪。

2.3 组件树与通信层级

当组件嵌套较深时,props 传递会变得繁琐("props drilling")。此时可以用 Context 或状态管理库(如 Redux、Zustand)。但我们的 Todo 只有三层(App -> TodoList -> TodoItem),直接传递完全足够。理解这种简单场景下的通信,是掌握更复杂方案的基础。


三、开发流程与组件树规划(Vibe Coding 的基石)

在开始编码之前,先画一张组件树,明确层级和数据归属,能极大提高开发效率(这也是 README 中提到的"有利于 vibe coding")。

3.1 组件树结构

scss 复制代码
App (根)
├── TodoInput
│   └── (内部 state: inputValue)
├── TodoList
│   └── 遍历 todos,渲染每个任务 (li)
│       └── (每个任务有 checkbox 和 text)
└── TodoStats
    └── (显示计数,清除按钮)
  • 数据状态todos 数组在 App 中管理,因为它是多个子组件(列表和统计)共享的数据。
  • 子组件自身状态inputValue 只属于输入框,所以留在 TodoInput 内部,不必提升。

3.2 目录结构(规划)

按照组件树,项目文件结构如下:

css 复制代码
src/
├── App.jsx
├── App.css
├── index.css
├── components/
│   ├── TodoInput.jsx
│   ├── TodoList.jsx
│   └── TodoStats.jsx

每个组件一个文件,清晰易维护。这种划分也是大型项目的基础。


四、实战代码逐行解析

接下来我们深入到每个文件,结合注释和实际代码,剖析每一行的设计意图。

4.1 App.jsx ------ 根组件,状态管理中心

jsx 复制代码
import { useState } from 'react';
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoStats from "./components/TodoStats";
import './App.css';
  • 导入 useState 和三个子组件,以及样式文件。
jsx 复制代码
const App = () => {
  const [todos, setTodos] = useState([
    { id: 1, text: '吃饭', completed: false },
    { id: 2, text: '睡觉', completed: false },
    { id: 3, text: '打豆豆', completed: true }
  ]) 
  • 使用 useState 初始化待办列表。每个任务包含 id(唯一标识)、text(内容)和 completed(是否完成)。
jsx 复制代码
  // 添加todo的方法 父组件管理 
  const addTodo = (text) => {
    if (text.trim() === '') return 
    // 全新的状态
    setTodos([
      { id: +Date.now(), text, completed: false },
      ...todos
    ])
  }
  • addTodo 接收文本,先判断是否为空字符串。然后调用 setTodos,传入一个新数组,新任务放在最前面(...todos 展开旧数组)。这里利用了 Date.now() 生成唯一 id(时间戳转为数字)。注意,状态更新是不可变的 ,我们不能直接 push,必须创建新数组。
jsx 复制代码
  const toggleTodo = (id) => {
    // 全新的状态
    setTodos(todos.map(todo => 
      todo.id === id 
        ? { ...todo, completed: !todo.completed}
        : todo
    ))
  }
  • 切换完成状态。用 map 遍历,找到匹配 id 的任务,用展开运算符创建新对象并反转 completed,其余不变。返回新数组。
jsx 复制代码
  const deleteTodo = (id) => {
    setTodos(
      todos.filter(todo => todo.id !== id)
    )
  }
  • 删除任务:用 filter 过滤掉匹配 id 的任务。
jsx 复制代码
  const clearCompleted = () => {
    setTodos( todos.filter(todos => !todos.completed));
  }
  • 清除所有已完成任务:同样用 filter,保留 completedfalse 的项。
jsx 复制代码
 const activeCount = todos.filter(t => !t.completed).length;
 const completedCount = todos.length - activeCount;
  • 计算活跃和已完成数量,用于统计展示。这里每次渲染都会重新计算,性能足够(数据量小)。
jsx 复制代码
  return (
    <div>
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo}/>
      <TodoList 
        todos={todos} 
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
      <TodoStats 
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  )
}
  • 渲染 JSX:标题,输入组件,列表组件,统计组件。所有回调都绑定到父组件的处理函数。

深层思考 :为什么在 addTodo 中用 [...todos, newTodo] 而不是直接修改 todos?因为 React 依赖不可变数据来检测变化。如果直接修改原数组,setTodos 可能不会触发重新渲染(因为引用未变)。而每次创建新数组,引用变了,React 就知道需要更新。

4.2 TodoInput.jsx ------ 表单交互组件

jsx 复制代码
import { useState } from 'react';

const TodoInput = ({ onAdd }) => {
  console.log(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?"
        autoFocus
      />
      <button type="submit">Add</button>
    </form>
  )
}

export default TodoInput;
  • 受控组件 :输入框的值与 inputValue 状态绑定,onChange 更新状态,这是典型的受控组件模式。
  • 提交处理handleSubmit 阻止默认行为,调用 onAdd 上报数据,然后清空输入。
  • 注释点明:"共享状态只有父组件持有",再次强调单一数据源。

思考 :为什么不把 inputValue 放在父组件?因为它只影响输入框本身,不需要与其他组件共享。如果放在父组件,父组件会因输入每次变化而重新渲染所有子组件,造成性能浪费。所以状态要"就近管理"。

4.3 TodoList.jsx ------ 列表展示组件

jsx 复制代码
const TodoList = ({
  todos,
  onToggle,
  onDelete
}) => {
   return(
    <ul className="todo-list">
        {
            todos.length === 0 ? (
                <li className="no-todos">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>
                </li>
            ))
        }
    </ul>
   )
}

export default TodoList;
  • 接收 todosonToggleonDelete(虽然 onDelete 在本文件中未使用,但保留了接口,可用于扩展删除按钮)。
  • 条件渲染 :如果 todos 为空,显示"No todos yet!"。
  • 列表渲染 :使用 map 生成 li,必须指定 key 属性(这里用 todo.id),帮助 React 识别哪些项变化,优化更新性能。
  • 完成状态样式 :根据 completed 添加 'completed' 类,配合 CSS 可实现划掉效果。
  • 切换事件checkboxonChange 触发 onToggle(todo.id),通知父组件切换。

深层key 的作用是什么?当列表重新渲染时,React 通过 key 判断哪些元素是新增、删除或移动的,从而最小化 DOM 操作。如果使用数组索引作为 key,当列表顺序变化时可能导致性能问题和状态错乱,所以应该用稳定的唯一标识(如 id)。

4.4 TodoStats.jsx ------ 统计与清除组件

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;
  • 接收三个计数和清除回调。
  • 条件渲染按钮 :只有 completed > 0 时才显示"Clear Completed"按钮,避免用户点击无意义。
  • 点击按钮调用 onClearCompleted,触发父组件清空已完成任务。

五、样式文件 index.css 的思考

虽然样式不是本文重点,但提一下其中设计的亮点:

  • 使用 CSS 变量(--text, --bg 等)支持亮色/暗色主题,通过 @media (prefers-color-scheme: dark) 自动适配系统主题。
  • #root 设置了最大宽度和居中对齐,并添加了边框,形成卡片效果。
  • 全局字体、行高、抗锯齿优化,提升了阅读体验。
  • 这些样式体现了工程化的细节,与组件代码一起构成了完整的应用。

六、深入底层:setState 的工作原理与性能优化

很多人会用 setState,但未必知道它背后的机制。下面我们从两个角度深入。

6.1 异步更新与批量处理

setTodos 并不是同步修改状态,而是将更新放入队列,React 会在事件循环结束时批量处理这些更新,然后统一重新渲染。这带来的好处是:在同一个事件处理函数中多次调用 setState,只会触发一次重渲染,提升了性能。

例如,如果在 addTodo 中连续调用两次 setTodos,React 会合并它们,只执行最后一次。但要注意,如果依赖前一个状态,应该使用函数式更新:setTodos(prev => [...prev, newTodo]),避免因闭包问题拿到旧值。本例中我们使用了展开运算符,因为 todos 是当前闭包中的值,在同步函数中是安全的,但如果涉及异步,建议使用函数式更新。

6.2 虚拟 DOM 与 diff 算法

React 在内存中维护一棵虚拟 DOM 树,每次状态变化后,重新生成新的虚拟 DOM,然后与旧树进行 diff 比较,找出最小变化,最后将差异应用到真实 DOM。这个过程称为协调(Reconciliation)。

在我们的 Todo 中,当切换一个任务的完成状态时,React 只会更新那个 checkbox 和文本的样式,而不会重新渲染整个列表。这正是虚拟 DOM 和 key 机制带来的高效。

6.3 为什么状态必须是不可变的?

因为 React 使用浅比较(shallow comparison)来检测状态变化。如果直接修改 todos 数组的内容,然后调用 setTodos(todos)(引用不变),React 会认为状态没变,从而跳过渲染。只有创建新数组或新对象,引用变化,React 才能感知到变化。这也是为什么我们总是用 mapfilter展开运算符 等返回新值。


七、组件设计原则与扩展思考

7.1 单一职责

每个组件只做一件事:TodoInput 只管输入,TodoList 只管展示,TodoStats 只管统计。职责清晰,便于测试和复用。

7.2 状态最小化

只把必要的状态提升到共同父组件,避免过度提升。本例中,inputValue 保留在子组件,减少了父组件的渲染次数。

7.3 可扩展性

如果要添加"编辑任务"功能,可以增加一个 TodoItem 组件,将 TodoList 中的每个 li 抽取出来,内部管理编辑状态。这样既保持了 TodoList 的简洁,又符合组件化原则。


八、总结

通过这个看似简单的 Todo 应用,我们实践了 React 最核心的概念:

  • 组件化 将 UI 拆分为独立、可复用的部分。
  • 数据驱动 使 UI 与状态同步,让开发者专注于数据管理。
  • 单向数据流 保证了数据变化的可预测性,props 向下,事件向上。
  • Hooks 让函数组件拥有状态和副作用能力,代码更简洁。

同时,我们深入讨论了 setState 的异步性、虚拟 DOM 的优化、不可变数据的重要性,以及组件间通信的模式。这些知识不只在 Todo 中有效,在任何一个 React 项目中都是基石。

希望这篇文章能帮助你从"会写"升级到"理解",从而更自信地构建 React 应用。如果你有任何疑问或想法,欢迎在评论区交流。Happy coding!


相关推荐
IT_陈寒2 小时前
Python线程池把我坑惨了,这些盲区你不踩?
前端·人工智能·后端
mONESY2 小时前
从 DeepSeek WebGPU 输入框,吃透 React+TS 四大核心开发实践
javascript·后端
程序员爱钓鱼2 小时前
第一个 Go 程序:Hello World
前端·后端·go
阳光是sunny2 小时前
LangGraph 核心概念详解:从编译到可视化
前端·人工智能·后端
寒水馨3 小时前
macOS下载、安装electron-v43.2.0(附安装包electron-v43.2.0-darwin-arm64.zip)
javascript·macos·electron·node.js·跨平台·桌面应用开发·开源框架
不好听6133 小时前
React 父子组件通信:从 Todo 应用理解单向数据流
前端·react.js
光影少年3 小时前
RN 路由栈管理、页面销毁、返回拦截
javascript·react native·react.js
_lucas3 小时前
做了一个glsl在线调试工具
前端·javascript·three.js
To_OC11 小时前
LC 51 N 皇后:我以为难的是回溯,结果栽在了对角线下标
javascript·算法·leetcode