React 组件化实战:从 TodoList 吃透父子通信与状态管理

学 React 最大的坑不是语法,而是思维。很多人能写出 JSX,却不知道什么时候该拆组件、数据该放在谁手里。这篇文章用一个 TodoList 从 0 到 1,帮你建立 React 组件化开发的核心心智模型。

一、React 的三大核心理念

在写代码之前,先理解 React 的三个核心哲学:

1.1 组件化:UI = 组件树

React 认为任何界面都可以拆成一棵组件树

复制代码
App(根组件)
├── TodoInput(输入组件)
├── TodoList(列表组件)
└── TodoStates(统计组件)

每个组件只负责一小块 UI,组合起来就是完整页面。这就像搭乐高 ------ 单个积木很简单,但组合起来可以拼出任何东西。

1.2 数据驱动:状态决定 UI

传统 DOM 操作是命令式的:

javascript 复制代码
// 命令式:告诉浏览器"做什么"
document.getElementById('count').textContent = count;
document.getElementById('count').style.color = 'red';

React 是声明式的:

jsx 复制代码
// 声明式:告诉 React "UI 应该长什么样"
<span>{count}</span>

你只需要定义"状态是什么",React 自动帮你更新 DOM。状态变了 → UI 自动变了,这就是数据驱动。

1.3 单向数据流:Props 向下,事件向上

这是 React 最核心的通信规则,也是初学者最容易搞混的地方:

scss 复制代码
┌─────────────────────────────────────────┐
│  App(父组件 - 持有 todos 状态)          │
│                                         │
│   todos = [{ id:1, text:'吃饭', ... }]  │
│     │                                   │
│     │  ──── props 向下传递 ────▶        │
│     │                                   │
│   ┌─────────────────────────┐           │
│   │ TodoList(子组件)       │           │
│   │   读取 todos,展示列表    │           │
│   │                         │           │
│   │   用户点击删除 ──┐       │           │
│   └─────────────────│───────┘           │
│                    │                     │
│     ◀─── 自定义事件向上通知 ────         │
│                    │                     │
│   App 调用 deleteTodo()                  │
│   更新 todos → 子组件自动刷新             │
└─────────────────────────────────────────┘

子组件不能直接修改父组件的数据 ,只能通过"自定义事件"通知父组件,由父组件来修改。这个规则保证了数据流的可预测性


二、TodoList 项目实战

2.1 组件树设计

动手之前,先画组件树。这是 Vibe Coding 的最佳实践 ------ 先规划结构,再写代码

scss 复制代码
App(根组件)
│  ├── 状态:todos 数组、addTodo、toggleTodo、deleteTodo、clearCompleted
│  │
│  ├── TodoInput
│  │  ├── props:onAdd
│  │  ├── 本地状态:inputValue
│  │  └── 行为:表单提交时调用 onAdd(inputValue)
│  │
│  ├── TodoList
│  │  ├── props:todos、onToggle、onDelete
│  │  └── 行为:遍历 todos 渲染列表项
│  │
│  └── TodoStates
│     ├── props:total、active、completed、onClearCompleted
│     └── 行为:展示统计信息 + 清除已完成按钮

核心原则:数据状态由父组件管理,子组件只负责展示和通知。

2.2 项目目录结构

bash 复制代码
src/
├── App.jsx          # 根组件,持有 todos 状态
├── App.css          # 样式文件
└── components/
    ├── TodoInput.jsx # 输入组件
    ├── TodoList.jsx  # 列表组件
    └── TodoStates.jsx # 统计组件

2.3 根组件 App.jsx ------ 状态的大本营

App 组件是整个应用的数据中心,todos 状态和所有修改方法都定义在这里:

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

const App = () => {
  // ========== 状态定义 ==========
  const [todos, setTodos] = useState([
    { id: 1, text: '吃饭', completed: false },
    { id: 2, text: '睡觉', completed: false },
    { id: 3, text: '打豆豆', completed: true }
  ]);

  // ========== 状态修改方法 ==========

  // 添加:展开运算符 + 新元素放前面
  const addTodo = (text) => {
    if (text.trim() === '') return;
    setTodos([
      { id: Date.now(), text, completed: false },
      ...todos
    ])
  }

  // 切换完成状态:map 生成全新数组
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    ))
  }

  // 删除:filter 生成全新数组
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }

  // 清除已完成:filter 掉 completed 为 true 的
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }

  // ========== 派生状态(计算属性) ==========
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.filter(todo => todo.completed).length;

  // ========== 渲染 ==========
  return (
    <div>
      <h1>My Todo List</h1>
      {/* 自定义事件:子组件通过 onAdd 通知父组件 */}
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
      <TodoStates
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  )
}

export default App;

重点理解

  • todos 状态只在 App 中定义,子组件通过 props 接收
  • 所有修改方法(addTodo / toggleTodo / deleteTodo)也只在 App 中定义
  • activeCountcompletedCount派生状态,不需要单独的 useState,直接从 todos 计算得出

2.4 TodoInput.jsx ------ 受控表单组件

jsx 复制代码
// 表单交互组件
import { useState } from 'react';

const TodoInput = ({ 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;

受控组件 :input 的 valueinputValue 状态控制,onChange 更新状态。这和 Vue 的 v-model 本质一样 ------ 状态是唯一数据源

数据流图解:

scss 复制代码
用户输入 "吃饭"
    │
    ▼
onChange 触发 → setInputValue("吃饭")
    │
    ▼
inputValue 状态更新
    │
    ▼
React 重新渲染 → input 显示 "吃饭"
    │
    ▼
用户点击 Add → handleSubmit 触发
    │
    ▼
e.preventDefault() + onAdd("吃饭") + setInputValue("")
    │
    ▼
父组件 App 调用 addTodo("吃饭") → todos 更新 → 所有子组件刷新

2.5 TodoList.jsx ------ 纯展示组件

jsx 复制代码
// 列表组件
const TodoList = ({ todos, onToggle, onDelete }) => {
  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)}>删除</button>
            </li>
          ))
        )
      }
    </ul>
  )
}

export default TodoList;

为什么用 key={todo.id} 而不是 key={index} React 用 key 来识别哪个元素发生了变化。如果用 index 做 key,删除中间一项后所有 index 都会错位,导致 React 错误地复用组件。用唯一 id 做 key,React 能精确定位到变化的那一项。

2.6 TodoStates.jsx ------ 统计信息组件

jsx 复制代码
const TodoStates = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-states">
      <p>Total:{total} | Active:{active} | Completed:{completed}</p>
      {
        completed > 0 && (
          <button className="clear-btn" onClick={onClearCompleted}>
            Clear Completed
          </button>
        )
      }
    </div>
  )
}

export default TodoStates;

条件渲染completed > 0 && (...) ------ 只有当有已完成的 todo 时才显示清除按钮。这是 JSX 中最常用的条件渲染写法。

2.7 App.css ------ 极简样式

css 复制代码
* {
  margin: 0;
  padding: 0;
}
li.completed span {
  text-decoration: line-through;
}

样式配合 JSX 中的 className={todo.completed ? 'completed' : ''} 动态类名,实现已完成项的删除线效果。


三、核心概念深度解析

3.1 useState 的"懒执行"特性

useState 的初始化函数是懒执行的 ------ 只在首次渲染时运行一次:

jsx 复制代码
// ❌ 每次渲染都会创建新的数组
const [todos, setTodos] = useState([
  { id: 1, text: '吃饭', completed: false },
  { id: 2, text: '睡觉', completed: false },
  { id: 3, text: '打豆豆', completed: true }
]);

// ✅ 只在首次渲染时执行(适用于计算开销大的初始值)
const [todos, setTodos] = useState(() => [
  { id: 1, text: '吃饭', completed: false },
  { id: 2, text: '睡觉', completed: false },
  { id: 3, text: '打豆豆', completed: true }
]);

当初始值是简单字面量时差别不大,但如果初始值需要复杂计算(如读取 localStorage、大数据处理),懒执行可以避免不必要的重复计算。

3.2 不可变更新:永远创建新状态

React 中修改状态的黄金法则:永远不要直接修改原状态,而是创建一个全新的状态对象

jsx 复制代码
const toggleTodo = (id) => {
  // ❌ 直接修改(React 检测不到变化)
  const target = todos.find(t => t.id === id);
  target.completed = !target.completed;
  setTodos(todos); // React 认为没有变化,不会重新渲染

  // ✅ 创建全新数组(React 能检测到引用变化)
  setTodos(todos.map(todo =>
    todo.id === id
      ? { ...todo, completed: !todo.completed } // 展开运算符创建新对象
      : todo
  ))
}

三种不可变更新的模式速查:

操作 方法 返回值
添加元素到头部 [newItem, ...array] 新数组
修改数组中某一项 array.map(item => ...) 新数组
删除数组中某一项 array.filter(item => ...) 新数组
修改对象某个属性 { ...obj, key: newValue } 新对象

3.3 父子通信完整模式

React 的父子组件通信遵循严格的**"Props Down, Events Up"**模式:

yaml 复制代码
        ┌──────────────────────────────┐
        │        App (父组件)           │
        │                              │
        │  todos: [状态数据]            │
        │  addTodo: [修改方法]           │
        │                              │
   Props│                              │Events
   Down │    onAdd ──────────────▶     │  Up
        │                              │
        │  ┌─────────────────────┐    │
        │  │  TodoInput (子组件)   │    │
        │  │                     │    │
        │  │  inputValue: 本地状态  │    │
        │  │  handleSubmit:      │    │
        │  │    → onAdd(text) ───│────┘
        │  └─────────────────────┘    │
        │                              │
        │  ┌─────────────────────┐    │
        │  │  TodoList (子组件)    │    │
        │  │                     │    │
        │  │  props: todos        │    │
        │  │  onDelete(id) ───────│────┘
        │  │  onToggle(id) ───────│────┘
        │  └─────────────────────┘    │
        └──────────────────────────────┘

对应到代码中的映射关系:

传递方向 语法 示例
Props 向下(数据) <Child data={value} /> <TodoList todos={todos} />
Events 向上(通知) <Child onAction={handler} /> <TodoInput onAdd={addTodo} />
子组件触发事件 props.onAction(data) onAdd(inputValue)
父组件处理事件 更新自己的 state setTodos([...])

3.4 Fragment 组件:减少无意义的 DOM 节点

当你需要返回多个元素但没有父容器时,用 Fragment:

jsx 复制代码
// ❌ 多了一个无意义的 div
return (
  <div>
    <h1>Title</h1>
    <p>Content</p>
  </div>
)

// ✅ Fragment 不会产生真实 DOM 节点
return (
  <>
    <h1>Title</h1>
    <p>Content</p>
  </>
)

<>...</><React.Fragment> 的语法糖,在 DOM 中完全消失,只保留子元素。


四、数据流完整生命周期

以"用户添加一条 Todo"为例,完整追踪数据在组件间的流动:

css 复制代码
┌─ Step 1: 用户在 input 中输入 "学习 React" ─────────────────┐
│                                                            │
│  TodoInput 组件内部:                                        │
│    onChange → setInputValue("学习 React")                    │
│    input 的 value 自动更新为 "学习 React"                     │
│    (这是 TodoInput 的本地状态,与父组件无关)                   │
└────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─ Step 2: 用户点击 Add 按钮 ───────────────────────────────┐
│                                                            │
│  TodoInput 组件内部:                                        │
│    handleSubmit(e) 触发                                    │
│    → e.preventDefault()  // 阻止页面刷新                    │
│    → onAdd("学习 React")  // 调用 props 传入的回调           │
│    → setInputValue("")     // 清空输入框                      │
└────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─ Step 3: 父组件 App 响应 ─────────────────────────────────┐
│                                                            │
│  App 组件内部:                                              │
│    addTodo("学习 React") 被调用                              │
│    → setTodos([                                             │
│        { id: 1721856000000, text: "学习 React", ... },    │
│        ...todos  // 原有的 todos 全部保留                    │
│      ])                                                    │
│    → todos 状态更新                                         │
└────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─ Step 4: 所有子组件自动刷新 ─────────────────────────────┐
│                                                            │
│  React 重新渲染:                                           │
│    TodoList 收到新的 todos → 列表多了一项                     │
│    TodoStates 收到新的 total/active → 数字更新了              │
│    TodoInput 不受影响 → 输入框保持空                          │
└────────────────────────────────────────────────────────────┘

这就是 React 的单向数据流:状态集中在父组件 → 通过 props 分发 → 子组件通过事件通知父组件修改 → 父组件更新状态 → React 自动刷新所有相关子组件。


五、知识图谱

ini 复制代码
React 组件化开发
├── 核心理念
│   ├── 组件化:UI = 组件树
│   ├── 数据驱动:状态 → UI
│   └── 单向数据流:Props Down, Events Up
│
├── Hooks
│   ├── useState:状态管理
│   │   ├── 基本用法:const [state, setState] = useState(initialValue)
│   │   ├── 懒执行:useState(() => expensiveValue)
│   │   └── 不可变更新:创建新对象/数组
│   └── useEffect:副作用(本文未涉及,下一篇展开)
│
├── 组件通信
│   ├── 父 → 子:Props(数据向下)
│   ├── 子 → 父:自定义事件(通知向上)
│   └── 兄弟组件:通过共同父组件中转
│
├── JSX 语法
│   ├── 表达式插值:{variable}
│   ├── 条件渲染:{condition && <Component />}
│   ├── 列表渲染:{array.map(item => ...)}
│   ├── Fragment:<>...</> 减少无意义 DOM
│   └── className(不是 class)
│
├── 受控组件
│   └── input value + onChange = 状态是唯一数据源
│
└── 开发流程
    ├── 先画组件树(规划阶段)
    ├── 确定数据归属(哪个组件持有状态)
    ├── 定义 Props 和 Events 接口
    └── 逐个实现组件

六、总结

通过 TodoList 这个经典案例,我们吃透了 React 组件化开发的五个核心要点:

  1. 组件树思维 ------ 先规划组件结构,再动手写代码
  2. 状态归属原则 ------ 数据放在最近的共同父组件中
  3. Props Down, Events Up ------ 子组件只读不改,通过回调通知
  4. 不可变更新 ------ 永远创建新状态,不要直接修改
  5. 受控组件 ------ 表单元素的值由状态控制

React 不只是一个库,更是一种思维方式。当你理解了"状态在哪里、怎么流动、谁来修改",你就真正掌握了 React。


系列文章推荐:

  • 《JS执行机制深度解析:变量提升与调用栈》------ 理解 React 底层的 JS 运行机制
  • 《JS同步异步与Promise机制深度解析》------ 为后续的 useEffect 做准备
  • 《CSS 3D 变换与 Flex 布局实战》------ 补充样式布局能力
相关推荐
bloglin999997 小时前
langchain 和 langgraph 和 react
javascript·react.js·langchain
触底反弹7 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js
moMo8 小时前
状态管理第一步:React useState 核心行为完全拆解
react.js
拾年27512 小时前
React 组件通信完全指南 —— 从 Todo App 搞懂父子组件那点事
react.js
先吃饱再说12 小时前
React 组件通信:从 Props 单向传递到自定义事件
前端·react.js·前端框架
何时梦醒12 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js
bonechips12 小时前
React 组件通信:一套 TodoList 讲清 props 与回调
前端·react.js