别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念

别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念

教程看了不少,一写代码就卡住?这篇文章不从 API 讲起,而是带你从 0 拆解一个真实项目,把 React 最核心的 5 个概念串成一张网。

前言

学 React 最常见的困境是:看视频教程时觉得"哦,挺简单的",一关掉视频自己写,连 useState 的括号往哪放都想不起来。

问题出在哪?零散的知识点没有形成网

这篇文章用一个只有 60 多行的 Todo 项目,帮你把 React 入门阶段 5 个核心概念串成一条完整的链路。每个概念都会讲清楚三个问题:

  • 它是什么?(用生活化比喻)
  • 在这个项目里怎么用的?(看真实代码)
  • 底层为什么这样设计?(理解原理,才能举一反三)

读完你会收获

  • 🎯 彻底理解 useState ------ 为什么需要第二参数 setXxx
  • 📦 搞懂"父传子、子传父"的完整通信链路
  • 🔄 明白 .map() / .filter() 和"不可变数据"的关系
  • 📋 理解"受控组件"这个面试高频考点
  • 🧱 掌握"组件化思维"------拿到需求先想怎么拆

项目概览

这是一个经典的 Todo 待办事项应用,功能如下:

  • 输入文字 → 添加一条待办任务
  • 点击 ☑️ 勾选框 → 切换完成状态
  • 点击 Remove → 删除任务
  • 顶部显示 总计 / 进行中 / 已完成 数量
  • 一键清除所有已完成的任务

组件树

拿到需求第一步:拆组件。这个项目的目录结构长这样:

css 复制代码
src/
├── App.jsx                    ← 父组件,管理所有数据
├── main.jsx                   ← 入口文件
├── components/
│   ├── TodoInput.jsx          ← 输入框 + 添加按钮
│   ├── TodoStates.jsx         ← 数量统计 + 清除按钮
│   └── TodoList.jsx           ← 任务列表
└── index.css / App.css        ← 样式

用图表示组件关系:

scss 复制代码
App (父组件 --- 大脑,持有所有数据状态)
├── TodoInput   (子组件 --- 只负责"新增",不关心任务长什么样)
├── TodoStates  (子组件 --- 只负责"展示统计",不关心数据怎么来)
└── TodoList    (子组件 --- 只负责"渲染列表",不关心如何添加)

这就是组件化思维的核心:每个组件只做一件事,组合起来完成复杂功能。


概念一:组件化与组件树 --- 拿到需求第一步先拆

它是什么?

把页面拆成独立的、可复用的小块,每个小块就是一个组件。组件就像乐高积木------单个积木很简单,拼起来可以搭出任何东西。

这个项目怎么拆的?

一个 Todo 应用看起来简单,但如果不拆组件,所有代码塞在一个文件里:

jsx 复制代码
// ❌ 不拆组件 --- 代码混在一起,改一处可能影响另一处
const App = () => {
  // 输入框的逻辑
  // 列表渲染的逻辑
  // 统计数字的逻辑
  // 删除的逻辑
  // ...
  return (
    <div>
      {/* 输入框 */}
      {/* 统计 */}
      {/* 列表 */}
    </div>
  )
}

拆了之后,App.jsx 变成"指挥官",子组件各司其职:

jsx 复制代码
// ✅ 拆组件 --- App 只做协调,具体工作分派给子组件
const App = () => {
  // App 只管理数据和核心逻辑
  return (
    <div>
      <TodoInput onAdd={addTodo} />
      <TodoStates total={...} active={...} completed={...} />
      <TodoList todos={todos} onToggle={...} onRemove={...} />
    </div>
  )
}

拆组件的好处

好处 说明
好维护 输入框出 bug → 去 TodoInput 找,不用翻几百行代码
可复用 同一个组件可以在不同页面用
易测试 每个组件独立,可以单独测试
便协作 不同的人开发不同的组件,互不干扰

概念二:useState --- 给组件装上"记忆芯片"

它是什么?

React 组件默认是"无记忆"的------每次渲染都是全新的。useState 就是给组件装上一块记忆芯片,让它在渲染之间能记住数据。

打个比方:没有 useState 的组件就像金鱼,每 7 秒重新认识一遍世界。有了 useState,它变成了大象------能记住很久以前的事。

代码中的使用

App.jsx 里最重要的一行:

jsx 复制代码
const [todos, setTodos] = useState([
  { id: 1, text: '学习 React', completed: false },
  { id: 2, text: '学习 Vue', completed: false },
  { id: 3, text: '学习 Node.js', completed: true },
])

这行代码拆开看

scss 复制代码
const [todos,   setTodos]   = useState(初始值)
       ^^^^^^   ^^^^^^^^^         ^^^^^^^^^^^^
       变量     修改函数           设置初始值
       (只读)   (唯一能改它的方法)

三个关键点

  1. todos 是当前的数据,你可以读它(渲染列表、统计数量),但不能直接改它
  2. setTodos 是唯一能修改 todos 的函数,调用它 → React 知道"状态变了" → 自动重渲染页面
  3. useState(...) 括号里的值只在第一次渲染时生效,之后 React 用的是你的最新状态

底层逻辑:为什么不能直接改?

jsx 复制代码
// ❌ 直接改 --- React 根本不知道你改了
todos.push({ id: 4, text: '新任务', completed: false })

// ✅ 通过 setTodos --- React 收到通知 → 重新渲染 → 页面更新
setTodos([...todos, { id: 4, text: '新任务', completed: false }])

React 的渲染机制是**"状态变了就重新画页面"**。直接赋值 todos = xxx 不会触发这个机制,就像你偷偷换了房间里的东西但不告诉任何人------UI 永远不会变。只有通过 setTodos,React 才能收到"状态变更通知"。


概念三:父子组件通信 --- 数据向下流,事件向上报

这是 React 里最重要的通信模式,没有之一。

核心规则(README 里的原话)

子组件是不可以直接修改父组件的数据状态的,只能通过自定义事件通知父组件,父组件修改后,子组件自动更新。

这句话就是 "状态提升(Lifting State Up)" 的精髓,拆成三步理解:

ini 复制代码
第1步:数据在父组件
  App 持有 todos 和所有修改方法(addTodo、toggleTodo、removeTodo)

第2步:父 → 子(数据下传,用 props)
  <TodoList todos={todos} onToggle={toggleTodo} />

第3步:子 → 父(事件上报,用回调)
  onClick={() => onToggle(todo.id)}
  子组件调用 props 里收到的方法 → 父组件执行修改 → 自动重渲染

完整链路演示

以"勾选完成一条 todo"为例,跟踪完整的数据流:

scss 复制代码
1. 用户点击 ☑️ 勾选框
        │
2. TodoList:onChange={() => onToggle(todo.id)}
   告诉父组件:"id=2 这条要翻转状态!"
        │
3. App 的 toggleTodo(2) 被调用
   setTodos(todos.map(todo =>
     todo.id === 2 ? { ...todo, completed: !todo.completed } : todo
   ))
        │
4. React 检测到 todos 变了 → 重新渲染 App
        │
5. 新 todos 通过 props 流向 TodoList → 勾选框刷新 ✅

全程数据只在一个方向上流动:状态 → 渲染 → 用户操作 → 回调上报 → 修改状态 → 重新渲染。这个闭环就是 React 的核心哲学。

三种 props 传递模式

在实际代码中,父组件向子组件传的 props 分为三类:

类型 例子 说明
传数据 todos={todos} 子组件拿到后只读,用来渲染
传回调 onAdd={addTodo} 子组件需要"通知父组件"时调用
传计算结果 total={todos.length} 父组件算好,子组件直接展示

概念四:不可变数据 --- .map().filter() 为什么是你最好的朋友

它是什么?

React 有一条铁律:不要修改(mutate)现有的 state 对象/数组,而是用旧数据生成一份新的

这个项目用三个方法完整展示了三种不可变更新模式:

模式 1:新增 --- [...旧数组, 新元素]

jsx 复制代码
const addTodo = (text) => {
  if (text.trim() === '') return          // 防空白提交
  setTodos([
    { id: Date.now(), text, completed: false },
    ...todos                               // 展开旧数组
  ])
}

每一步发生了什么

bash 复制代码
初始状态: [{id:1}, {id:2}, {id:3}]

1. { id: Date.now(), text, completed: false }
   → 新建一个 todo 对象,Date.now() 返回当前时间戳作为唯一 id

2. ...todos
   → 把旧数组的三个元素"摊开"

3. [新对象, 第1条, 第2条, 第3条]
   → 拼成新数组。新对象在最前面,所以最新的 todo 显示在最上面

4. setTodos(...)
   → 通知 React:"给你一个新数组,重新渲染吧"

模式 2:修改某一条 --- .map() + 三元 + 展开

这是最复杂的更新模式,但也是 React 开发中最常见的操作,值得仔细拆解:

jsx 复制代码
const toggleTodo = (id) => {
  setTodos(todos.map(todo =>          // ① 遍历旧数组
    todo.id === id                    // ② 判断:当前这条是不是目标?
      ? { ...todo, completed: !todo.completed }  // ③ 是 → 复制并翻转
      : todo                          // ④ 否 → 原样保留
  ))
}

这 4 层嵌套逻辑,每一步单独看都很简单:

.map(todo => ...)

.map() 遍历数组的每个元素,对每个元素执行回调,收集返回值形成新数组:

css 复制代码
旧数组: [A, B, C]
          ↓  ↓  ↓  .map()
新数组: [A', B', C']

旧数组完好无损,你拿到的是一个全新的数组

todo.id === id --- 找到目标

假设调用 toggleTodo(2)

  • 遍历到 {id:1}1 === 2? → false
  • 遍历到 {id:2}2 === 2? → true找到了!
  • 遍历到 {id:3}3 === 2? → false

{ ...todo, completed: !todo.completed } --- 不可变更新的王牌组合

这是 React 里最常见的"修改对象字段"写法,拆成两步:

yaml 复制代码
原始 todo: { id: 2, text: '学习 Vue', completed: false }

Step 1: { ...todo }
  → { id: 2, text: '学习 Vue', completed: false }  // 全量复制

Step 2: completed: !todo.completed
  → completed: !false
  → completed: true                                // 覆盖 completed 字段

最终: { id: 2, text: '学习 Vue', completed: true }
       ^^^^       ^^^^^^^^^^^        ^^^^^^^^^^^^^^^
       照抄       照抄               翻转了!

{ ...旧对象, 要改的字段: 新值 } 是 React 中最常用的模式------复制一份,只改需要改的。

: todo --- 不满足条件,原样返回

不是目标的 todo,不做任何处理,直接放进新数组。

模式 3:删除 --- .filter()

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

filter 的规则很简单:回调返回 true → 保留,返回 false → 丢弃。

bash 复制代码
删除 id=2:
[id:1, id:2, id:3]
  ↓      ↓     ↓
 true  false  true    ← 只有 id=2 返回 false
  ↓             ↓
[id:1,        id:3]   ← id=2 被"过滤掉"了

小结:map vs filter

方法 作用 返回 比喻
.map() 每条都保留,可以变形 和原数组一样长 炒菜 --- 食材都在,样子变了
.filter() 符合条件的留下,其余丢弃 比原数组短或一样长 筛子 --- 大的留下,小的筛掉

两者都不修改原数组,返回新数组,完美符合 React 不可变数据要求。


概念五:受控组件 --- 面试必问,但理解起来很简单

它是什么?

普通 HTML 中,<input> 自己管理自己的值------你输入什么,它就显示什么,React 并不知道。

在 React 中,我们让 state 来管理 input 的值,input 变成纯粹"听指挥"的展示。这就是受控组件。

css 复制代码
普通 input:        受控组件 input:
用户 ──→ input      用户 ──→ onChange ──→ setState ──→ state ──→ input value
(React 不知道值)    (React 始终知道精确的值)

代码中的实现

TodoInput 里的 input 就是一个标准的受控组件:

jsx 复制代码
const [inputValue, setInputValue] = useState('')

<input
  value={inputValue}                              // ① 值由 state 控制
  onChange={(e) => setInputValue(e.target.value)} // ② 输入时更新 state
/>

这个闭环长这样

lua 复制代码
初始: inputValue = ""     → input 显示 ""
用户敲 "a"  → onChange   → setInputValue("a")
inputValue = "a"          → input 显示 "a"     ← 数据 = UI,完全同步
用户敲 "b"  → onChange   → setInputValue("ab")
inputValue = "ab"         → input 显示 "ab"

为什么要用受控组件?

因为 React 需要"知道" input 的值是什么。举个例子:

jsx 复制代码
const handleSubmit = (e) => {
  e.preventDefault()
  onAdd(inputValue)     // ← 这里需要用 input 的值
  setInputValue('')     // ← 这里需要清空 input
}

如果 input 的值由浏览器自己管,React 就不知道用户输入了什么------handleSubmit 里拿不到 inputValue,也没法清空输入框。

受控组件的好处就是:React 始终拥有"真相的唯一来源"(Single Source of Truth)。

同样,TodoList 里的 checkbox 也是受控组件:

jsx 复制代码
<input
  type="checkbox"
  checked={todo.completed}                     // 勾选状态由数据决定
  onChange={() => onToggle(todo.id)}           // 用户点击 → 翻转数据 → 重新渲染
/>

checkbox 自己不做主------它只是 todo.completed 这个布尔值的"显示器"。


总结:5 个概念如何连成一整张网

回顾这个 60 多行的 Todo 项目,5 个概念不是孤立的,它们互相配合:

python 复制代码
① 组件化:把页面拆成 App、TodoInput、TodoStates、TodoList 四块
    ↓
② useState:在 App 里用 useState 管理 todos 数据("记忆芯片")
    ↓
③ 父子通信:通过 props 把数据传给子组件,通过回调把事件报给父组件
    ↓
④ 不可变更新:用 .map() / .filter() + 展开运算符 修改数组,不碰原数据
    ↓
⑤ 受控组件:input 的 value 和 checkbox 的 checked 由 state 驱动

这张网就是 React 入门阶段最核心的知识体系。掌握这 5 个概念,80% 的 React 入门项目你都能独立完成。

下一步可以探索的方向

  • useEffect 把 todos 存到 localStorage,实现数据持久化
  • useReducer 替代多个 useState,适合更复杂的状态逻辑
  • 拆分更细的子组件,比如把每条 <li> 抽成独立的 TodoItem 组件
  • 用 Context API 替代 props 层层传递,理解"跨层级通信"

React 的核心从来不是记 API------理解了这几个概念,API 查文档就行。关键是思维方式的转变:你不再手动操作 DOM,而是描述"状态应该是什么样子",React 负责把状态变成界面。

你觉得这种拆解方式对你有帮助吗?欢迎在评论区交流你的学习心得 👏

相关推荐
大白要努力!1 小时前
纯前端实现 PDF 加水印工具 —— 零后端、支持中文、实时预览
前端·pdf·html
石小石Orz1 小时前
TRAE SOLO实战:实现一个桌面3D助手
前端·人工智能
蜡台1 小时前
使用 uni-popup 实现数据选择器Data-Picker
前端·javascript·html·uniapp·uni-popup·data-picker
拆房老料1 小时前
BaseMetas FileView 1.2.0 发布:Office/WPS 大文件预览与内存安全优化实测
前端·产品运营·开源软件
blns_yxl1 小时前
Promise封装Fetch + 重试机制(HTML+JS)
前端·javascript·html
Elastic 中国社区官方博客1 小时前
使用重新设计的 AutoOps 更快地进行 Elasticsearch 问题排查
大数据·运维·前端·人工智能·elasticsearch·搜索引擎·全文检索
无巧不成书02181 小时前
Vue+Vue-CLI全平台零基础搭建教程
前端·javascript·vue.js·vue·前端开发·vue-cli·前端环境搭建
何时梦醒2 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js