React 组件化思想实战:用 Todo App 讲透“谁管数据,谁管界面”

开篇

学 React,很多人一开始会写组件,但不一定真的理解"组件化"。

比如写一个计数器,所有代码都放在 App.jsx 里,好像也没什么问题。

但当页面变成一个 Todo App:

  • 有输入框
  • 有添加按钮
  • 有待办列表
  • 有完成状态
  • 有删除按钮
  • 有统计信息
  • 还可能有清除已完成

如果所有逻辑都塞进一个 App.jsx,代码很快就会变成一坨。

React 的组件化,不只是"把页面拆成几个文件"。

更重要的是搞清楚三个问题:

谁负责管理数据?

谁负责展示界面?

子组件怎么通知父组件发生了变化?

这个 Todo App 项目刚好很适合拆解 React 组件化思想。它虽然小,但把几个核心概念都串起来了:

  • 组件树
  • 本地状态和共享状态
  • 数据向下,事件向上
  • 状态提升
  • 不可变更新
  • 受控组件
  • 单一数据源

读完这篇文章,你会明白为什么 React 开发里经常说:

先画组件树,再写代码。

一、先画组件树,再写代码

面对 Todo App 这种页面,新手最容易直接开写:

text 复制代码
一个输入框,一个按钮,一个 ul,循环渲染 li。

这样当然能写出来。

但一旦功能变多,你就会发现:

  • 输入框逻辑和列表逻辑混在一起
  • 删除逻辑和统计逻辑混在一起
  • 状态到底放在哪里开始变得模糊
  • 后续想加筛选、排序、持久化会越来越难

所以更好的做法是:先拆组件。

这个 Todo App 可以拆成:

text 复制代码
App(总管)
├── TodoInput   输入框 + 添加按钮
├── TodoList    待办列表 + 勾选/删除
└── TodoStats   统计信息 + 清除已完成

这就是组件树。

以前写原生 DOM,我们更关心:

text 复制代码
div 里面套 div,ul 里面套 li

而写 React,我们更关心:

text 复制代码
哪个组件负责什么职责,数据从哪里来,事件往哪里走

组件树的价值不只是"看起来清楚"。

它直接决定后面的状态设计。

二、组件划分的第一原则:谁需要,谁管理

划分组件不是随便拆文件。

一个很重要的原则是:

状态应该放在所有需要使用它的组件的最近公共父组件里。

这句话听起来有点绕,放到 Todo App 里就很清楚。

todos 这份数据被多个地方需要:

  • TodoList 需要它来渲染列表
  • TodoStats 需要它来统计数量
  • TodoInput 添加新任务时,需要让父组件把新任务加进 todos
  • App 需要协调所有子组件的数据和事件

所以 todos 应该放在 App 里。

jsx 复制代码
const [todos, setTodos] = useState([
  { id: 1, text: "吃饭", completed: false },
  { id: 2, text: "睡觉", completed: false },
  { id: 3, text: "打豆豆", completed: true },
]);

App 是这个 Todo App 的数据总管。

它维护唯一的 todos 数据源,然后把数据通过 props 传给子组件。

这就是 React 里常说的:

single source of truth,单一数据源。

只有一个地方保存真实数据,其他组件都从这里读取。

这样做的好处是:数据不会散落在多个组件里,也不会出现 A 组件里的数据已经变了,B 组件还拿着旧数据的情况。

三、本地状态和共享状态

不是所有状态都应该放在 App

比如输入框里的内容:

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

这个状态只属于 TodoInput

用户正在输入什么,只有输入框自己关心。TodoList 不需要知道,TodoStats 也不需要知道。

所以它应该放在 TodoInput 内部。

这就是本地状态。

todos 不一样。

它被列表、统计栏、添加逻辑共同使用,所以它应该提升到共同父组件 App 中。

这就是共享状态。

判断一个状态放在哪里,可以问自己两个问题:

text 复制代码
谁需要读取它?
谁需要修改它?

如果只有一个组件需要,那就放在这个组件内部。

如果多个兄弟组件都需要,那就往上提,放到它们最近的共同父组件里。

这就是状态提升。

四、数据向下流,事件向上流

组件拆好之后,下一个问题是:

子组件不能直接改父组件的数据,那它怎么让父组件知道"我要添加/删除/切换完成状态"?

React 的答案是:

数据通过 props 向下传,事件通过回调向上传。

比如 Apptodos 传给 TodoList

jsx 复制代码
<TodoList
  todos={todos}
  onToggle={toggleTodo}
  onDelete={deleteTodo}
/>

这里有两类 props:

text 复制代码
todos       数据
onToggle    回调函数
onDelete    回调函数

TodoList 负责展示数据。

当用户点击复选框或删除按钮时,TodoList 不直接修改 todos,而是调用父组件传下来的函数:

jsx 复制代码
onToggle(todo.id);
onDelete(todo.id);

子组件的意思是:

爸,我这里发生了一个事件,你来决定怎么改数据。

父组件收到事件后,调用 setTodos 更新状态。

状态一变,父组件重新渲染,再把新的 todos 传给子组件。

这就是 React 的单向数据流。

五、TodoInput:本地状态 + 通知父组件

先看输入组件:

jsx 复制代码
const TodoInput = ({ onAdd }) => {
  const [inputValue, setInputValue] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();

    if (inputValue.trim() === "") return;

    onAdd(inputValue);
    setInputValue("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  );
};

这个组件做了两件事:

  1. 自己管理输入框内容
  2. 提交时通知父组件添加任务

inputValue 是本地状态:

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

它只影响输入框自己,不需要提升到 App

提交时:

jsx 复制代码
onAdd(inputValue);

TodoInput 并不知道父组件怎么添加任务。

它只负责把用户输入的文本交出去。

父组件里才是真正修改 todos 的地方:

jsx 复制代码
const addTodo = (text) => {
  if (text.trim() === "") return;

  setTodos([
    { id: Date.now(), text, completed: false },
    ...todos,
  ]);
};

这里有一个小建议:如果子组件里已经判断了空字符串,父组件里也可以保留一次判断。

因为父组件是数据入口,防御性更强。

六、TodoList:只展示,不保存 todos

再看列表组件:

jsx 复制代码
const TodoList = ({ todos, onToggle, onDelete }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li className="empty">no todos</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>
  );
};

这个组件很干净。

它没有自己的 useState

因为它不拥有 todos,它只是展示 todos

它的职责很明确:

  • todos.length === 0 时显示空状态
  • todos.map 渲染待办列表
  • 勾选时调用 onToggle(todo.id)
  • 删除时调用 onDelete(todo.id)

它不关心父组件怎么切换完成状态。

它也不关心父组件怎么删除任务。

这就是一个好子组件的样子:

数据从 props 来,事件通过回调走。

七、TodoStats:从 todos 推导统计信息

统计组件通常长这样:

jsx 复制代码
const TodoStats = ({ todos, onClearCompleted }) => {
  const total = todos.length;
  const completed = todos.filter((todo) => todo.completed).length;
  const active = total - completed;

  return (
    <div className="todo-stats">
      <span>全部:{total}</span>
      <span>未完成:{active}</span>
      <span>已完成:{completed}</span>
      <button onClick={onClearCompleted}>清除已完成</button>
    </div>
  );
};

这里有一个很重要的点:

totalcompletedactive 不需要再用 useState 保存。

因为它们都可以从 todos 计算出来。

text 复制代码
total = todos.length
completed = 已完成数量
active = total - completed

这种数据叫派生数据。

派生数据能算出来,就不要额外存 state。

否则你就要维护两份数据的一致性,很容易出 bug。

八、不可变更新:不要直接改原数组

React 状态更新里有一条非常重要的规则:

不要直接修改原数组或原对象,而是创建一个新数组或新对象。

添加任务:

jsx 复制代码
setTodos([
  { id: Date.now(), text, completed: false },
  ...todos,
]);

切换完成状态:

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

删除任务:

jsx 复制代码
setTodos(todos.filter((todo) => todo.id !== id));

清除已完成:

jsx 复制代码
setTodos(todos.filter((todo) => !todo.completed));

这几种写法都有一个共同点:

返回新数组,而不是在原数组上改。

不要这样写:

jsx 复制代码
todos.push(newTodo);
setTodos(todos);

也不要这样写:

jsx 复制代码
todo.completed = !todo.completed;
setTodos(todos);

因为你修改的是原来的数组和对象,容易导致 React 无法可靠判断状态变化,也会让数据流变得不可预测。

更好的习惯是:

text 复制代码
旧数据进去,新数据出来。

这就是不可变更新。

它不只是为了让页面更新,更是为了让状态变化可追踪、可调试、可维护。

九、受控组件:React 如何接管表单

输入框这里还有一个经典概念:受控组件。

jsx 复制代码
<input
  type="text"
  value={inputValue}
  onChange={(e) => setInputValue(e.target.value)}
/>

这段代码的意思是:

输入框显示什么,由 React 状态决定。

用户输入时,并不是你手动去改 DOM。

流程是:

text 复制代码
用户输入
  ↓
触发 onChange
  ↓
setInputValue 更新状态
  ↓
组件重新渲染
  ↓
input 显示新的 inputValue

这样做有什么好处?

因为表单数据变成了 React 状态。

你想做校验、限制长度、格式化、提交前处理,都可以在状态更新逻辑里完成。

比如限制最多 20 个字:

jsx 复制代码
onChange={(e) => {
  const value = e.target.value;
  if (value.length > 20) return;
  setInputValue(value);
}}

这就是受控组件的价值。

十、完整流程:添加一条待办发生了什么?

把前面的内容串起来。

当用户添加一条"买菜"时,完整流程是:

text 复制代码
用户在输入框输入"买菜"
  ↓
onChange 触发
  ↓
setInputValue("买菜")
  ↓
TodoInput 本地状态更新

用户点击 Add
  ↓
form onSubmit 触发
  ↓
handleSubmit 执行
  ↓
e.preventDefault()
  ↓
onAdd("买菜")
  ↓
通知父组件 App

App 执行 addTodo
  ↓
setTodos([新任务, ...旧任务])
  ↓
todos 更新
  ↓
App 重新渲染

新的 todos 通过 props 传下去
  ↓
TodoList 显示"买菜"
  ↓
TodoStats 数量 +1

全程没有手动操作 DOM。

你只是更新状态。

React 根据状态重新计算 UI。

这就是数据驱动界面。

十一、组件化真正带来了什么?

组件化不是为了显得代码"高级"。

它真正解决的是复杂度。

拆成组件之后:

  • TodoInput 只管输入和提交
  • TodoList 只管列表展示和列表事件
  • TodoStats 只管统计和清除事件
  • App 只管状态和协调

这样一来,需求变化时影响范围就很清楚。

要改输入框样式,只看 TodoInput

要改空状态文案,只看 TodoList

要改统计规则,只看 TodoStats

要接本地存储或远程接口,优先从 App 的状态入口处理。

这就是组件化最实际的价值:

职责清晰,变化可控。

十二、常见误区

误区一:组件拆得越多越好

不是。

组件拆分的目的不是追求文件数量,而是让职责更清楚。

如果一个组件本身很简单,也没有复用需求,强行拆出去反而增加理解成本。

误区二:子组件应该自己修改父组件数据

不应该。

子组件通过回调通知父组件,父组件负责真正修改状态。

这能保证数据流清晰。

误区三:所有数据都要放到 App

不是。

只有多个组件共享的数据才需要往上提。

像输入框临时内容这种只属于 TodoInput 的状态,留在组件内部就很好。

误区四:派生数据也要存 state

不需要。

能从已有 state 计算出来的数据,优先直接计算。

比如 totalcompletedactive 都可以从 todos 推导出来。

误区五:可以直接 push 修改数组

不建议。

React 状态更新要保持不可变思维。用 mapfilter、展开运算符创建新数组、新对象,数据变化才更可靠、更容易调试。

十三、面试怎么答?

如果面试官问:

React 组件化思想是什么?

可以这样答:

React 组件化不是简单地把页面拆成多个文件,而是根据职责拆分 UI 和逻辑。父组件通常负责状态管理和协调,子组件负责展示和触发事件。数据通过 props 向下传递,子组件通过回调把事件传回父组件,父组件更新状态后再驱动子组件重新渲染。

如果面试官问:

React 里为什么强调单向数据流?

可以这样答:

单向数据流能让数据来源更清楚。父组件维护状态,通过 props 传给子组件;子组件不能直接修改父组件状态,只能通过回调通知父组件。这样状态变化路径是可追踪的,组件之间不会互相偷偷改数据,应用更容易维护。

如果面试官问:

什么是状态提升?

可以这样答:

当多个组件都需要共享同一份状态时,应该把这份状态提升到它们最近的共同父组件中,由父组件统一管理,再通过 props 分发给子组件。这可以避免多个组件各自维护一份状态导致数据不同步。

总结

这个 Todo App 虽然只有一百多行代码,但它讲清楚了 React 组件化的核心:

知识点 结论
组件树 先拆职责,再写代码
App 负责共享状态和协调子组件
TodoInput 管理本地输入状态,提交时通知父组件
TodoList 根据 props 展示列表,通过回调上报事件
TodoStats 从 todos 推导统计信息
单向数据流 数据向下,事件向上
状态提升 多组件共享状态放到最近公共父组件
不可变更新 用新数组、新对象描述状态变化
受控组件 表单值由 React 状态控制

React 组件化最重要的不是"拆文件",而是"拆职责"。

真正写得好的 React 代码,看起来应该像一棵清楚的组件树:

父组件管数据,子组件管展示;数据向下流,事件向上走。

理解了这句话,Todo App 就不只是一个练手项目,而是 React 组件化思想的入门样板。

相关推荐
码栈研说20 小时前
Go 语言大白话入门 10 - 排序与常用数据操作
后端·程序员
小阿鑫21 小时前
一个 AI 应用开发程序员的一天,都在屏幕前忙些什么?
ai·程序员·agent·rd270q·明基rd270q
猿的天空1 天前
机器人双手迎来全栈训练系统:灵初智能EgoSteer让灵巧手无所不能
网络·人工智能·计算机·ai·程序员·机器人·编程
触底反弹1 天前
🔥 AI 写代码总翻车?这套实战方法论救了我
人工智能·面试·程序员
用户852495071841 天前
从零攻克 useState:React 最核心的 Hook,一篇讲透状态更新和批处理
程序员
用户852495071841 天前
轻松上手 Vibe Coding:别让 AI 写屎山代码,先学会怎么指挥它
程序员
狂师1 天前
从 LLM 评测到 AI Agent 评测,我的一些思考!
人工智能·程序员·aigc
AI大模型-小雄2 天前
Codex 长任务总要重新开始?买 Credits 还是升级 ChatGPT Pro
人工智能·chatgpt·程序员·ai编程·codex·ai办公·chatgpt pro
Java爱好狂.2 天前
Java就业需要学习哪些内容?
spring·程序员·springboot·架构师·java面试·java面试题·java八股文