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 向下传,事件通过回调向上传。

比如 App 把 todos 传给 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>
  );
};

这里有一个很重要的点:

total、completed、active 不需要再用 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 计算出来的数据,优先直接计算。

比如 total、completed、active 都可以从 todos 推导出来。

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

不建议。

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

十三、面试怎么答?

如果面试官问:

React 组件化思想是什么?

可以这样答:

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

如果面试官问:

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

可以这样答:

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

如果面试官问:

什么是状态提升?

可以这样答:

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

总结

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

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

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

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

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

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

相关推荐
程序员cxuan9 小时前
腾讯又来一王炸,开源版 WorkBuddy 太夯了!
人工智能·后端·程序员
穆梓兰煊10 小时前
WebAssembly AI 推理学习路线图
程序员
穆梓东海10 小时前
公司从 400 多人缩减到 100 多人,我开始重新思考程序员的未来
程序员
newerp10 小时前
Execution Trace 深入实战:可视化调度与系统停顿分析
后端·程序员·go
勿信日志10 小时前
症状不指向根因:六个把我骗过的 bug
程序员
两万五千个小时10 小时前
DeepSeek Harness 从 0 开始:20 goal模式
人工智能·程序员·架构
勿信日志11 小时前
我让 AI 做了一次调研,它给了三个错误答案——每个都长得很像真的
程序员
newerp15 小时前
Go 性能分析工具 pprof:CPU、Heap 与 Goroutine 实战
后端·程序员·go
KylinLab15 小时前
工具手册的机器可读重构:人机双轨 + 六模块
程序员
知守观16 小时前
18年老兵的十条开发军规:每条背后都是一个翻车现场
java·后端·程序员