今天我花了几个小时,从零开始搭建了一个 React + Vite + Stylu 的 Todo 应用。这个看似简单的待办事项列表,其实藏着 React 开发中最核心的两个概念:
- 组件之间的通信机制
- 状态(State)的统一管理和响应式更新
别看它功能简单 —— 添加任务、完成任务、删除任务、统计数量、清空已完成……但正是这些“基础操作”,构成了我们日常开发中绝大多数交互逻辑的骨架。
这篇文章,我会带你一步步拆解这个 Todo 应用,不讲花哨术语,只讲真正能落地的知识点。如果你是 React 初学者,或者对“父子组件怎么传数据”、“兄弟组件如何协作”还一头雾水,那这篇文一定适合你。
一、技术栈选择:为什么是 React + Vite?
先简单说下项目结构:
├── App.jsx
├── components/
│ ├── TodoInput.jsx
│ ├── TodoList.jsx
│ └── TodoStats.jsx
└── styles/
└── app.jsx
使用的工具链如下:
- React:构建用户界面的声明式 JavaScript 库。
- Vite:新一代前端构建工具,启动快如闪电,热更新几乎无延迟。
- Stylus:CSS 预处理器之一,语法简洁灵活,支持嵌套写法,比原生 CSS 更高效。
虽然现在很多人用 Tailwind 或 Sass,但我想告诉你:掌握一种预处理器的本质比追逐潮流更重要。Stylus 的嵌套和变量机制,已经足够让我们写出清晰可维护的样式代码。
二、父组件:App.jsx —— 数据的“中央仓库”
在 React 中,有一个非常重要的原则:
数据流向是单向的,状态提升到共同祖先。
什么意思?来看我们的 App 组件:
export default function App() {
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// 添加新任务
const addTodo = (text) => {
setTodos([...todos, {
id: Date.now(),
text,
completed: false,
}]);
};
// 删除任务
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 切换完成状态
const onCompleted = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 清除已完成
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// 统计未完成/已完成数量
const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
// 持久化存储
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
return (
<div className="todo-app">
<h1>My Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onDelete={deleteTodo}
onCompleted={onCompleted}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
}
关键点解析
✅ 1. 状态集中管理(Single Source of Truth)
所有的 todos 数据都保存在 App 组件中,子组件不能直接修改它。这就像一个公司的“人事档案室”,只有 HR(父组件)可以改档案,员工(子组件)只能提交申请。
✅ 2. 自定义事件传递(Prop Drilling)
父组件把修改方法通过 props 传给子组件:
<TodoInput onAdd={addTodo} />
这里的 onAdd 就是一个“自定义事件”。当用户输入并回车时,TodoInput 调用这个函数,相当于向上级“汇报工作”。
这种模式叫做 “回调提升(Callback Lifting)” —— 子组件触发行为,父组件处理逻辑。
✅ 3. 使用 useEffect 实现持久化
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
只要 todos 变化,就自动保存到本地存储。下次打开页面还能恢复数据 —— 用户体验直接拉满!
三、子组件详解:各司其职,协同作战
1. TodoInput.jsx:负责“新增任务”
export default function 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)}
/>
<button>Add</button>
</form>
);
}
核心知识点:
- 受控组件(Controlled Component):
value由 React 状态控制,每次输入都会触发onChange更新状态。 - 阻止默认行为:表单提交会刷新页面,必须调用
e.preventDefault()。 - 防空提交:检查是否为空字符串,避免无效数据污染状态。
💡 这里有个常见误区:有人喜欢用 ref 直接操作 DOM 获取值。但在 React 中,我们应该优先使用状态驱动视图,而不是反过来。
2. TodoList.jsx:展示任务列表
export default function TodoList({ todos, onDelete, onCompleted }) {
return (
<ul className="todo-list">
{todos.length === 0 ? (
<li className="empty">暂无待办事项</li>
) : (
todos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onCompleted(todo.id)}
/>
<span>{todo.text}</span>
</label>
<button onClick={() => onDelete(todo.id)}>X</button>
</li>
))
)}
</ul>
);
}
重点说明:
key属性的重要性:React 需要key来识别每个元素的身份,确保高效的 diff 算法。这里用Date.now()虽然简单,但在高并发场景可能重复,生产环境建议使用nanoid或uuid。- 条件渲染:没有任务时显示提示信息,提升用户体验。
- 类名动态绑定:根据
completed状态添加.completed类,方便样式控制。
// app.styl 示例
li.completed span
text-decoration line-through
color #aaa
3. TodoStats.jsx:统计面板
export default function TodoStats({ total, active, completed, onClearCompleted }) {
return (
<div>
<p>Total: {total} | Active: {active} | Completed: {completed}</p>
{completed > 0 && (
<button onClick={onClearCompleted} className="clear-btn">
Clear Completed
</button>
)}
</div>
);
}
巧妙之处:
- 按需渲染清除按钮:只有存在已完成任务时才显示“清空”按钮,避免多余操作入口。
- 语义化展示:让用户一眼看清当前任务分布情况。
四、组件通信全解析:父子、子父、兄弟之间如何“对话”?
这是本文最核心的部分。很多新手搞不清“谁该管什么”,导致状态混乱、组件难以复用。
🔄 三种通信方式总结
| 类型 | 方式 | 示例 |
|---|---|---|
| 父 → 子 | Props 传递数据 | <TodoList todos={todos} /> |
| 子 → 父 | 回调函数(自定义事件) | <TodoInput onAdd={addTodo} /> |
| 兄弟 ↔ 兄弟 | 通过共同父级中转 | TodoInput 修改状态 → TodoList 自动更新 |
🧠 举个生活化的例子:
想象你在公司写周报:
- 你(子组件) 写完内容后点击【提交】
- 触发的是 主管(父组件) 的审批流程
- 主管处理完后,通知财务(另一个子组件)准备报销
你看,你并没有直接联系财务,而是通过主管协调。这就是典型的“兄弟组件通信靠父级”。
五、最佳实践建议:写出更健壮的 React 代码
-
✅ 状态尽可能提升到需要共享它的最近公共父组件
不要把
todos放在TodoList里!否则TodoStats就拿不到数据了。 -
✅ 方法命名要有意义
onAdd表示“即将添加”onDelete表示“将要删除”- 前缀
on是约定俗成的习惯,表示这是一个回调
-
✅ 避免不必要的 re-render
当前实现中,每次
todos改变,所有子组件都会重新渲染。未来可以用React.memo优化性能。 -
✅ 错误边界和边界情况处理
- 输入为空时不提交
- 列表为空时友好提示
- ID 生成尽量唯一(可用
crypto.randomUUID()替代Date.now())
-
✅ 本地存储加异常处理(进阶)
useEffect(() => { try { localStorage.setItem('todos', JSON.stringify(todos)); } catch (err) { console.error('Failed to save todos:', err); } }, [todos]);
六、结语:小应用,大智慧
这个 Todo 应用虽然只有不到 200 行代码,但它涵盖了 React 开发中最基础也最重要的思想:
- 单向数据流
- 组件职责分离
- 状态提升
- 回调函数实现反向通信
- 副作用管理(useEffect)
- 本地持久化
你可以把它当作你的“React 入门样板工程”。每当你想练习一个新的概念(比如 Context、Redux、useReducer),都可以基于它进行改造。
❤️ 如果你觉得有收获…
不妨点赞 + 收藏 + 关注,让更多人看到这份“接地气”的 React 教程。
也欢迎留言讨论:你是怎么管理复杂状态的?有没有遇到过组件通信的坑?我们一起交流进步!
📌 记住一句话:伟大的应用,都是从一个小小的 Todo 开始的。