【React入门实战】手把手拆解 Todo List:从组件通信到 Hooks 详解

78 阅读7分钟

在前端开发的学习路径中,Todo List(待办事项清单)被称为“Hello World”级别的实战项目。虽然看起来简单,但它涵盖了 CRUD(增删改查)、组件拆分、状态管理等核心逻辑。

今天我们将通过一份基于 Vite + React + Stylus 的源码,深入拆解 React 的核心开发模式。

1. 项目架构与组件化思维

React 的核心思想是组件化。我们将 UI 拆分成独立的、可复用的部分。在这个项目中,我们的组件结构非常清晰:

  • App (父组件) : 整个应用的容器,负责“持有数据”和“管理逻辑”。它是唯一的数据源(Source of Truth)
  • TodoInput (子组件) : 负责用户输入,将新任务传递给父组件。
  • TodoList (子组件) : 负责展示任务列表,处理完成/删除操作。
  • TodoStarts (子组件) : 负责展示统计信息(总数、剩余、已完成),并提供清除功能。

这种结构体现了 React 开发的一个重要原则:状态提升(Lifting State Up) 。因为 TodoList 需要展示数据,TodoStarts 需要统计数据,所以我们将数据统一放在它们的共同父组件 App 中管理。

2. 核心知识点拆解

2.1 State 管理与 Hooks 高级用法

App.jsx 中,我们使用了 useState 来管理任务列表数据。

// App.jsx
const [todos, setTodos] = useState(() => {
  // 💡 知识点:Lazy Initialization (惰性初始化)
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

解析:

通常我们写 useState([]),但这里传入了一个函数。

  • 为什么? localStorage.getItem 是同步操作,如果直接写在组件体内,每次组件重新渲染都会执行,影响性能。
  • 惰性初始化:传入函数后,React 只会在组件首次渲染时执行该逻辑,后续渲染会跳过,这是性能优化的一个小技巧。

2.2 受控组件 (Controlled Components)

TodoInput.jsx 中,我们处理用户输入的方式与 Vue 的 v-model 不同。

// TodoInput.jsx
const [inputValue, setInputValue] = useState('');

// ... render
<input 
    type="text"
    value={inputValue} // 1. 绑定状态
    onChange={e => setInputValue(e.target.value)} // 2. 监听变化并更新状态
/>

解析:

React 提倡单向数据流。

  1. 输入框的值由 React state (inputValue) 控制。

  2. 用户的输入触发 onChange

  3. setInputValue 更新 state,React 重新渲染组件,输入框显示新值。

    这被称为受控组件,它保证了数据和视图的严格同步。

2.3 组件通信:父传子 (Props)

数据如何从 App 流向 TodoList?通过 Props

// App.jsx (父组件)
<TodoList todos={todos} ... />

// TodoList.jsx (子组件)
const TodoList = (props) => {
    const { todos } = props; // 解构获取数据
    // ...
}

解析:

父组件将 todos 数组作为属性传递给子组件,子组件只能读取,严禁直接修改 Props。这是 React 数据流向单一性的铁律。

2.4 组件通信:子传父 (回调函数)

子组件想要修改数据怎么办?例如用户在 TodoInput 点击了添加,或者在 TodoList 点击了删除。

答案:父组件传递“修改数据的方法”给子组件。

场景一:添加任务

// App.jsx (父组件定义方法)
const addTodo = (text) => {
  setTodos([...todos, { id: Date.now(), text, completed: false }])
}
// 传递给子组件
<TodoInput onAdd={addTodo} />

// TodoInput.jsx (子组件调用)
const handleSubmit = (e) => {
    e.preventDefault();
    onAdd(inputValue); // 调用父组件传下来的函数
    setInputValue('');
}

场景二:兄弟组件通信(间接)

TodoInput 添加的数据,最终显示在了 TodoList 中。它们之间没有直接联系,而是通过:

  1. TodoInput 通知 App 更新 state。

  2. App state 变化,触发重新渲染。

  3. App 将新的 todos 传给 TodoList

    这就是状态共享的魅力。

2.5 交互逻辑详解:不可变数据的删除与更新

在 React 中,更新状态(State)有一个核心原则:永远不要直接修改 State,而是用新数据替换旧数据。这一点在删除和勾选操作中体现得淋漓尽致。

1. 删除功能:Filter 的妙用

点击删除按钮时,我们需要移除列表中的某一项。在 App.jsx 中,我们没有使用数组的 splice 方法(因为它会直接修改原数组),而是使用了 filter

// App.jsx
const deleteTodo = (id) => {
  // 💡 知识点:Immutable Update (不可变更新)
  // filter 返回一个新数组,不包含被删除的那一项
  setTodos(todos.filter(todo => todo.id !== id))
}

在 UI 组件 TodoList.jsx 中,我们需要注意事件绑定的写法:

// TodoList.jsx
<button onClick={() => onDelete(todo.id)}>Delete</button>

⚠️ 新手易错点:

一定要写成箭头函数 () => onDelete(todo.id)。

如果写成 onClick={onDelete(todo.id)},函数会在组件渲染时立即执行,导致无限循环报错。

2. 勾选切换:Map 与展开运算符

这是 React 数组更新中最常见的模式。当用户点击 Checkbox 时,我们需要把数组中特定 ID 的那一项的 completed 状态取反,同时保持其他项不变。

// App.jsx
const toggleTodo = (id) => {
  setTodos(todos.map(todo => 
    // 遍历每一个 item,找到 id 匹配的那一个
    todo.id === id ? {
      ...todo, // 💡 知识点:Spread Operator (展开运算符) 复制原有属性
      completed: !todo.completed // 覆盖 completed 属性
    } : todo // id 不匹配的项保持原样
  ))
}

逻辑解析:

  1. todos.map:生成一个全新的数组,保证不修改原 todos 引用。
  2. ...todo:利用 ES6 的展开运算符,将旧对象的所有属性(id, text)复制到新对象中。
  3. completed: !todo.completed:单独覆盖我们需要修改的属性。

TodoList.jsx 中,我们将这个逻辑绑定到 input 的 onChange 事件上,同时利用 checked 属性实现受控组件的双向绑定效果:

// TodoList.jsx
<input 
    type="checkbox" 
    checked={todo.completed} // 视图状态依赖数据
    onChange={() => onToggle(todo.id)} // 数据更新依赖交互
/>

同时也利用这个状态来动态控制 CSS 类名,实现完成时的划线样式:

<li key={todo.id} className={todo.completed ? 'completed' : ''}>

2.6 列表渲染与 Key

TodoList.jsx 中,我们使用 map 方法渲染列表:

// TodoList.jsx
todos.map(todo => (
    <li key={todo.id} className={todo.completed ? 'completed' : ''}>
        {/* ...内容 */}
    </li>
))

解析:

  • Key 的重要性:React 需要 key 来识别哪些元素改变了、添加了或删除了。这里我们使用了 todo.id (时间戳) 作为唯一标识。切记不要使用数组索引(index)作为 key,这在列表顺序变化时会导致严重的渲染错误或性能问题。
  • 条件渲染:我们使用三元运算符 todos.length === 0 ? (...) : (...) 来处理空状态的展示。

2.7 副作用处理 (useEffect)

如何实现数据持久化,刷新页面数据不丢失?我们使用了 useEffect

// App.jsx
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos])

解析:

  • useEffect 用于处理副作用(Side Effects),比如数据获取、订阅、手动修改 DOM 等。
  • 依赖数组 [todos] :这意味着每当 todos 状态发生变化时,React 就会执行这个函数,将最新的数据同步到 LocalStorage。

2.8 逻辑复用与短路运算

TodoStarts.jsx 中,有一个很优雅的条件渲染写法:

// TodoStarts.jsx
{
    completed > 0 && (
        <button onClick={onClearCompleted}>Clear Completed</button>
    )
}

解析:

利用 JavaScript 的逻辑与 (&&) 运算。只有当 completed > 0 为真时,后面的 Button 才会渲染。这比写 if/else 要简洁得多。

3. 总结

通过这个小小的 Todo List,我们完整实践了 React 开发的“黄金法则”:

  1. 数据驱动视图:UI 是 State 的投影 (UI = f(State))。
  2. 单向数据流:数据向下流动 (Props),事件向上冒泡 (Callback)。
  3. 不可变性:在 App.jsxdeleteTodo 等方法中,我们使用了 filtermap 返回新数组,而不是直接修改原数组 (push/splice),这对 React 识别状态变化至关重要。
// ❌ 错误示范
todos.push(newItem); 

// ✅ 正确示范 (不可变更新)
setTodos([...todos, newItem]); 

4. 项目源码

4.1 App.jsx

import { useState, useEffect } from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList.jsx'
import TodoInput from './components/TodoInput.jsx'
import TodoStarts from './components/TodoStarts.jsx'

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 toggleTodo = (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} onToggle={toggleTodo} />
      <TodoStarts total={todos.length} active={activeCount} completed={completedCount} onClearCompleted={clearCompleted}/>
    </div>
  )
}

export default App;

4.2 TodoInput.jsx

import { useState } from 'react'
const TodoInput = (props) => {
    console.log(props);
    const { onAdd } = props;
    // react 不支持 Vue 中的 v-model 那样的双向绑定,react 认为这样性能不好
    // react 只支持单向绑定,性能好 + onChange 实现数据和视图的同步
    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)}  
            />
            <button type="submit">Add</button>
        </form>

    )
}

export default TodoInput;

4.3 TodoList.jsx

const TodoList = (props) => {
    const { todos, onDelete, onToggle } = props;
    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)}>Delete</button>
                        </li>
                    ))
                )
            }
        </ul>
    )
}

export default TodoList;

4.4 TodoStarts.jsx

const TodoStarts = (props) => {
    const { total, active, completed, onClearCompleted } = props;
  return (
    <div className="todo-starts">
        <p>Total: {total} | Active: {active} | Completed: {completed}</p>
        {
            completed > 0 && (
                <button onClick={onClearCompleted} className="clear-btn">Clear Completed</button>
            )
        }
    </div>
  )
}

export default TodoStarts;