用React 完成Todos 的 组件应用:深入理解单向数据流与组件通信

前言

本文基于一个使用 React 19 + Vite 8 构建的 Todo 应用,深入探讨 React 中最核心的设计理念:单向数据流状态提升 以及子组件通过 props 共享统一数据状态的实践模式。这个项目虽然简洁,却完整地展示了 React 组件化开发的最佳实践。


一、技术栈概览

技术 版本 用途
React 19.2.7 UI 框架
Vite 8.1.1 构建工具
ESLint 10.6.0 代码规范

项目创建:

bash 复制代码
pnpm create vite/npm init vite

项目创建完成后添加相应子组件(src目录下新建component将组件分别加入3个组件(代码如下)),并将父组件(App.jsx)替换

二、核心架构:单一数据源与状态提升

2.1 组件树结构

整个应用被拆分为四个组件,呈树状结构:

scss 复制代码
App (数据源 + 所有操作方法)
├── TodoInput   (输入框 + 添加按钮)
├── TodoList    (任务列表展示)
└── TodoStats   (统计信息 + 清除按钮)

父组件

jsx 复制代码
import{useState} from "react"
import TodoInput from "./components/TodoInput"
import TodoList from "./components/TodoList"
import TodoStats from "./components/TodoStats"
import "./App.css"

const App =()=>{
  const[todos,setTodos]=useState([{
    id:1,
    text:'eat',
    completed:true
  },
  {
    id:2,
    text:'sleep',
    completed:true
  },
  {
    id:3,
    text:'study',
    completed:true
  }
])
// 添加todo的方法,父组件管理
const addTodo=(text)=>{
    if(text.trim()==='')return
    //传一个全新状态
    setTodos([
      {id: + Date.now(),text,completed:false},
      ...todos,
    ])
}

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

const deleteTodo = (id)=>{
  setTodos(
    todos.filter(todo=>todo.id!==id)
  )
}
const clearCompleted=()=>{
  setTodos(todos.filter(todo=>!todo.completed))
}
const activeCount=todos.filter(t=>!t.completed).length
const completedCount=todos.length-activeCount

  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>
  )
}
export default App

todoinput

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

TodoList

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

export default TodoList;

TodoStats

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

2.2 状态提升 ------ 数据的"唯一真相来源"

这是本文最核心的设计原则。我们来看 App.jsx 中的数据定义:

jsx 复制代码
const [todos, setTodos] = useState([
  { id: 1, text: 'eat', completed: true },
  { id: 2, text: 'sleep', completed: true },
  { id: 3, text: 'study', completed: true },
]);

关键决策todos 状态被定义在顶层 App 组件中,而非分散在各个子组件里。

为什么要这样做?原因有三:

  1. 单一数据源(Single Source of Truth) :所有组件的渲染数据都来源于同一个 todos 数组。不存在"这个组件维护一份数据,那个组件也维护一份"的混乱局面。

  2. 数据与界面统一 :当 todos 发生变化时,React 会自动重新渲染所有依赖该数据的子组件。界面永远是数据的忠实映射 ------ 数据变,界面就变;数据和界面统一,不能出问题

  3. 状态修改可控 :子组件不能直接修改 todos,只能通过父组件传递下来的回调函数"报告意图",由父组件统一处理。

2.3 子组件通过 Props 共享统一数据状态

来看看三个子组件如何各取所需:

TodoInput ------ 只拿它需要的

jsx 复制代码
const TodoInput = ({ onAdd }) => {
  const [inputValue, setInputValue] = useState("");
  // 当需要报告父组件的时候,执行
  const handleSubmit = (e) => {
    e.preventDefault();
    onAdd(inputValue);   // ← 只负责"上报",不碰数据
    setInputValue('');
  };
  // ...
};

TodoInput 不关心 todos 里有什么,它只做一件事:收集用户输入,然后通过 onAdd 回调报告给父组件 。它拥有自己的局部状态 inputValue,但这个状态只服务于输入框本身,不影响全局数据。

TodoList ------ 只读 + 事件上报

jsx 复制代码
const TodoList = ({ todos, onToggle, onDelete }) => {
  return (
    <ul className="todo-list">
      {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)}>Delete</button>  {/* ← 报告删除意图 */}
        </li>
      ))}
    </ul>
  );
};

TodoList 接收完整的 todos 数据来渲染列表,但它从不直接修改数据 。当用户点击复选框或删除按钮时,它只调用父组件传下来的 onToggleonDelete,把"想做什么"告诉父组件。

TodoStats ------ 派生数据的消费者

jsx 复制代码
<TodoStats
  total={todos.length}
  active={activeCount}
  completed={completedCount}
  onClearCompleted={clearCompleted}
/>

注意这里传的不是 todos 本身,而是派生数据 ------ totalactivecompletedTodoStats 只需要统计数据,不需要知道每一条 todo 的具体内容。这是一种很好的最小权限原则实践。


三、数据修改流程:严格的单向数据流

整个应用的数据修改遵循一条不可逆的路径:

复制代码
用户操作 → 子组件回调 → 父组件 setTodos → 状态更新 → React 自动重渲染 → 界面同步

具体来看三个操作:

添加 Todo

jsx 复制代码
const addTodo = (text) => {
  if (text.trim() === '') return;
  setTodos([
    { id: +Date.now(), text, completed: false },
    ...todos,
  ]);
};

不可变性(Immutability) :不是 todos.push(...) 然后 setTodos(todos),而是创建一个全新的数组。React 通过引用比较来判断状态是否变化 ------ 如果直接修改原数组,React 无法感知变化,界面就不会更新。

切换完成状态

jsx 复制代码
const toggleTodo = (id) => {
  setTodos(todos.map(todo =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  ));
};

这里用了 map 创建新数组,对于被修改的那一项用展开运算符 { ...todo, completed: !todo.completed } 创建新对象。其余项保持原引用(这没问题,因为它们没变)。

删除 Todo

jsx 复制代码
const deleteTodo = (id) => {
  setTodos(todos.filter(todo => todo.id !== id));
};

filter 返回新数组,干净利落。


四、为什么要这样设计?

回到 README 中强调的那句话:

子组件们共享统一(props 提出修改)的数据状态,数据和界面统一的,不能出问题。

这句话概括了 React 组件设计的精髓。让我们展开来讲:

4.1 可预测性

当所有状态修改都发生在 App 组件的几个方法中时,调试变得极其简单 ------ 你只需要在这几个方法里打日志,就能追踪到每一次状态变化。如果状态散落在各个子组件中,当出现 bug 时,你需要逐个排查,心智负担呈指数级增长。

4.2 数据一致性

假设 TodoListTodoStats 各自维护一份 todo 数据。当用户添加一条 todo 时,你需要同时更新两个地方。稍有不慎,列表显示 5 条但统计显示 3 条 ------ 数据和界面就不统一了

而我们的架构中,todos 只有一个,TodoStats 的统计数据是从 todos 实时派生出来的:

jsx 复制代码
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;

这些值每次渲染时重新计算,绝不可能出现数据不一致

4.3 组件可复用

因为子组件不持有全局状态,只依赖 props,它们变得高度可复用。你可以把 TodoInput 拿到另一个项目里,只要那个项目提供一个 onAdd 回调即可。


五、组件通信全景图

yaml 复制代码
                        ┌─────────────────────────────────┐
                        │            App (父组件)            │
                        │                                  │
                        │  state: todos[]                  │
                        │  methods:                        │
                        │    addTodo()                     │
                        │    toggleTodo()                  │
                        │    deleteTodo()                  │
                        │    clearCompleted()              │
                        │  derived: activeCount,           │
                        │           completedCount         │
                        └──┬──────────┬─────────────┬─────┘
                           │          │             │
              ┌────────────┼──────────┼─────────────┼────────────┐
              │            │          │             │            │
              ▼            ▼          ▼             ▼            │
     ┌────────────┐ ┌────────────┐ ┌────────────┐              │
     │ TodoInput  │ │ TodoList   │ │ TodoStats  │              │
     │            │ │            │ │            │              │
     │ props:     │ │ props:     │ │ props:     │              │
     │  onAdd ────┼─┼──> 回调     │ │  total     │              │
     │            │ │  todos ────┼─┼──> 只读数据  │  active     │
     │ local:     │ │  onToggle ─┼─┼──> 回调     │  completed  │
     │  inputValue│ │  onDelete ─┼─┼──> 回调     │  onClear ───┼──> 回调
     └────────────┘ └────────────┘ └────────────┘              │
                           │                                   │
                           │   数据流向:父 → 子 (props)         │
                           │   事件流向:子 → 父 (callbacks)     │
                           └───────────────────────────────────┘

六、总结

这个简单的 Todo 应用展示了一个经典的 React 数据流模式:

原则 实践
单一数据源 todos 状态只在 App 中定义
状态提升 共享的状态放在最近的公共祖先
Props 向下 数据通过 props 从父传到子
回调向上 子组件通过回调报告操作意图
不可变更新 永远用新对象/新数组来更新状态
派生而非复制 统计数据从源数据实时计算,不另行存储

掌握了这些原则,你就掌握了 React 组件化开发的基石。**子组件们共享统一的数据状态,数据和界面始终保持一致 ** ------ 这不是一句口号,而是通过严谨的单向数据流架构来实现的工程约束。


相关推荐
玉宇夕落1 小时前
React 父子组件通信 Todo List 看透单向数据流与不可变数据
前端
软件开发技术深度爱好者1 小时前
国际音标魔法实验室工具HTML5实现
前端·html5·英语学习
FogLetter1 小时前
嘘!WebSocket正在“偷听”你的网络请求——全双工通信的魔法
前端·面试
用户938515635071 小时前
从零构建 React Todo 应用:组件化设计与数据流深度解析
前端·javascript
IT_陈寒1 小时前
Python线程池把我坑惨了,这些盲区你不踩?
前端·人工智能·后端
程序员爱钓鱼2 小时前
第一个 Go 程序:Hello World
前端·后端·go
阳光是sunny2 小时前
LangGraph 核心概念详解:从编译到可视化
前端·人工智能·后端
不好听6133 小时前
React 父子组件通信:从 Todo 应用理解单向数据流
前端·react.js
_lucas3 小时前
做了一个glsl在线调试工具
前端·javascript·three.js