文章目录
-
- 一、引言
- [二、React 核心知识](#二、React 核心知识)
-
- [2.1 组件化](#2.1 组件化)
- [2.2 响应式与数据驱动](#2.2 响应式与数据驱动)
- [2.3 JSX](#2.3 JSX)
- [2.4 Fragment 组件](#2.4 Fragment 组件)
- [2.5 Hooks](#2.5 Hooks)
-
- [2.5.1 useState](#2.5.1 useState)
- [2.5.2 懒执行](#2.5.2 懒执行)
- [2.5.3 useEffect](#2.5.3 useEffect)
- [2.6 组件通信](#2.6 组件通信)
-
- [2.6.1 父子组件通信](#2.6.1 父子组件通信)
- [2.6.2 组件树](#2.6.2 组件树)
- 三、开发流程和思路
-
- [3.1 组件思想:拿到需求先拆组件](#3.1 组件思想:拿到需求先拆组件)
- [3.2 规划组件树:目录结构决定协作效率](#3.2 规划组件树:目录结构决定协作效率)
- [3.3 父子组件通信实战](#3.3 父子组件通信实战)
-
- [3.3.1 数据模型设计](#3.3.1 数据模型设计)
- [3.3.2 TodoInput:输入与新增](#3.3.2 TodoInput:输入与新增)
- [3.3.3 TodoList:展示、切换与删除](#3.3.3 TodoList:展示、切换与删除)
- [3.3.4 TodoStatus:统计与批量清除](#3.3.4 TodoStatus:统计与批量清除)
- [3.3.5 App:状态管理的核心](#3.3.5 App:状态管理的核心)
- [3.3.6 main.jsx:应用入口](#3.3.6 main.jsx:应用入口)
- 四、数据流动全景图
- 五、全文总结
- 六、核心知识点复盘
- [七、常见问题 / 避坑指南](#七、常见问题 / 避坑指南)
一、引言
Todo 应用是前端学习路上绕不开的经典练手项目。它麻雀虽小、五脏俱全------涵盖了组件化 、状态管理 、数据驱动 、父子通信等 React 最核心的概念。本文将以一个完整的 Todo 应用为主线,从概念到实战,层层递进地讲解 React 的必备知识。
读完本文,你将能够:
- 理解 React 组件化的本质
- 掌握
useState的用法与"懒执行"机制 - 搞懂"数据驱动视图"到底是什么意思
- 独立完成一个带增删改查的 Todo 应用
二、React 核心知识
2.1 组件化
是什么?
组件化就是把一个页面拆成一个个独立、可复用的小块(组件),每个组件负责自己的结构和行为。就像搭乐高------每个积木块各司其职,拼在一起就是一个完整的作品。
为什么要组件化?
- 可维护:页面出问题时,能快速定位到具体组件,不用在几千行代码里大海捞针。
- 可复用:一个按钮组件写好了,整个项目到处都能用。
- 分工协作:不同的人可以并行开发不同组件,互不干扰。
在本文的 Todo 应用中,我们把页面拆成了三个组件:
| 组件 | 职责 |
|---|---|
TodoInput |
输入框 + 添加按钮,负责"新增"交互 |
TodoList |
展示列表,负责"展示"和"勾选/删除" |
TodoStatus |
底部统计栏,负责"计数"和"清除已完成" |
核心原则:一个组件只做一件事。 不要把输入框、列表、统计栏写在一个大组件里,否则逻辑纠缠在一起,改一处牵动全身。
2.2 响应式与数据驱动
这两个概念经常一起出现,但各有侧重。
响应式
响应式的意思是:数据变了,界面自动跟着变,不需要手动操作 DOM。
传统 jQuery 的开发模式是"命令式"的------你要手动找到 DOM 节点,然后 .text() 或 .html() 去改它。React 完全不同:你只管改数据,React 帮你更新界面。
js
// jQuery 时代:手动改 DOM
$('#count').text(5);
// React:改数据,界面自动更新
setCount(5);
数据驱动(单向数据绑定)
数据驱动指界面由数据决定。只要数据一致,渲染出来的界面就一致。
在 React 中,数据流是单向的:
数据(state) → 视图(JSX) → 用户操作 → 更新数据 → 视图自动更新
这个单向流动让数据变化可追踪、可预测 ,是 React 应用稳定性的基石。与之对比,Vue 的双向绑定(v-model)虽然写起来方便,但数据流在复杂场景下可能变得难以追踪。
一句话总结:React 中,UI = f(state)。界面就是数据的函数,同样的输入必然产生同样的输出。
2.3 JSX
JSX 看起来像 HTML,但本质上是 JavaScript 的语法糖。
jsx
// JSX 写法
<h1 className="title">Hello</h1>
// Babel 编译后等价于:
React.createElement('h1', { className: 'title' }, 'Hello');
关键规则:
- 必须有一个根节点(或使用 Fragment,后面会讲)。
{}里可以写任何 JS 表达式 ,包括三元运算、map、函数调用等。class要写成className,for要写成htmlFor------因为class和for是 JS 的保留字。- 布尔值、
null、undefined不会被渲染,可以利用这个特性做条件渲染。
jsx
// 条件渲染示例:completed > 0 时才显示按钮
{completed > 0 && (
<button onClick={onClearCompleted}>Clear Completed</button>
)}
2.4 Fragment 组件
前面提到 JSX 必须有一个根节点。如果你不想在 DOM 中多套一层 <div>,就用 Fragment:
html
// ❌ 多余的 div 包裹,影响 CSS 布局
return (
<div>
<h1>Title</h1>
<p>Content</p>
</div>
);
// ✅ Fragment 不会在 DOM 中生成任何节点
import { Fragment } from 'react';
return (
<Fragment>
<h1>Title</h1>
<p>Content</p>
</Fragment>
);
// ✅ 更简洁的短语法(无需 import)
return (
<>
<h1>Title</h1>
<p>Content</p>
</>
);
在 Todo 应用中,App 组件的 return 就用 <div> 包裹了三个子组件。如果你想用 Fragment 替换,也是完全可以的------这取决于你的 CSS 布局是否依赖外层容器。
2.5 Hooks
Hooks 是 React 16.8 引入的能力,让函数组件也能拥有状态和生命周期。本文重点讲 useState,后续会补充 useEffect 的场景。
2.5.1 useState
useState 是函数组件的"记忆体"------它让组件能记住数据,并且数据变化时自动重新渲染。
js
const [state, setState] = useState(初始值);
state:当前状态值(只读,不能直接修改)。setState:更新状态的函数(调用它才会触发重新渲染)。初始值:组件首次渲染时的默认值。
关键规则:
- 不可变更新(Immutable Update) :必须用
setState传入一个全新的值/对象/数组,不能直接修改原状态。
js
// ❌ 错误:直接 push 不会触发渲染
todos.push(newTodo);
// ✅ 正确:用展开运算符创建新数组
setTodos([...todos, newTodo]);
-
状态更新是异步的 :在同一个事件处理函数中连续调用
setState,React 会批量处理,只触发一次渲染。 -
用函数式更新避免闭包陷阱:当新状态依赖旧状态时,推荐传函数而非直接传值。
js
// ❌ 连续调用可能读到旧的 count
setCount(count + 1);
setCount(count + 1); // 两次调用都基于同一个旧值
// ✅ 函数式更新确保每次拿到最新值
setCount(prev => prev + 1);
setCount(prev => prev + 1);
在 Todo 应用中,每个增删改操作都严格遵循了不可变原则:
js
// 添加:展开旧数组 + 新对象
setTodos([...todos, { id: +Date.now(), text, completed: false }]);
// 修改:map 返回新数组,匹配的项用展开创建新对象
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
// 删除:filter 返回新数组
setTodos(todos.filter(todo => todo.id !== id));
为什么
{ ...todo, completed: !todo.completed }没有两个completed?对象属性名是唯一的。展开
...todo先把completed: false写入,紧接着completed: !todo.completed用新值覆盖 了它。最终只有一个completed,值是新算出来的那个。这是 React 里切换布尔状态的经典写法。
2.5.2 懒执行
useState 的初始值只在组件首次渲染时 计算一次。如果初始值需要复杂计算(比如从 localStorage 读取并解析 JSON),直接写在 useState() 里会在每次渲染时都执行计算(虽然结果只取第一次)。
js
// ❌ 每次渲染都会执行 JSON.parse(浪费性能)
const [todos, setTodos] = useState(
JSON.parse(localStorage.getItem('todos')) || []
);
// ✅ 传函数:只在首次渲染时执行一次
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
这就是 懒初始化(Lazy Initialization) ------传一个函数给 useState,React 只在首次渲染时调用它,后续渲染直接跳过。
适用场景: 初始值计算成本较高(如
localStorage读写、复杂数据转换)时才需要用懒初始化。像useState(0)或useState('')这种简单值,完全不需要。
2.5.3 useEffect
虽然本文的 Todo 应用没有用到 useEffect,但这是一个必须了解的核心 Hook。
useEffect 让你在函数组件中执行副作用------即那些不能直接在渲染阶段做的事,比如:请求 API 数据、操作 DOM、设置定时器、订阅事件。
js
useEffect(() => {
// 副作用逻辑(组件渲染后执行)
console.log('组件挂载或更新了');
return () => {
// 清理函数(组件卸载前 / 下次 effect 执行前运行)
console.log('清理工作');
};
}, [依赖项]); // 依赖项数组
依赖项数组控制 effect 的触发时机:
| 依赖项 | 触发时机 |
|---|---|
[] 空数组 |
只在组件挂载 时执行一次(类似 componentDidMount) |
[a, b] |
挂载时 + 每次 a 或 b 变化时执行 |
| 不传第二个参数 | 每次渲染后都执行(很少用,容易造成死循环) |
常见踩坑:
setState→ 组件重渲染 →useEffect再次触发 → 又setState→ 死循环。务必正确设置依赖项。
2.6 组件通信
2.6.1 父子组件通信
这是 React 中最基础、最重要的通信模式。记住两条铁律:
-
父 → 子:通过
props传递数据。 -
子 → 父:子组件不能直接修改父组件的状态。 只能通过父组件传下来的回调函数来"报告"父组件,让父组件自己决定怎么改。
父组件 App(持有 todos 状态)
│
├── props(数据下发)→ TodoList(展示列表)
├── props(数据下发)→ TodoStatus(展示统计)
│
└── props(回调下发)→ TodoInput(用户输入后回调 addTodo)
为什么子组件不能直接改父组件的状态?
这是 React 的单向数据流 原则。如果每个子组件都能随意修改父组件的数据,数据变化就变得不可追踪。统一由父组件管理状态,确保了数据流是可预测的。
在 Todo 应用中,整个通信关系非常清晰:
jsx
// App.jsx ------ 父组件持有状态,下发数据和回调
const App = () => {
const [todos, setTodos] = useState([...]);
const addTodo = (text) => { /* 添加逻辑 */ };
const toggleTodo = (id) => { /* 切换完成状态 */ };
const deleteTodo = (id) => { /* 删除逻辑 */ };
const clearCompleted = () => { /* 清除已完成 */ };
return (
<div>
{/* TodoInput 只拿回调,不需要数据 */}
<TodoInput onAdd={addTodo} />
{/* TodoList 拿数据 + 回调 */}
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
{/* TodoStatus 拿统计数据 + 回调 */}
<TodoStatus
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
};
2.6.2 组件树
组件树就是把组件之间的嵌套关系画成一棵树。理解组件树对后续学习很重要,因为:
- 状态提升:多个子组件需要共享同一份数据时,把状态放到它们最近的共同父组件上。
- props drilling:当组件层级很深时,props 一层层传递会很痛苦------这是后续学习 Context 或状态管理库的动机。
- 调试:React DevTools 就是按组件树展示的。
本文 Todo 应用的组件树:
App
├── TodoInput
├── TodoList
└── TodoStatus
层级很浅,不需要 Context。当你的组件树长到 5 层、10 层时,自然会感受到为什么要学 Context API。
三、开发流程和思路
3.1 组件思想:拿到需求先拆组件
拿到一个 Todo 应用的需求,不要一上来就写代码。先思考:
- 页面上有哪些"块"? → 输入区、列表区、状态区。
- 每个块需要什么数据? → 列表需要
todos数组,状态区需要计数。 - 数据存在哪里? → 三个组件都需要
todos,所以状态放在共同父组件App中------这叫状态提升。
这个"拆 → 分析 → 决定数据归属"的思考流程,就是组件化开发的核心思维。
3.2 规划组件树:目录结构决定协作效率
合理的目录结构不仅让自己思路清晰,在团队协作中(尤其是使用 AI 辅助编码时)也能让 AI 更准确地定位文件、理解项目上下文。
src/
├── App.jsx # 根组件,管理全局状态
├── App.css # 根组件样式
├── main.jsx # 应用入口
├── index.css # 全局样式
└── components/
├── TodoInput.jsx # 输入组件
├── TodoList.jsx # 列表组件
└── TodoStatus.jsx # 状态栏组件
命名建议: 组件文件用 PascalCase(大驼峰),一眼就知道这是组件;工具函数文件用 camelCase(小驼峰)。
3.3 父子组件通信实战
3.3.1 数据模型设计
todos 是一个 JSON 数组,每个元素包含三个字段:
js
{
id: 1, // 唯一标识,用于 React key 和增删改定位
text: '吃饭', // todo 文本内容
completed: false // 是否完成
}
这个数据模型是 App 组件的唯一数据源(Single Source of Truth) ,所有子组件展示的内容都派生于此。确保数据和界面完全对应 ------如果界面上显示 3 条,那么 todos.length 也一定是 3,绝不对不上。
3.3.2 TodoInput:输入与新增
javascript
// components/TodoInput.jsx
import { useState } from 'react';
const TodoInput = ({ onAdd }) => {
// 输入框的值是"局部状态"------只有这个组件自己关心
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // 阻止表单默认提交(页面刷新)
if (inputValue.trim() === '') return; // 空内容不添加
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;
关键点解析:
inputValue是局部状态 ------只有TodoInput自己需要,父组件完全不关心用户打了什么字,只关心最终提交的内容。- 受控组件模式:
<input>的value绑定到 state,onChange更新 state。React 成为数据的"唯一真实来源",输入框的值完全由 React 控制。 e.preventDefault():表单内的按钮默认type="submit"会触发页面刷新,必须阻止。
3.3.3 TodoList:展示、切换与删除
javascript
// components/TodoList.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}:React 用key来追踪列表中的每一项,提高 diff 效率。永远不要用数组索引当 key,否则在列表增删时可能导致渲染错乱。todo.completed ? 'completed' : '':通过类名切换样式,展示已完成/未完成的不同外观。onChange={() => onToggle(todo.id)}:这里不能用onChange={onToggle(todo.id)}------后者会在渲染时立即执行,而不是等用户点击。用箭头函数包装一层,延迟到事件触发时才调用。
⚠️ 经典陷阱:箭头函数
{}vs()
jsx// ❌ 错误:{} 是函数体,没有 return 语句 → map 返回 undefined todos.map(todo => { <li key={todo.id}>{todo.text}</li> }); // ✅ 正确:() 包裹 JSX,隐式返回 todos.map(todo => ( <li key={todo.id}>{todo.text}</li> )); // ✅ 也可以:{} 配合显式 return todos.map(todo => { return <li key={todo.id}>{todo.text}</li>; });这是新手写 React 最容易犯的错误之一。当你用
{}写箭头函数体时,JSX 只是一条"孤立的表达式语句",不会被返回。必须用()包起来实现隐式返回,或者写return。
3.3.4 TodoStatus:统计与批量清除
javascript
// components/TodoStatus.jsx
const TodoStatus = ({
total,
active,
completed,
onClearCompleted
}) => {
return (
<div className="todo-status">
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{completed > 0 && (
<button className="clear-btn" onClick={onClearCompleted}>
Clear Completed
</button>
)}
</div>
);
};
export default TodoStatus;
关键点解析:
{completed > 0 && <button>...</button>}:利用短路求值做条件渲染。只有当completed > 0时按钮才显示,否则渲染false(React 会忽略)。TodoStatus不接收完整的todos数组,只接收它需要的派生数据 (total、active、completed)。这是"最小权限原则"在组件 props 设计中的体现------组件只知道它需要知道的。
3.3.5 App:状态管理的核心
javascript
// App.jsx
import { useState } from 'react';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
import TodoStatus from './components/TodoStatus';
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([...todos, { id: +Date.now(), text, completed: false }]);
};
// 切换完成状态:map 返回新数组,匹配项创建新对象
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 删除:用 filter 排除指定 id
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 清除已完成:保留 completed 为 false 的项
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// 派生数据:不需要额外的 state,直接从 todos 计算
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.length - activeCount;
return (
<div>
<h1>My Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStatus
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
};
export default App;
核心思路复盘:
- 状态提升 :
todos放在App中,因为三个子组件都需要它。 - 派生数据不存 state :
activeCount和completedCount直接从todos计算即可。如果你把它们也存成 state,每次修改todos时都要同步更新它们------数据多了一份,bug 也多了一个来源。 - 不可变更新贯穿始终 :
map、filter、展开运算符...都返回新数组/新对象,从来不会直接修改原数据。
3.3.6 main.jsx:应用入口
js
// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
这里主要关注 <StrictMode>------它是 React 的开发模式组件,不会影响生产构建,但在开发时会:
- 检测意外的副作用(故意执行两次组件渲染函数)。
- 检测过时的 API 使用。
- 在控制台给出更明确的警告信息。
如果你在开发时发现
console.log被打印了两次,别慌------这是 StrictMode 干的,不是 bug。
四、数据流动全景图
把整个应用的数据流动串起来,形成一张清晰的"数据回路":
用户输入文本 → TodoInput 调用 onAdd(text)
↓
App.addTodo 执行 → setTodos 更新状态
↓
React 检测到 todos 变化 → 重新渲染 App
↓
App 将新的 todos 通过 props 下发
↙ ↓ ↘
TodoInput TodoList TodoStatus
(无影响) (列表刷新) (统计刷新)
整个流程是单向的、可预测的 ------数据从 App 流出,经过各个子组件展示,用户操作通过回调回到 App,App 修改状态后又重新流向子组件。这个循环就是 React 应用的最基本运行模型。
五、全文总结
本文以 Todo 应用为载体,完整走通了 React 开发的一个标准流程:
- 先思考后动手:拆组件 → 定数据归属 → 设计通信关系。
- 状态提升:多个组件共享的数据放到最近的共同父组件。
- 不可变更新 :永远通过
setState传新值,不直接修改原状态。 - 单向数据流:数据从父组件通过 props 流到子组件;子组件通过回调把"意图"传回父组件。
六、核心知识点复盘
| 知识点 | 一句话总结 |
|---|---|
| 组件化 | 拆成独立小块,各司其职,可复用可维护 |
| JSX | HTML 写在 JS 里,最终编译成 createElement 调用 |
| 单向数据流 | 数据从父到子,不可反向直接修改 |
useState |
函数组件的"记忆",改数据触发重渲染 |
| 不可变更新 | map/filter/... 创建新值,不动原值 |
| 懒初始化 | useState(() => heavyCalc()) 避免重复计算 |
| 父子通信 | 父传 props,子传回调,数据始终由父管理 |
| 派生数据 | 能从 state 算出来的值,不要再存一个 state |
key |
列表渲染的稳定标识,不能用索引 |
| Fragment | 不产生 DOM 节点的包裹元素,<>...</> |
七、常见问题 / 避坑指南
- 为什么我改了数据,界面不更新?
原因几乎都是:直接修改了原状态,没有用 setState。
js
// ❌ 直接修改,React 不知道数据变了
todos.push(newTodo);
// ✅ 创建新数组,React 检测到引用变化,触发渲染
setTodos([...todos, newTodo]);
React 用 Object.is 比较新旧状态------数组引用没变,它就认为没变。请务必使用不可变更新的方式。
- map 里写了 JSX 但页面上什么也不显示?
检查箭头函数是否用了 {} 而不是 ():
jsx
// ❌ {} 没有 return,每个元素返回 undefined
todos.map(todo => { <li>...</li> })
// ✅ () 隐式返回 JSX
todos.map(todo => ( <li>...</li> ))
- 用了索引当 key 会怎样?
jsx
// ❌ 用索引当 key
todos.map((todo, index) => <li key={index}>...</li>)
列表增删时,索引会变化,React 可能复用了错误的 DOM 节点,导致输入框内容错位、动画异常等问题。始终用稳定且唯一的值当 key (如 todo.id)。
setState后立即读取 state 还是旧值
js
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // 还是 0!不是 1!
};
状态更新是异步的,在新一次渲染之前,count 的值不会变。如果需要基于新值做操作,用函数式更新或 useEffect 监听。
- 表单提交时页面刷新了
检查是否在 handleSubmit 中调用了 e.preventDefault()。表单内按钮的默认 type 是 submit,点击会触发页面刷新。
- StrictMode 下 console.log 打印两次
正常现象。StrictMode 在开发模式下会故意重复调用组件函数来帮你发现副作用问题,打包到生产环境后不会有这个行为。
本文基于 React 18 + Vite 构建的 Todo 应用实战编写,完整覆盖了组件化、状态管理、数据驱动和父子通信等核心概念,适合 React 初学者作为系统性学习参考。