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

3 阅读10分钟

前言

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

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

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

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

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

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

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

对应的目录结构:

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

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

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

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

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

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

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 会用新值重新渲染组件
const [值, 更新函数] = useState(初始值)

什么是懒执行?

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

const [data, setData] = useState(() => 昂贵的初始计算())

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

2.2 数据怎么组织?

每条 todo 是一个对象:

{ id: 数字, text: '事项描述', completed: 布尔值 }

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

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

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

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

3.1 为什么用 [] 包起来?

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

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

3.2 为什么不能用 push

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

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

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

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

3.3 text.trim() 是干嘛的?

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

3.4 { text } 是什么?

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

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

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 保留符合条件

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 取反条件

const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed))
}

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

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

const activeCount = todos.filter(todo => !todo.completed).length
const completedCount = todos.length - activeCount

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

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

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

我们写的这些标签叫 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),事件向上冒泡(回调函数)
┌──────────────────────────────────────────┐
│  App(父组件)                             │
│  · 持有 todos state                       │
│  · 定义 addTodo / toggleTodo / deleteTodo  │
│                                           │
│   ┌─────────────────┐                    │
│   │ props ↓  ↑ 回调  │                    │
│   │  TodoInput       │  onAdd={addTodo}   │
│   └─────────────────┘                    │
│   ┌─────────────────┐                    │
│   │ props ↓  ↑ 回调  │                    │
│   │  Todolist        │  todos={todos}     │
│   │                  │  onToggle={...}    │
│   │                  │  onDelete={...}    │
│   └─────────────────┘                    │
│   ┌─────────────────┐                    │
│   │ props ↓  ↑ 回调  │                    │
│   │  TodoStats       │  total={...}       │
│   └─────────────────┘                    │
└──────────────────────────────────────────┘

9.2 Props 命名约定

<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——受控组件实战

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 自己管理——这就是受控组件

用户输入 → onChange → setInputValue → state 更新 → value 变化 → 输入框显示新值

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

10.2 e.preventDefault()

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

10.3 两个 useState 的区别

注意这个项目里有两个 useState

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

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

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

10.4 key 的作用

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

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

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 实现删除线效果:

.completed {
  text-decoration: line-through;
}

十二、TodoStats——条件渲染

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 里最常用的条件渲染写法:

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

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

十三、凑成整体——入口文件 main.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"> 上。

十四、完整的心智模型

把整个流程串起来:

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}
子组件改 propsReact 不允许通过回调通知父组件改

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

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

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

总结

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

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

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


项目源码hjf-ai/fe/react/basic/todos/
技术栈:React 18 + Vite + JSX