前言
本文基于一个使用 React 19 + Vite 8 构建的 Todo 应用,深入探讨 React 中最核心的设计理念:单向数据流 、状态提升 以及子组件通过 props 共享统一数据状态的实践模式。这个项目虽然简洁,却完整地展示了 React 组件化开发的最佳实践。
一、技术栈概览
| 技术 | 版本 | 用途 |
|---|---|---|
| React | 19.2.7 | UI 框架 |
| Vite | 8.1.1 | 构建工具 |
| ESLint | 10.6.0 | 代码规范 |
项目创建:
bash
pnpm create vite/npm init vite
项目创建完成后添加相应子组件(src目录下新建component将组件分别加入3个组件(代码如下)),并将父组件(App.jsx)替换
二、核心架构:单一数据源与状态提升
2.1 组件树结构
整个应用被拆分为四个组件,呈树状结构:
scss
App (数据源 + 所有操作方法)
├── TodoInput (输入框 + 添加按钮)
├── TodoList (任务列表展示)
└── TodoStats (统计信息 + 清除按钮)
父组件
jsx
import{useState} from "react"
import TodoInput from "./components/TodoInput"
import TodoList from "./components/TodoList"
import TodoStats from "./components/TodoStats"
import "./App.css"
const App =()=>{
const[todos,setTodos]=useState([{
id:1,
text:'eat',
completed:true
},
{
id:2,
text:'sleep',
completed:true
},
{
id:3,
text:'study',
completed:true
}
])
// 添加todo的方法,父组件管理
const addTodo=(text)=>{
if(text.trim()==='')return
//传一个全新状态
setTodos([
{id: + Date.now(),text,completed:false},
...todos,
])
}
const toggleTodo=(id)=>{
setTodos(todos.map(todo=>
todo.id===id?{...todo,completed:!todo.completed}
:todo
))
}
const deleteTodo = (id)=>{
setTodos(
todos.filter(todo=>todo.id!==id)
)
}
const clearCompleted=()=>{
setTodos(todos.filter(todo=>!todo.completed))
}
const activeCount=todos.filter(t=>!t.completed).length
const completedCount=todos.length-activeCount
return(
<div>
<h1>My Todo List</h1>
{/* 自定义事件 */}
<TodoInput onAdd={addTodo}/>
<TodoList todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
)
}
export default App
todoinput
jsx
// 表单交互组件
import{useState} from "react"
const TodoInput = ({ onAdd }) => {
console.log(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
TodoList
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)}>Delete</button>
</li>
))
)}
</ul>
);
};
export default TodoList;
TodoStats
jsx
const TodoStats=({
total,
active,
completed,
onClearCompleted
})=>{
return(
<div className="todo-stats">
<p>Total:{total}|Active:{active}|Completed:{completed}</p>
{
completed>0&&(
<button
onClick={onClearCompleted}
className="clear-btn">
Clear Completed
</button>
)
}
</div>
)
}
export default TodoStats
2.2 状态提升 ------ 数据的"唯一真相来源"
这是本文最核心的设计原则。我们来看 App.jsx 中的数据定义:
jsx
const [todos, setTodos] = useState([
{ id: 1, text: 'eat', completed: true },
{ id: 2, text: 'sleep', completed: true },
{ id: 3, text: 'study', completed: true },
]);
关键决策 :todos 状态被定义在顶层 App 组件中,而非分散在各个子组件里。
为什么要这样做?原因有三:
-
单一数据源(Single Source of Truth) :所有组件的渲染数据都来源于同一个
todos数组。不存在"这个组件维护一份数据,那个组件也维护一份"的混乱局面。 -
数据与界面统一 :当
todos发生变化时,React 会自动重新渲染所有依赖该数据的子组件。界面永远是数据的忠实映射 ------ 数据变,界面就变;数据和界面统一,不能出问题。 -
状态修改可控 :子组件不能直接修改
todos,只能通过父组件传递下来的回调函数"报告意图",由父组件统一处理。
2.3 子组件通过 Props 共享统一数据状态
来看看三个子组件如何各取所需:
TodoInput ------ 只拿它需要的
jsx
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState("");
// 当需要报告父组件的时候,执行
const handleSubmit = (e) => {
e.preventDefault();
onAdd(inputValue); // ← 只负责"上报",不碰数据
setInputValue('');
};
// ...
};
TodoInput 不关心 todos 里有什么,它只做一件事:收集用户输入,然后通过 onAdd 回调报告给父组件 。它拥有自己的局部状态 inputValue,但这个状态只服务于输入框本身,不影响全局数据。
TodoList ------ 只读 + 事件上报
jsx
const TodoList = ({ todos, onToggle, onDelete }) => {
return (
<ul className="todo-list">
{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)}>Delete</button> {/* ← 报告删除意图 */}
</li>
))}
</ul>
);
};
TodoList 接收完整的 todos 数据来渲染列表,但它从不直接修改数据 。当用户点击复选框或删除按钮时,它只调用父组件传下来的 onToggle 和 onDelete,把"想做什么"告诉父组件。
TodoStats ------ 派生数据的消费者
jsx
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
注意这里传的不是 todos 本身,而是派生数据 ------ total、active、completed。TodoStats 只需要统计数据,不需要知道每一条 todo 的具体内容。这是一种很好的最小权限原则实践。
三、数据修改流程:严格的单向数据流
整个应用的数据修改遵循一条不可逆的路径:
用户操作 → 子组件回调 → 父组件 setTodos → 状态更新 → React 自动重渲染 → 界面同步
具体来看三个操作:
添加 Todo
jsx
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: +Date.now(), text, completed: false },
...todos,
]);
};
不可变性(Immutability) :不是 todos.push(...) 然后 setTodos(todos),而是创建一个全新的数组。React 通过引用比较来判断状态是否变化 ------ 如果直接修改原数组,React 无法感知变化,界面就不会更新。
切换完成状态
jsx
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
这里用了 map 创建新数组,对于被修改的那一项用展开运算符 { ...todo, completed: !todo.completed } 创建新对象。其余项保持原引用(这没问题,因为它们没变)。
删除 Todo
jsx
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
filter 返回新数组,干净利落。
四、为什么要这样设计?
回到 README 中强调的那句话:
子组件们共享统一(props 提出修改)的数据状态,数据和界面统一的,不能出问题。
这句话概括了 React 组件设计的精髓。让我们展开来讲:
4.1 可预测性
当所有状态修改都发生在 App 组件的几个方法中时,调试变得极其简单 ------ 你只需要在这几个方法里打日志,就能追踪到每一次状态变化。如果状态散落在各个子组件中,当出现 bug 时,你需要逐个排查,心智负担呈指数级增长。
4.2 数据一致性
假设 TodoList 和 TodoStats 各自维护一份 todo 数据。当用户添加一条 todo 时,你需要同时更新两个地方。稍有不慎,列表显示 5 条但统计显示 3 条 ------ 数据和界面就不统一了。
而我们的架构中,todos 只有一个,TodoStats 的统计数据是从 todos 实时派生出来的:
jsx
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;
这些值每次渲染时重新计算,绝不可能出现数据不一致。
4.3 组件可复用
因为子组件不持有全局状态,只依赖 props,它们变得高度可复用。你可以把 TodoInput 拿到另一个项目里,只要那个项目提供一个 onAdd 回调即可。
五、组件通信全景图
yaml
┌─────────────────────────────────┐
│ App (父组件) │
│ │
│ state: todos[] │
│ methods: │
│ addTodo() │
│ toggleTodo() │
│ deleteTodo() │
│ clearCompleted() │
│ derived: activeCount, │
│ completedCount │
└──┬──────────┬─────────────┬─────┘
│ │ │
┌────────────┼──────────┼─────────────┼────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ │
┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ TodoInput │ │ TodoList │ │ TodoStats │ │
│ │ │ │ │ │ │
│ props: │ │ props: │ │ props: │ │
│ onAdd ────┼─┼──> 回调 │ │ total │ │
│ │ │ todos ────┼─┼──> 只读数据 │ active │
│ local: │ │ onToggle ─┼─┼──> 回调 │ completed │
│ inputValue│ │ onDelete ─┼─┼──> 回调 │ onClear ───┼──> 回调
└────────────┘ └────────────┘ └────────────┘ │
│ │
│ 数据流向:父 → 子 (props) │
│ 事件流向:子 → 父 (callbacks) │
└───────────────────────────────────┘
六、总结
这个简单的 Todo 应用展示了一个经典的 React 数据流模式:
| 原则 | 实践 |
|---|---|
| 单一数据源 | todos 状态只在 App 中定义 |
| 状态提升 | 共享的状态放在最近的公共祖先 |
| Props 向下 | 数据通过 props 从父传到子 |
| 回调向上 | 子组件通过回调报告操作意图 |
| 不可变更新 | 永远用新对象/新数组来更新状态 |
| 派生而非复制 | 统计数据从源数据实时计算,不另行存储 |
掌握了这些原则,你就掌握了 React 组件化开发的基石。**子组件们共享统一的数据状态,数据和界面始终保持一致 ** ------ 这不是一句口号,而是通过严谨的单向数据流架构来实现的工程约束。