React 待办事项 Todos 从零到一:组件化思维、状态管理与父子通信一次讲透

前言

作为一个前端学习者,总有一个绕不开的练手项目------Todo List(待办事项) 。看似简单,实际上它把 React 最核心的几个概念全部串了起来:组件化、响应式状态、不可变更新、父子通信。

本文从一个零基础的视角出发,一步步拆解我是怎么写出这个 todos 项目的,文章中会覆盖:

  • 组件树的规划思路
  • useState 的用法和"懒执行"
  • JSX 的本质
  • Props 单向数据流
  • 父子组件通信的完整流程
  • map / filter / 展开运算符在 React 状态更新中的运用
  • 受控组件与非受控组件

读完你会收获一个完整的 React 心智模型,而不只是"能写出代码"。

一、先有思路,再写代码------组件树的规划

拿到需求后,不要急着写代码。先用组件化的视角把页面拆解一下:

css 复制代码
App(父组件,总管一切)
├── TodoInput    → 输入框 + 添加按钮
├── Todolist     → 待办列表展示
│   └── li × N   → 每条待办(checkbox + 文字 + 删除按钮)
└── TodoStats    → 统计信息 + 清除已完成按钮

对应的目录结构:

bash 复制代码
src/
├── App.jsx              # 父组件:持有数据状态,定义增删改逻辑
├── App.css              # 全局样式
├── main.jsx             # 入口文件,挂载到 DOM
└── components/
    ├── TodoInput.jsx    # 表单交互组件
    ├── Todolist.jsx     # 列表渲染组件
    └── TodoStats.jsx    # 统计展示组件

一个关键原则:数据状态由父组件管理,子组件们共享统一的数据状态。这意味着:

  • 子组件不持有业务数据
  • 子组件通过 props 接收数据
  • 子组件想修改数据,只能通过回调函数通知父组件

这个原则贯穿整个项目,也是 React 单向数据流的核心。

二、从数据开始------App 父组件

所有业务数据都存在 App 里,用 useState 管理:

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

const App = () => {
  const [todos, setTodos] = useState([
    { id: 1, text: '学习react', completed: false },
    { id: 2, text: '睡觉', completed: false },
    { id: 3, text: '打豆豆', completed: true },
  ])
  // ...
}

2.1 理解 useState

useState 是 React 最基础的 Hook,它做三件事:

  1. 声明一个状态变量 --- todos,初始值是那个数组
  2. 给你一个更新函数 --- setTodos,调用它来改变状态
  3. 触发重新渲染 --- 每次调用 setTodos,React 会用新值重新渲染组件
js 复制代码
const [值, 更新函数] = useState(初始值)

什么是懒执行?

如果初始值很昂贵(比如复杂计算),你可以传一个函数给 useState:

js 复制代码
const [data, setData] = useState(() => 昂贵的初始计算())

这个函数只会在组件首次创建时执行一次,后续重新渲染不会再次执行------这就是"懒执行"。我们这里初始值就是一个简单数组,直接传即可。

2.2 数据怎么组织?

每条 todo 是一个对象:

js 复制代码
{ id: 数字, text: '事项描述', completed: 布尔值 }

用数组包起来,就是 todos。这就是平时说的"对象数组"------不是 JSON 字符串,JSON 是用来传输/存储的文本格式,代码里它就是普通的 JS 数组和对象。

三、新增一条 todo------不可变更新入门

jsx 复制代码
const addTodo = (text) => {
  if (text.trim() === '') return  // 过滤空白输入

  setTodos([
    {
      id: Date.now(),   // 用时间戳生成唯一 id
      text,             // 简写,等同于 text: text
      completed: false
    },
    ...todos,           // 把旧数据展开
  ])
}

3.1 为什么用 [] 包起来?

todos 本身是一个数组 ,setTodos 需要接收一个新数组来替换旧状态。所以:

js 复制代码
setTodos([ ...todos, 新项 ])
//       ↑           ↑
//     创建新数组    末尾加一条

3.2 为什么不能用 push?

js 复制代码
// ❌ 错误写法
todos.push(新项)
setTodos(todos)
// React:数组引用没变 → 不触发更新 → 页面没变化

React 判断状态是否变化靠的是引用对比 (===)。push 修改的是原数组,引用不变。而 [...todos, 新项] 创建了一个全新的数组,引用变了,React 就知道该重新渲染了。

这是 React 不可变更新的第一条铁律:数组更新,用新数组包;对象更新,用新对象包。

js 复制代码
// 数组
setTodos([...todos, newItem])
// 对象
setUser({ ...user, name: '新名字' })

3.3 text.trim() 是干嘛的?

去除首尾空格,防止用户输入一堆空格就提交。' '.trim() 结果是 '',判空后直接 return 不添加。

3.4 { text } 是什么?

这是 ES6 的对象属性简写。{ text } 等价于 { text: text },当属性名和变量名一样时就能简写。

四、切换完成状态------map 与三元表达式

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

逐层拆解:

代码 作用
todos.map(todo => ...) 遍历数组,返回一个新数组
todo.id === id ? ... : todo 找到匹配的那条就改,否则原样不动
{ ...todo, completed: !todo.completed } 展开旧属性,翻转 completed

所以这段代码的逻辑是:遍历 → 找到 id 匹配的那条 → 把它的 completed 翻过来 → 其他不动 。map 本身返回新数组,直接交给 setTodos 就行了。

!todo.completed --- 取反。true 变 false,false 变 true,就是勾选 ↔ 取消勾选的切换。

五、删除一条------filter 保留符合条件

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

filter 保留符合条件 的元素。这里条件是 todo.id !== id,意思是"id 不等于要删的那个"。

所以结果就是:除了被删那条,其他全保留。

同样的三条:todos = [{id:1}, {id:2}, {id:3}],调用 deleteTodo(2),剩下 [{id:1}, {id:3}]。

六、清除已完成------filter 取反条件

jsx 复制代码
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed))
}

只保留 completed 为 false 的,即 !todo.completed 为 true 的。等价于说:"只留下未完成的"。

七、派生数据------不算 state,实时算就行

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

这两行不是 useState,就是普通变量。它们在每次渲染时重新计算,不必单独维护一个 state。

一个判断标准:如果一个值可以从已有的 state 推导出来,就不要用新的 state 存它。否则你会面临"两个 state 不同步"的 bug。

八、JSX------那些看起来像 HTML 的东西

我们写的这些标签叫 JSX:

jsx 复制代码
return (
  <div>
    <h1>Todo List</h1>
    <Todoinput onAdd={addTodo} />
    <Todolist todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
    <TodoStats ... />
  </div>
)

JSX 不是 HTML,是 JavaScript 的语法糖 。它会被编译成 React.createElement() 调用,最终产出真实的 DOM 节点。

在 JSX 里:

  • {} 里可以写任意 JS 表达式
  • 自定义组件必须以大写字母 开头(<Todoinput />,不是 <todoinput />)
  • 原生 HTML 属性变成了驼峰命名:className 代替 class,onClick 代替 onclick

关于 return 后面的括号 () ------ 它是为了让 JSX 可以换行写 。不加括号的话,return 后面的 JSX 必须和 return 在同一行,否则 JS 会在 return 后自动插入分号。

九、父子组件通信------整个项目的核心

9.1 基本原则

复制代码
数据向下流动(props),事件向上冒泡(回调函数)
ini 复制代码
┌──────────────────────────────────────────┐
│  App(父组件)                             │
│  · 持有 todos state                       │
│  · 定义 addTodo / toggleTodo / deleteTodo  │
│                                           │
│   ┌─────────────────┐                    │
│   │ props ↓  ↑ 回调  │                    │
│   │  TodoInput       │  onAdd={addTodo}   │
│   └─────────────────┘                    │
│   ┌─────────────────┐                    │
│   │ props ↓  ↑ 回调  │                    │
│   │  Todolist        │  todos={todos}     │
│   │                  │  onToggle={...}    │
│   │                  │  onDelete={...}    │
│   └─────────────────┘                    │
│   ┌─────────────────┐                    │
│   │ props ↓  ↑ 回调  │                    │
│   │  TodoStats       │  total={...}       │
│   └─────────────────┘                    │
└──────────────────────────────────────────┘

9.2 Props 命名约定

jsx 复制代码
<Todoinput onAdd={addTodo} />
//          ↑prop名  ↑父组件的函数
  • onAdd --- prop 的名字,on 开头表示"当......的时候",类似原生 onClick。子组件通过 props.onAdd 调用它
  • addTodo --- 父组件里定义的,真正干活的那个函数

两边名字可以不一样,比如 onSubmit={handleAdd},但保持一致更直观。

9.3 子组件不能直接改父组件的数据

React 的数据流是单向的:props 只读。子组件想修改数据,必须:

  1. 调用父组件传下来的回调函数
  2. 父组件在回调里 setTodos 修改状态
  3. React 发现状态变了,重新渲染,新的 props 自动流回子组件

用一句话记:子组件报告意图,父组件执行修改,React 负责同步。

十、TodoInput------受控组件实战

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}                          // 值由 state 控制
        onChange={(e) => setInputValue(e.target.value)}  // 每次输入更新 state
        placeholder="What needs to be done?"
        autoFocus
      />
      <button type="submit">Add</button>
    </form>
  )
}

10.1 受控组件

这里 input 的 value 由 React state (inputValue) 控制,而不是 DOM 自己管理------这就是受控组件。

perl 复制代码
用户输入 → onChange → setInputValue → state 更新 → value 变化 → 输入框显示新值

每一步都在 React 的掌控中,数据流向清晰可追踪。

10.2 e.preventDefault()

表单里按回车,浏览器默认会刷新页面 。但这里的"提交"只是想往列表里加一条数据,不需要刷新。e.preventDefault() 就是拦截这个默认行为。

10.3 两个 useState 的区别

注意这个项目里有两个 useState:

位置 state 作用域 用途
App todos 业务数据 整个应用共享
TodoInput inputValue 表单 UI 仅输入框自己用

todos 是"业务状态",由父组件持有。inputValue 是"UI 状态",仅输入框自己关心,放在 TodoInput 内部就行。

这是一个很好的判断标准:这个状态其他组件关心吗?关心 → 放在父组件;不关心 → 放在自己内部。

10.4 key 的作用

在 Todolist 中:<li key={todo.id}>,key 是 React 给列表里每个元素分的"身份证"。React 靠 key 来判断哪些项变了、删了、加了,从而高效更新 DOM。永远不要用数组索引当 key(除非列表是纯静态的),用唯一 id。

十一、Todolist------纯展示 + 转发事件

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}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => onToggle(todo.id)}     // 转发:点我切换这条
            />
            <span className={todo.completed ? 'completed' : ''}>
              {todo.text}
            </span>
            <button onClick={() => onDelete(todo.id)}>Delete</button>  {/* 转发:点我删这条 */}
          </li>
        ))
      )}
    </ul>
  )
}

这个组件本身没有任何状态,纯纯地接收 props、渲染列表、把点击事件转发回去。这种组件叫"展示组件"(Presentational Component),职责单一,最容易写也最容易调试。

completed 类名配合 CSS 实现删除线效果:

css 复制代码
.completed {
  text-decoration: line-through;
}

十二、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}>Clear Completed</button>
      )}
    </div>
  )
}

{completed > 0 && <button .../>} 是 React 里最常用的条件渲染写法:

js 复制代码
条件 && <要渲染的内容>
// true && <div>  → 渲染 <div>
// false && <div> → 什么都不渲染

利用了 JS 的短路求值 :false && anything 直接短路返回 false,React 不会渲染 false / null / undefined。

十三、凑成整体------入口文件 main.jsx

jsx 复制代码
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

createRoot 是 React 18+ 的新写法,把整个应用挂载到 index.html 里的 <div id="root"> 上。

十四、完整的心智模型

把整个流程串起来:

scss 复制代码
1. App 初始化 → useState 设置初始 todos → 渲染三个子组件
                ↓
2. 用户输入 "学 React" 按回车
   → TodoInput.handleSubmit → e.preventDefault() 阻止刷新
   → onAdd("学 React") → App.addTodo 被调用
   → setTodos([新对象, ...旧数组]) → todos 更新
   → React 重新渲染 App、Todolist、TodoStats
                ↓
3. 用户点击某个 checkbox
   → onChange → onToggle(id) → App.toggleTodo 被调用
   → setTodos(map 出翻转 completed 的新数组) → 重新渲染
                ↓
4. 用户点击某个 Delete
   → onClick → onDelete(id) → App.deleteTodo 被调用
   → setTodos(filter 掉那条) → 重新渲染

每一步都是:子组件报告 → 父组件 setState → React 重新渲染 → 新 props 自动流回去。

十五、常见错误速查

错误 原因 正确写法
todos.push(x); setTodos(todos) 原地修改,引用不变 setTodos([...todos, x])
export default App() 调用了组件,导出的是返回值 export default App
todo.completed = true; setTodos(todos) 直接修改对象属性 setTodos(todos.map(...))
key 用索引 key={i} 数组顺序变化时出 bug 用唯一 id key={todo.id}
子组件改 props React 不允许 通过回调通知父组件改

十六、进阶思考:这些概念的关系

markdown 复制代码
组件化 → 拆页面成小组件 → 需要组件间通信 → Props 单向数据流
                                               ↓
                                    useState 管理每个组件的状态
                                               ↓
                                    修改状态必须不可变(新引用)
                                               ↓
                                    React 自动对比引用 → 自动重新渲染

这不是几个孤立的 API,而是一套完整的数据驱动视图范式:你只管修改数据,React 负责把界面同步到最新。

总结

一个 Todo List,涵盖的知识点:

  • ✅ 组件化思维 --- 拆页面为组件树
  • ✅ useState --- 管理组件状态
  • ✅ 不可变更新 --- 展开运算符 ... + 新数组/新对象
  • ✅ map / filter --- 遍历变换、条件过滤
  • ✅ 父子通信 --- props 向下,回调向上
  • ✅ 受控组件 --- value + onChange 绑定
  • ✅ 条件渲染 --- && 短路求值
  • ✅ 事件处理 --- onClick、onSubmit、preventDefault

这些东西不是靠背能学会的,敲一遍就全懂了。把握住"数据驱动视图"这个中心思想,其他的都是具体实现细节。


项目源码 :hjf-ai/fe/react/basic/todos/

技术栈:React 18 + Vite + JSX

相关推荐
晨米酱4 天前
AGENTS.md:Agent 的上下文策略层
面试·架构·agent
彧azz4 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
CoderYanger4 天前
A.每日一题:835. 图像重叠
java·开发语言·程序人生·leetcode·面试·职场和发展·学习方法
爱学习的执念4 天前
助力金九银十1000道软件测试面试题(功能、接口、自动化、WEB、APP.......)附答案
软件测试·面试
小的~~4 天前
银河麒麟V10 ARM部署TDengine(涛思)工业时序数据库安装与测试
面试·程序员创富
小刘在重生~4 天前
Oracle_day2 单行函数|组函数|分组|伪列|子查询|分页|多表连接
笔记·oracle·面试
Sam_Deep_Thinking4 天前
关于java final关键字的可见性
java·后端·面试·程序员
Rain的Java大神之路4 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
淡海水4 天前
13-04-面试-源码级深度追问链
数据结构·unity·面试·c#·游戏引擎·源码·il2cpp
小刘在重生~5 天前
Java Lock 显式锁案例|ReentrantLock 解决多线程安全问题(超详细实战)
java·笔记·面试