用 React 写一个 TodoList

从零开始,用 React 19 + Vite 8 搭建一个完整的待办事项应用。


目录

  1. 我们要做什么
  2. 项目初始化
  3. 先画组件树再写代码
  4. [入口文件: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")
  5. [根组件 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")
  6. TodoInput:输入框与受控组件
  7. TodoList:列表渲染与事件冒泡
  8. TodoStatus:派生状态与条件渲染
  9. 复盘:数据是怎么流动的
  10. 进阶思考:这个项目还可以怎么优化

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         # ← 全局样式

核心依赖: reactreact-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>
);

这五行代码做的事:

  1. createRoot(domNode) ------ 在 #root 上创建一个 React 根节点(React 18 新增的 API,替代老版的 ReactDOM.render
  2. .render(<App />) ------ 把 <App /> 组件渲染进去
  3. <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 方向 含义
todostotalactivecompleted 父 → 子 把数据"下发"给子组件展示
onAddonToggleonDeleteonClear 子 → 父 子组件"上报"事件,父组件响应

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?

它只负责展示 ,不负责计算totalactivecompleted 这三个数字已经在父组件 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 确保表达式结果是 truefalse,不会出现意外。


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
相关推荐
To_OC11 小时前
写了三遍 Todo List,我终于搞懂了 React 父子组件到底怎么通信
前端·javascript·react.js
kyriewen15 小时前
别再乱用useEffect了——你写的10个里有8个不该存在
前端·javascript·react.js
zandy101116 小时前
衡石 Agentic BI的ReAct 推理框架在 Agentic BI 中的工程化实践
前端·javascript·react.js
labixiong19 小时前
React Compiler 用 Rust 重写了,编译提速 10 倍——手写 useMemo 的日子到头了
react.js·rust
张元清1 天前
React useDeepCompareEffect:修复 useEffect 的对象依赖问题(2026)
javascript·react.js
用户298698530141 天前
React 前端处理 Excel 工作表复制的技术实践
javascript·react.js·excel
光影少年1 天前
RN原生交互 & 桥接
前端·javascript·react native·react.js·前端框架
GuWenyue1 天前
踩坑无数!吃透useState 3个核心技巧,彻底解决状态旧值、性能卡顿问题
前端·javascript·react.js
无人生还1 天前
从 Vue3 到 React · 快速上手系列第 4 篇:条件渲染与列表渲染
前端·vue.js·react.js