学 React 最大的坑不是语法,而是思维。很多人能写出 JSX,却不知道什么时候该拆组件、数据该放在谁手里。这篇文章用一个 TodoList 从 0 到 1,帮你建立 React 组件化开发的核心心智模型。
一、React 的三大核心理念
在写代码之前,先理解 React 的三个核心哲学:
1.1 组件化:UI = 组件树
React 认为任何界面都可以拆成一棵组件树:
App(根组件)
├── TodoInput(输入组件)
├── TodoList(列表组件)
└── TodoStates(统计组件)
每个组件只负责一小块 UI,组合起来就是完整页面。这就像搭乐高 ------ 单个积木很简单,但组合起来可以拼出任何东西。
1.2 数据驱动:状态决定 UI
传统 DOM 操作是命令式的:
javascript
// 命令式:告诉浏览器"做什么"
document.getElementById('count').textContent = count;
document.getElementById('count').style.color = 'red';
React 是声明式的:
jsx
// 声明式:告诉 React "UI 应该长什么样"
<span>{count}</span>
你只需要定义"状态是什么",React 自动帮你更新 DOM。状态变了 → UI 自动变了,这就是数据驱动。
1.3 单向数据流:Props 向下,事件向上
这是 React 最核心的通信规则,也是初学者最容易搞混的地方:
scss
┌─────────────────────────────────────────┐
│ App(父组件 - 持有 todos 状态) │
│ │
│ todos = [{ id:1, text:'吃饭', ... }] │
│ │ │
│ │ ──── props 向下传递 ────▶ │
│ │ │
│ ┌─────────────────────────┐ │
│ │ TodoList(子组件) │ │
│ │ 读取 todos,展示列表 │ │
│ │ │ │
│ │ 用户点击删除 ──┐ │ │
│ └─────────────────│───────┘ │
│ │ │
│ ◀─── 自定义事件向上通知 ──── │
│ │ │
│ App 调用 deleteTodo() │
│ 更新 todos → 子组件自动刷新 │
└─────────────────────────────────────────┘
子组件不能直接修改父组件的数据 ,只能通过"自定义事件"通知父组件,由父组件来修改。这个规则保证了数据流的可预测性。
二、TodoList 项目实战
2.1 组件树设计
动手之前,先画组件树。这是 Vibe Coding 的最佳实践 ------ 先规划结构,再写代码。
scss
App(根组件)
│ ├── 状态:todos 数组、addTodo、toggleTodo、deleteTodo、clearCompleted
│ │
│ ├── TodoInput
│ │ ├── props:onAdd
│ │ ├── 本地状态:inputValue
│ │ └── 行为:表单提交时调用 onAdd(inputValue)
│ │
│ ├── TodoList
│ │ ├── props:todos、onToggle、onDelete
│ │ └── 行为:遍历 todos 渲染列表项
│ │
│ └── TodoStates
│ ├── props:total、active、completed、onClearCompleted
│ └── 行为:展示统计信息 + 清除已完成按钮
核心原则:数据状态由父组件管理,子组件只负责展示和通知。
2.2 项目目录结构
bash
src/
├── App.jsx # 根组件,持有 todos 状态
├── App.css # 样式文件
└── components/
├── TodoInput.jsx # 输入组件
├── TodoList.jsx # 列表组件
└── TodoStates.jsx # 统计组件
2.3 根组件 App.jsx ------ 状态的大本营
App 组件是整个应用的数据中心,todos 状态和所有修改方法都定义在这里:
jsx
import { useState } from 'react';
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoStates from "./components/TodoStates";
import './App.css';
const App = () => {
// ========== 状态定义 ==========
const [todos, setTodos] = useState([
{ id: 1, text: '吃饭', completed: false },
{ id: 2, text: '睡觉', completed: false },
{ id: 3, text: '打豆豆', completed: true }
]);
// ========== 状态修改方法 ==========
// 添加:展开运算符 + 新元素放前面
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: Date.now(), text, completed: false },
...todos
])
}
// 切换完成状态:map 生成全新数组
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
))
}
// 删除:filter 生成全新数组
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
// 清除已完成:filter 掉 completed 为 true 的
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed))
}
// ========== 派生状态(计算属性) ==========
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
// ========== 渲染 ==========
return (
<div>
<h1>My Todo List</h1>
{/* 自定义事件:子组件通过 onAdd 通知父组件 */}
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStates
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
)
}
export default App;
重点理解:
todos状态只在 App 中定义,子组件通过props接收- 所有修改方法(addTodo / toggleTodo / deleteTodo)也只在 App 中定义
activeCount和completedCount是派生状态,不需要单独的 useState,直接从 todos 计算得出
2.4 TodoInput.jsx ------ 受控表单组件
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}
onChange={(e) => setInputValue(e.target.value)}
placeholder="What needs to be done?"
autoFocus
/>
<button type="submit">Add</button>
</form>
)
}
export default TodoInput;
受控组件 :input 的
value由inputValue状态控制,onChange更新状态。这和 Vue 的v-model本质一样 ------ 状态是唯一数据源。
数据流图解:
scss
用户输入 "吃饭"
│
▼
onChange 触发 → setInputValue("吃饭")
│
▼
inputValue 状态更新
│
▼
React 重新渲染 → input 显示 "吃饭"
│
▼
用户点击 Add → handleSubmit 触发
│
▼
e.preventDefault() + onAdd("吃饭") + setInputValue("")
│
▼
父组件 App 调用 addTodo("吃饭") → todos 更新 → 所有子组件刷新
2.5 TodoList.jsx ------ 纯展示组件
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} 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>
)
}
export default TodoList;
为什么用
key={todo.id}而不是key={index}? React 用 key 来识别哪个元素发生了变化。如果用 index 做 key,删除中间一项后所有 index 都会错位,导致 React 错误地复用组件。用唯一 id 做 key,React 能精确定位到变化的那一项。
2.6 TodoStates.jsx ------ 统计信息组件
jsx
const TodoStates = ({ total, active, completed, onClearCompleted }) => {
return (
<div className="todo-states">
<p>Total:{total} | Active:{active} | Completed:{completed}</p>
{
completed > 0 && (
<button className="clear-btn" onClick={onClearCompleted}>
Clear Completed
</button>
)
}
</div>
)
}
export default TodoStates;
条件渲染 :
completed > 0 && (...)------ 只有当有已完成的 todo 时才显示清除按钮。这是 JSX 中最常用的条件渲染写法。
2.7 App.css ------ 极简样式
css
* {
margin: 0;
padding: 0;
}
li.completed span {
text-decoration: line-through;
}
样式配合 JSX 中的 className={todo.completed ? 'completed' : ''} 动态类名,实现已完成项的删除线效果。
三、核心概念深度解析
3.1 useState 的"懒执行"特性
useState 的初始化函数是懒执行的 ------ 只在首次渲染时运行一次:
jsx
// ❌ 每次渲染都会创建新的数组
const [todos, setTodos] = useState([
{ id: 1, text: '吃饭', completed: false },
{ id: 2, text: '睡觉', completed: false },
{ id: 3, text: '打豆豆', completed: true }
]);
// ✅ 只在首次渲染时执行(适用于计算开销大的初始值)
const [todos, setTodos] = useState(() => [
{ id: 1, text: '吃饭', completed: false },
{ id: 2, text: '睡觉', completed: false },
{ id: 3, text: '打豆豆', completed: true }
]);
当初始值是简单字面量时差别不大,但如果初始值需要复杂计算(如读取 localStorage、大数据处理),懒执行可以避免不必要的重复计算。
3.2 不可变更新:永远创建新状态
React 中修改状态的黄金法则:永远不要直接修改原状态,而是创建一个全新的状态对象。
jsx
const toggleTodo = (id) => {
// ❌ 直接修改(React 检测不到变化)
const target = todos.find(t => t.id === id);
target.completed = !target.completed;
setTodos(todos); // React 认为没有变化,不会重新渲染
// ✅ 创建全新数组(React 能检测到引用变化)
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed } // 展开运算符创建新对象
: todo
))
}
三种不可变更新的模式速查:
| 操作 | 方法 | 返回值 |
|---|---|---|
| 添加元素到头部 | [newItem, ...array] |
新数组 |
| 修改数组中某一项 | array.map(item => ...) |
新数组 |
| 删除数组中某一项 | array.filter(item => ...) |
新数组 |
| 修改对象某个属性 | { ...obj, key: newValue } |
新对象 |
3.3 父子通信完整模式
React 的父子组件通信遵循严格的**"Props Down, Events Up"**模式:
yaml
┌──────────────────────────────┐
│ App (父组件) │
│ │
│ todos: [状态数据] │
│ addTodo: [修改方法] │
│ │
Props│ │Events
Down │ onAdd ──────────────▶ │ Up
│ │
│ ┌─────────────────────┐ │
│ │ TodoInput (子组件) │ │
│ │ │ │
│ │ inputValue: 本地状态 │ │
│ │ handleSubmit: │ │
│ │ → onAdd(text) ───│────┘
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ TodoList (子组件) │ │
│ │ │ │
│ │ props: todos │ │
│ │ onDelete(id) ───────│────┘
│ │ onToggle(id) ───────│────┘
│ └─────────────────────┘ │
└──────────────────────────────┘
对应到代码中的映射关系:
| 传递方向 | 语法 | 示例 |
|---|---|---|
| Props 向下(数据) | <Child data={value} /> |
<TodoList todos={todos} /> |
| Events 向上(通知) | <Child onAction={handler} /> |
<TodoInput onAdd={addTodo} /> |
| 子组件触发事件 | props.onAction(data) |
onAdd(inputValue) |
| 父组件处理事件 | 更新自己的 state | setTodos([...]) |
3.4 Fragment 组件:减少无意义的 DOM 节点
当你需要返回多个元素但没有父容器时,用 Fragment:
jsx
// ❌ 多了一个无意义的 div
return (
<div>
<h1>Title</h1>
<p>Content</p>
</div>
)
// ✅ Fragment 不会产生真实 DOM 节点
return (
<>
<h1>Title</h1>
<p>Content</p>
</>
)
<>...</> 是 <React.Fragment> 的语法糖,在 DOM 中完全消失,只保留子元素。
四、数据流完整生命周期
以"用户添加一条 Todo"为例,完整追踪数据在组件间的流动:
css
┌─ Step 1: 用户在 input 中输入 "学习 React" ─────────────────┐
│ │
│ TodoInput 组件内部: │
│ onChange → setInputValue("学习 React") │
│ input 的 value 自动更新为 "学习 React" │
│ (这是 TodoInput 的本地状态,与父组件无关) │
└────────────────────────────────────────────────────────────┘
│
▼
┌─ Step 2: 用户点击 Add 按钮 ───────────────────────────────┐
│ │
│ TodoInput 组件内部: │
│ handleSubmit(e) 触发 │
│ → e.preventDefault() // 阻止页面刷新 │
│ → onAdd("学习 React") // 调用 props 传入的回调 │
│ → setInputValue("") // 清空输入框 │
└────────────────────────────────────────────────────────────┘
│
▼
┌─ Step 3: 父组件 App 响应 ─────────────────────────────────┐
│ │
│ App 组件内部: │
│ addTodo("学习 React") 被调用 │
│ → setTodos([ │
│ { id: 1721856000000, text: "学习 React", ... }, │
│ ...todos // 原有的 todos 全部保留 │
│ ]) │
│ → todos 状态更新 │
└────────────────────────────────────────────────────────────┘
│
▼
┌─ Step 4: 所有子组件自动刷新 ─────────────────────────────┐
│ │
│ React 重新渲染: │
│ TodoList 收到新的 todos → 列表多了一项 │
│ TodoStates 收到新的 total/active → 数字更新了 │
│ TodoInput 不受影响 → 输入框保持空 │
└────────────────────────────────────────────────────────────┘
这就是 React 的单向数据流:状态集中在父组件 → 通过 props 分发 → 子组件通过事件通知父组件修改 → 父组件更新状态 → React 自动刷新所有相关子组件。
五、知识图谱
ini
React 组件化开发
├── 核心理念
│ ├── 组件化:UI = 组件树
│ ├── 数据驱动:状态 → UI
│ └── 单向数据流:Props Down, Events Up
│
├── Hooks
│ ├── useState:状态管理
│ │ ├── 基本用法:const [state, setState] = useState(initialValue)
│ │ ├── 懒执行:useState(() => expensiveValue)
│ │ └── 不可变更新:创建新对象/数组
│ └── useEffect:副作用(本文未涉及,下一篇展开)
│
├── 组件通信
│ ├── 父 → 子:Props(数据向下)
│ ├── 子 → 父:自定义事件(通知向上)
│ └── 兄弟组件:通过共同父组件中转
│
├── JSX 语法
│ ├── 表达式插值:{variable}
│ ├── 条件渲染:{condition && <Component />}
│ ├── 列表渲染:{array.map(item => ...)}
│ ├── Fragment:<>...</> 减少无意义 DOM
│ └── className(不是 class)
│
├── 受控组件
│ └── input value + onChange = 状态是唯一数据源
│
└── 开发流程
├── 先画组件树(规划阶段)
├── 确定数据归属(哪个组件持有状态)
├── 定义 Props 和 Events 接口
└── 逐个实现组件
六、总结
通过 TodoList 这个经典案例,我们吃透了 React 组件化开发的五个核心要点:
- 组件树思维 ------ 先规划组件结构,再动手写代码
- 状态归属原则 ------ 数据放在最近的共同父组件中
- Props Down, Events Up ------ 子组件只读不改,通过回调通知
- 不可变更新 ------ 永远创建新状态,不要直接修改
- 受控组件 ------ 表单元素的值由状态控制
React 不只是一个库,更是一种思维方式。当你理解了"状态在哪里、怎么流动、谁来修改",你就真正掌握了 React。
系列文章推荐:
- 《JS执行机制深度解析:变量提升与调用栈》------ 理解 React 底层的 JS 运行机制
- 《JS同步异步与Promise机制深度解析》------ 为后续的 useEffect 做准备
- 《CSS 3D 变换与 Flex 布局实战》------ 补充样式布局能力