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 --- 取反。truefalsefalsetrue,就是勾选 ↔ 取消勾选的切换。

五、删除一条------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))
}

只保留 completedfalse 的,即 !todo.completedtrue 的。等价于说:"只留下未完成的"。

七、派生数据------不算 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 代替 classonClick 代替 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 绑定
  • 条件渲染 --- && 短路求值
  • 事件处理 --- onClickonSubmitpreventDefault

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


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

技术栈:React 18 + Vite + JSX

相关推荐
FogLetter2 小时前
嘘!WebSocket正在“偷听”你的网络请求——全双工通信的魔法
前端·面试
程序员爱钓鱼2 小时前
Rust HashMap 详解:键值存储、查询、更新与统计
后端·面试·rust
触底反弹12 小时前
🔥 2026 大模型选择指南:别再只看 Benchmark 了,这些维度才是关键!
人工智能·面试
醇氧14 小时前
CountDownLatch / CyclicBarrier / Semaphore 面试高频问答清单
前端·面试·职场和发展
黄敬峰17 小时前
从零搞懂 useState 与 Fragment:React 函数式组件的基石
面试
Android研究员21 小时前
Android进阶之事件分发机制深度剖析
android·前端·面试
触底反弹21 小时前
🔥 AI 写代码总翻车?这套实战方法论救了我
人工智能·面试·程序员
风栖柳白杨21 小时前
【面试】AI算法工程师_空白自测版本
人工智能·算法·面试
风栖柳白杨1 天前
【面试】职位要求_准备
面试·职场和发展