从零开始,用 React 19 + Vite 8 搭建一个完整的待办事项应用。
目录
- 我们要做什么
- 项目初始化
- 先画组件树再写代码
- [入口文件:React 是怎么跑起来的](#入口文件:React 是怎么跑起来的 "#4-%E5%85%A5%E5%8F%A3%E6%96%87%E4%BB%B6react-%E6%98%AF%E6%80%8E%E4%B9%88%E8%B7%91%E8%B5%B7%E6%9D%A5%E7%9A%84")
- [根组件 App:状态的"大本营"](#根组件 App:状态的"大本营" "#5-%E6%A0%B9%E7%BB%84%E4%BB%B6-app%E7%8A%B6%E6%80%81%E7%9A%84%E7%9A%84%E5%A4%A7%E6%9C%AC%E8%90%A5")
- TodoInput:输入框与受控组件
- TodoList:列表渲染与事件冒泡
- TodoStatus:派生状态与条件渲染
- 复盘:数据是怎么流动的
- 进阶思考:这个项目还可以怎么优化
1. 我们要做什么
一个经典 TodoList,功能很简单:
- 输入文字 + 回车,新增一条待办事项
- 点击 checkbox 切换"已完成 / 未完成"
- 点击删除按钮移除某条
- 底部显示统计:总计 / 进行中 / 已完成
- 一键清除所有已完成项
功能不复杂,但它几乎涵盖了 React 入门阶段所有核心概念:JSX、组件化、useState、props、受控组件、条件渲染、列表渲染、状态提升、不可变更新。
学完这一个项目,React 的基本功就扎实了。
2. 项目初始化
bash
# 用 Vite 脚手架创建一个 React 项目
npm create vite@latest todolist -- --template react
cd todolist
npm install
创建完你会看到这样的目录结构:
bash
todolist/
├── index.html # ← Vite 的入口 HTML
├── package.json
├── vite.config.js
└── src/
├── main.jsx # ← React 的挂载入口
├── App.jsx # ← 根组件(我们要重点写的)
├── App.css
└── index.css # ← 全局样式
核心依赖:
react和react-dom(版本 19)。Vite 负责打包和开发服务器,@vitejs/plugin-react负责编译 JSX。
3. 组件树
分析一下页面,你能看到三个区域:
ini
┌──────────────────────────────────┐
│ My Todo List │ ← 标题
├──────────────────────────────────┤
│ [________________] [Add] │ ← 输入区域 → TodoInput
├──────────────────────────────────┤
│ ☑ 吃饭 │
│ ☐ 睡觉 │ ← 列表区域 → TodoList
│ ☐ 打卡 [删除]│
├──────────────────────────────────┤
│ Total:3 | Active:2 | Done:1 │ ← 状态栏 → TodoStatus
│ [Clear Completed] │
└──────────────────────────────────┘
对应的组件树:
App(根组件)
├── TodoInput ← 负责输入
├── TodoList ← 负责展示列表
└── TodoStatus ← 负责统计和清除
目录结构也随之确定:
css
src/
├── App.jsx ← 根组件
├── commpents/ ← 子组件目录
│ ├── TodoInput.jsx
│ ├── TodoList.jsx
│ └── TodoStatus.jsx
设计原则: 一个组件只做一件事。输入归输入管,列表归列表管,统计归统计管。各司其职,互不越界。
4. 入口文件:React 是怎么跑起来的
index.html
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>todolist</title>
</head>
<body>
<div id="root"></div> <!-- ← 挂载点 -->
<script type="module" src="/src/main.jsx"></script> <!-- ← 入口脚本 -->
</body>
</html>
Vite 和 Webpack 不同 ------ 它直接把 index.html 当入口,不需要插件来生成。<div id="root"> 是 React 的"锚点",整个应用都会渲染进这个 div。
main.jsx
jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
这五行代码做的事:
createRoot(domNode)------ 在#root上创建一个 React 根节点(React 18 新增的 API,替代老版的ReactDOM.render).render(<App />)------ 把<App />组件渲染进去<StrictMode>------ 开发模式下的"严格检查",它会故意双重调用某些函数来帮你发现潜在 bug(比如副作用没清理干净)
新手常见疑问:
document.getElementById('root')能找到元素吗?能。因为
<script type="module">默认是defer的 ------ 浏览器会等 DOM 构建完再执行它。
5. 根组件 App:状态的"大本营"
这是整个应用的大脑。它持有核心数据,定义所有修改逻辑。
5.1 定义数据结构
jsx
const [todos, setTodos] = useState([
{ id: 1, text: '吃饭', completed: false },
{ id: 2, text: '睡觉', completed: false },
{ id: 3, text: '打卡', completed: true },
]);
为什么用对象数组? 因为每条待办需要三个信息:
| 字段 | 类型 | 作用 |
|---|---|---|
id |
number | 唯一标识(修改/删除时靠它定位) |
text |
string | 待办文字 |
completed |
boolean | 是否已完成 |
useState(初始值)返回一个数组[当前值, 更新函数]。React 规定:永远不要直接修改 state,必须通过 set 函数传入新值。
5.2 不可变更新 ------ 三种核心操作
这是 React 最重要的编码习惯,必须刻进肌肉记忆:
jsx
// ──── 新增:展开运算符 + 新对象 ────
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 ────
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
为什么不能
todos[0].completed = true直接改?React 靠引用对比 (
oldState === newState)判断要不要重新渲染。你改了对象内部属性,引用没变,React 不知道数据变了,界面就不会更新。
5.3 派生状态:能算就不存
jsx
// ✅ 直接计算,不需要 useState
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;
新手常见错误:
jsx
// ❌ 多余的 state,需要手动同步,容易出错
const [activeCount, setActiveCount] = useState(0);
const [completedCount, setCompletedCount] = useState(0);
原则: 如果 B 能通过 A 纯计算得出,就不要给 B 单独设 state。少一个 state,就少一个同步 bug 的可能。
5.4 组装组件
jsx
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}
onClear={clearCompleted}
/>
</div>
);
观察 props 的命名规律:
| Props | 方向 | 含义 |
|---|---|---|
todos、total、active、completed |
父 → 子 | 把数据"下发"给子组件展示 |
onAdd、onToggle、onDelete、onClear |
子 → 父 | 子组件"上报"事件,父组件响应 |
on 前缀是 React 社区约定 ------ 表示"当某事发生时回调我"。
6. TodoInput:输入框与受控组件
jsx
import { useState } from 'react';
const TodoInput = ({ onAdd }) => {
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // 阻止表单默认提交(刷新页面)
const trimmed = inputValue.trim();
if (!trimmed) return; // 空内容不提交
onAdd(trimmed); // 通知父组件
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>
);
};
6.1 为什么要拆出一个独立组件?
inputValue(输入框里的文字)只有 TodoInput 自己关心。父组件 App 不需要知道用户正在输入什么 ------ 它只需要知道用户"确认提交"的那一刻,用户到底输入了什么。
这就是状态归属的判断标准:谁用,谁持有。
6.2 什么是"受控组件"(Controlled Component)?
jsx
<input
value={inputValue} // ← 值由 React state 决定
onChange={(e) => setInputValue(e.target.value)} // ← 用户输入 → 更新 state
/>
这个 input 是 "受控" 的 ------ 它的值完全由 React state 驱动,不是 DOM 自己维护的。
为什么这样做? 因为 React 变成了"唯一的数据源"(Single Source of Truth)。任何时候你想拿到输入框的值,读 inputValue 就行,不用去操作 DOM。
对比非受控组件:
<input ref={myRef} />通过 ref 直接读 DOM 值 ------ 偶尔有用,但绝大多数场景受控组件更好。
6.3 e.preventDefault() 是干嘛的?
<form> 的默认行为:点击 type="submit" 的按钮时,浏览器会向 action 地址发送请求并刷新页面。
在 SPA(单页应用)里我们不需要这个 ------ 一切都在浏览器端完成。e.preventDefault() 阻止了默认行为,让表单提交只走我们的 JavaScript 逻辑。
7. 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>
);
};
7.1 key 为什么必须加?
jsx
{todos.map(todo => (
<li key={todo.id}> {/* ← 这个 key 不能省 */}
...
</li>
))}
React 用 key 来追踪列表里每一项的身份。没有 key(或用 index 当 key),删除第一项时 React 可能会搞混,导致:
- 状态错乱(checkbox 的选中状态跑到别的项上)
- 不必要的 DOM 重建(性能浪费)
key 的规则:
- 必须是稳定且唯一 的(用
todo.id,别用Math.random()) - 不要用数组 index 当 key(除非列表永远不会增删重排)
7.2 两种事件通知方式的对比
注意 checkbox 和删除按钮的写法不同:
jsx
{/* 方式1:onChange 里包一层箭头函数 */}
<input type="checkbox" onChange={() => onToggle(todo.id)} />
{/* 方式2:onClick 里包一层箭头函数 */}
<button onClick={() => onDelete(todo.id)}>删除</button>
为什么不能直接写 onChange={onToggle}?
因为 onToggle 需要参数 id。如果你写 onChange={onToggle},React 会把事件对象 e 传进去,而不是 id。用箭头函数 () => onToggle(todo.id) 创建一个闭包,把正确的 id 传进去。
7.3 受控的 Checkbox
jsx
<input
type="checkbox"
checked={todo.completed} // ← 选中状态由数据决定
onChange={() => onToggle(todo.id)} // ← 点击时通知父组件切换
/>
这个 checkbox 也是受控组件 ------ 它的 checked 状态完全由 todo.completed 决定。用户点击 → 触发 onToggle → 父组件修改 todos → 子组件重新渲染 → checkbox 状态更新。
数据单向流动,环形闭环。
7.4 空状态处理
jsx
{todos.length === 0 ? (
<li className="empty">No todos yet</li>
) : (
todos.map(...)
)}
好的 UI 要处理"没有数据"的状态。一句话就能让用户体验好很多 ------ 而不是显示一个空荡荡的白屏。
8. TodoStatus:派生状态与条件渲染
jsx
const TodoStatus = ({ total, active, completed, onClear }) => {
return (
<div className="todo-status">
<p>Total:{total} | Active:{active} | Completed :{completed}</p>
{completed > 0 && (
<button className="clear-btn" onClick={onClear}>
Clear Completed
</button>
)}
</div>
);
};
8.1 为什么 TodoStatus 不需要自己的 state?
它只负责展示 ,不负责计算 。total、active、completed 这三个数字已经在父组件 App 里从 todos 计算好了,直接通过 props 传进来。
这是"展示型组件"(Presentational Component) 的典型特征 ------ 没有自己的状态,只负责把 props 渲染成 UI。
8.2 条件渲染的细节
jsx
{completed > 0 && (
<button onClick={onClear}>Clear Completed</button>
)}
为什么写 completed > 0 而不是 completed?
因为 JavaScript 的短路求值:
jsx
{0 && <Button />} // → 渲染出 "0"(数字 0 是 falsy,但会被 React 渲染)
{false && <Button />} // → 什么都不渲染(false 不会被渲染)
{null && <Button />} // → 什么都不渲染
0 是一个合法的 React 节点,会在页面上显示一个 "0"。用 completed > 0 确保表达式结果是 true 或 false,不会出现意外。
9. 复盘:数据是怎么流动的
让我们追踪一个完整的用户操作 ------新增一条 todo:
scss
用户输入 "买菜" 并回车
│
▼
TodoInput 的 handleSubmit 被调用
│
├── e.preventDefault() ← 阻止表单默认提交
├── onAdd("买菜") ← 调用父组件传来的回调
└── setInputValue('') ← 清空自己的输入框
│
▼
App 的 addTodo("买菜") 被调用
│
├── 创建新对象 { id: 1700000001, text: "买菜", completed: false }
├── setTodos([新对象, ...旧数组]) ← 触发重新渲染
│
▼
React 重新渲染 App,以及所有接收 todos 的子组件
│
├── TodoInput: 没有收到变化的 props,但自身 state 没变,跳过
├── TodoList: todos 变了 → 重新渲染 → 列表多出一条
└── TodoStatus: total/active 变了 → 数字更新
关键洞察: 子组件里没有修改数据的代码 。TodoInput 调 onAdd,TodoList 调 onToggle/onDelete,TodoStatus 调 onClear ------ 都只是"打电话通知",真正动手改数据的是父组件 App。
这就是 React 单向数据流的精髓:
markdown
State 永远只在一个地方被修改(父组件)
│
▼
通过 props 向下传递数据
│
▼
子组件通过回调函数向上报告意图
│
▼
父组件修改 state → 自动触发所有子组件更新
数据和界面是统一的。 你永远不需要手动"刷新 UI",改了数据,界面自动跟上。
10. 进阶思考:这个项目还可以怎么优化
当你把基础功能跑通后,可以考虑这些方向:
10.1 使用 useEffect 持久化到 localStorage
jsx
// App.jsx 中添加:
// 初始化:从 localStorage 读取
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// 自动保存:todos 变化时写入 localStorage
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
这样刷新页面后数据不会丢失。
10.2 使用 useReducer 管理复杂状态
当状态逻辑变复杂时(比如还要支持编辑、拖拽排序),useState 会让组件越来越臃肿。useReducer 把"什么操作"和"怎么改"分离:
jsx
const todoReducer = (state, action) => {
switch (action.type) {
case 'ADD': return [{ id: Date.now(), text: action.text, completed: false }, ...state];
case 'TOGGLE': return state.map(t => t.id === action.id ? {...t, completed: !t.completed} : t);
case 'DELETE': return state.filter(t => t.id !== action.id);
case 'CLEAR': return state.filter(t => !t.completed);
default: return state;
}
};
const [todos, dispatch] = useReducer(todoReducer, []);
// dispatch({ type: 'ADD', text: '买菜' })
// dispatch({ type: 'TOGGLE', id: 1 })
10.3 拆分自定义 Hook
把数据逻辑抽成 useTodos.js,App 组件只负责组装:
jsx
// hooks/useTodos.js
export const useTodos = () => {
const [todos, setTodos] = useState([]);
const addTodo = (text) => { /* ... */ };
const toggleTodo = (id) => { /* ... */ };
// ...
return { todos, addTodo, toggleTodo, deleteTodo, clearCompleted, activeCount, completedCount };
};
// App.jsx ------ 清爽多了
const App = () => {
const todo = useTodos();
return (
<div>
<TodoInput onAdd={todo.addTodo} />
<TodoList todos={todo.todos} onToggle={todo.toggleTodo} onDelete={todo.deleteTodo} />
<TodoStatus total={todo.todos.length} active={todo.activeCount} completed={todo.completedCount} onClear={todo.clearCompleted} />
</div>
);
};
10.4 用 Context 避免 Props Drilling
当前只有一层嵌套,props 传递很清爽。但如果组件树深到 5-6 层,props 逐层传递就很痛苦了(这叫 "Props Drilling")。React Context 可以解决这个问题:
jsx
const TodoContext = createContext();
// 在 App 中用 Provider 包裹
<TodoContext.Provider value={todo}>
{/* 任何深度的子组件都能直接 useContext(TodoContext) 拿到数据 */}
</TodoContext.Provider>
不过 别过早优化 ------ 像本项目这种浅层组件树,直接传 props 反而是更清晰的选择。
总结
| 概念 | 说明 |
|---|---|
| JSX | 在 JS 里写类似 HTML 的语法,本质是 createElement 的语法糖 |
| 组件化 | 页面拆成独立的小块,每块只负责一件事 |
| useState | 给组件添加"记忆" ------ 数据变了,界面自动更新 |
| Props | 父组件向子组件传数据的管道,只读,不可修改 |
| 单向数据流 | 数据从上往下流,事件从下往上冒 |
| 受控组件 | 表单元素的值由 React state 完全控制 |
| 不可变更新 | 永远用新对象替换旧对象,不直接修改 |
| 派生状态 | 能计算出来的就不要单独存 |
| 条件渲染 | 用 && 或三元表达式决定渲染什么 |
| 列表渲染 | 用 map + 稳定的 key |