# 深入 React Todos:从零实现一个状态提升与本地持久化的待办应用

51 阅读9分钟

引言

在日常开发中,Todo 应用是学习前端框架的“Hello World”级案例,它浓缩了组件化开发的核心模式:状态管理、父子通信、兄弟组件协作、受控组件以及副作用处理。今天我们将基于一个使用 React + Vite + Stylus 构建的 Todo 项目,逐行解析其源码,并总结出可复用的最佳实践。文章会覆盖入口文件、根组件与三个功能组件,最后用表格对比不同组件的职责与数据流向,帮助大家真正掌握 React 的组件化思维。 完整项目链接:gitee.com/hong-strong…

项目总览:组件树与数据流

整个应用由四个组件构成:

App (根组件)
 ├─ TodoInput   (输入添加)
 ├─ TodoList    (列表展示与操作)
 └─ TodoStats   (统计与批量清除)

数据流原则

  1. 状态提升:共享状态 todos 存储在顶层组件 App 中,并通过 props 向下传递给子组件。
  2. 子→父通信:子组件无法直接修改 todos,而是通过父组件传递的回调函数(如 onAddonDelete)来“上报”修改意图,由父组件执行状态更新。
  3. 兄弟组件通信TodoInputTodoListTodoStats 之间没有直接联系,它们都通过与同一个父组件 App 交互实现间接通信。任何操作引发的状态变化都会自动反映到所有相关组件中。

这种模式保证了单一数据源可预测的状态更新,是 React 哲学的基石。

入口文件 main.jsx:React 18 的渲染方式

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

逐行解读

  • StrictMode:React 的严格模式,仅在开发环境下生效。它会对组件进行额外的检查,例如检测不安全的生命周期、过时的 API 以及意外的副作用。包裹 <App /> 有助于我们在开发阶段提前发现问题。
  • createRoot:React 18 引入的新 API,替代了旧版的 ReactDOM.render。它启用并发特性,为后续使用 Suspense、Transitions 等打下基础。
  • document.getElementById('root'):挂载点,对应 index.html 中的 div#root
  • .render(...):将 React 元素树渲染到真实 DOM 中。整个应用从这里启动。

tips:StrictMode 会让组件函数体、初始化函数等执行两次,所以在开发时会发现 useEffect 运行两次,这是刻意设计的,用于暴露副作用问题。

核心:App.jsx —— 状态管理与业务逻辑

根组件是整个应用的“大脑”,负责持有状态、定义修改方法、计算派生数据,以及处理副作用。

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

function App() {
  // 1. 状态初始化
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  })
  // ...
}

4.1 状态初始化:惰性读取 localStorage

useState 传入了一个函数,而不是直接传值。这是 惰性初始化(Lazy Initial State):该函数只在组件首次渲染时执行一次。如果直接传值,比如 useState(JSON.parse(localStorage.getItem('todos')) || []),每次渲染都会执行 localStorage.getItemJSON.parse,即使其结果已被忽略,造成不必要的性能开销。惰性初始化避免了重复读取,是应对从外部存储恢复状态的标准写法。

当本地存储中没有 todos 时返回空数组 [],否则解析出已有的待办列表。这样用户刷新页面后数据不会丢失。

4.2 操作方法:不可变更新

所有修改方法都遵循 不可变数据(Immutable) 原则,不直接修改原数组,而是返回一个新数组:

const addTodo = (text) => {
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false,
  }])
}
  • 使用展开运算符 ...todos 创建新数组,再附加一个新对象。id 用时间戳生成,保证唯一性;completed 初始为 false
  • 优点:React 通过引用比较来判断状态是否变化,不可变更新确保每次调用都会触发重新渲染。
const deleteTodo = (id) => {
  setTodos(todos.filter(todo => todo.id !== id))
}
  • filter 返回一个新数组,剔除指定 id 的项,实现删除。
const toggleTodo = (id) => {
  setTodos(todos.map(todo => todo.id === id ? {
    ...todo,
    completed: !todo.completed,
  } : todo))
}
  • map 遍历数组,找到匹配 id 的 todo,用对象展开 ...todo 复制其余属性,并翻转 completed 状态。未匹配的项原样返回。
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed))
}
  • 清除所有已完成项,同样通过 filter 返回新数组。

4.3 派生状态与副作用

const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;
  • 这两个变量并非 state,而是派生状态(Derived State):它们完全由 todos 计算得出,无需额外维护。每当 todos 变化,函数组件重新执行,这两个值会自动更新。这避免了数据冗余和同步问题。
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos])
  • 副作用处理:当 todos 变化时,将其序列化后存入 localStorage。依赖数组 [todos] 保证仅在 todos 引用改变时执行,避免无限循环。注意:useEffect 会在 DOM 更新后异步执行,不会阻塞渲染,因此不会影响交互流畅度。

4.4 组合视图

return (
  <div className="todo-app">
    <h1>My Todo List</h1>
    <TodoInput onAdd={addTodo}/>
    <TodoList 
      todos={todos} 
      onDelete={deleteTodo}
      onToggle={toggleTodo}
    />
    <TodoStats 
      total={todos.length}
      active={activeCount}
      completed={completedCount}
      onClearCompleted={clearCompleted}
    />
  </div>
)
  • 通过 props 向子组件传递数据todostotal 等)和修改方法onAddonDelete 等)。这些修改方法就是“自定义事件”,子组件调用时相当于向父组件发送了操作请求。
  • 这种设计保持了组件的纯净性:子组件只负责 UI 和触发行为,不关心状态如何存储与变更,实现了高内聚低耦合。

子组件解析

5.1 TodoInput:受控组件与表单提交

import { useState } from 'react'
const TodoInput = (props) => {
  const { onAdd } = props;
  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>
  )
}

逐行解析

  • const [inputValue, setInputValue] = useState(''):自有状态,管理输入框的文字。这里采用受控组件(Controlled Component) 模式:value 由 React 状态决定,onChange 更新状态,输入框的视图始终与状态同步。相对于 Vue 的 v-model 双向绑定,React 通过“值 + onChange”的组合实现单向数据流,性能与可预测性更好。
  • handleSubmit:阻止表单默认提交行为(避免页面刷新),调用父组件传入的 onAdd 回调,将当前文本传递给 App 进行添加,然后清空输入框。清空动作由本地 setInputValue 完成,体现了局部状态的自治。
  • 子→父通信onAdd(inputValue) 就是子组件向父组件传递数据的唯一途径。

5.2 TodoList:列表渲染与条件样式

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)}>X</button>
          </li>
        ))
      )}
    </ul>
  )
}

逐行解析

  • props 解构出 todos(数据)、onDeleteonToggle(操作回调)。
  • 条件渲染:当 todos.length === 0 时显示空状态提示,否则渲染列表。空状态处理提升了用户体验。
  • 列表渲染:用 map 遍历 todos,给每个 <li> 设置唯一 key(这里使用 todo.id),这是 React 虚拟 DOM Diff 算法优化重排的基础。
  • className={todo.completed ? 'completed' : ''} 动态绑定样式,通过样式类名展示完成/未完成状态。
  • 复选框:使用受控组件模式,checked={todo.completed} 由父组件状态决定,onChange 触发 onToggle(todo.id) 通知父组件切换完成状态。注意这里没有在子组件内修改 todo.completed,完全遵循单一数据流。
  • 删除按钮onClick={() => onDelete(todo.id)},同样通过回调将删除意图上报给父组件。

5.3 TodoStats:统计展示与批量操作

const TodoStats = (props) => {
  const { total, active, completed, onClearCompleted } = props;

  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>
  )
}

逐行解析

  • 接收四个 propstotalactivecompleted 三个统计数据,以及 onClearCompleted 回调。这些数据完全来自父组件计算的派生状态,体现了数据流自上而下
  • 展示统计信息,用管道符分隔,简洁明了。
  • {completed > 0 && (...)}:短路逻辑实现条件渲染,仅当已完成数量大于 0 时才显示“Clear Completed”按钮。避免无意义操作,UI 更清爽。
  • 点击按钮触发 onClearCompleted,无参数,父组件据此清除所有已完成项。

数据流总结与表格分析

整个应用严格遵循 单向数据流,形成了清晰的数据生命周期:

用户操作 → 子组件调用 props 回调 → 父组件更新 state → React 重新渲染
→ 子组件接收新 props → 视图更新

同时,通过 useEffect 将状态持久化到 localStorage,实现了 数据刷新不丢失

下面用一张表格总结各组件的职责与通信方式:

组件职责接收的 Props自有 State触发的回调(子→父)
App持有全局状态、定义修改逻辑、持久化todos无(它是顶层)
TodoInput输入新待办,提交添加onAddinputValueonAdd(text)
TodoList展示待办列表,提供完成/删除交互todos, onToggle, onDeleteonToggle(id), onDelete(id)
TodoStats显示统计信息,提供批量清除入口total, active, completed, onClearCompletedonClearCompleted()

关键设计要点

  • 状态提升todos 是唯一数据源,放在公共祖先 App 中,避免多组件状态不一致。
  • 兄弟组件解耦TodoInput 添加事项后,无需直接通知 TodoListTodoStats;只因 todos 变化,这些组件通过接收新 props 自动更新。
  • 不可变更新:所有状态更新都使用新数组,保证 React 能够正确检测变化并触发渲染。
  • 受控组件TodoInput 的文本输入与 TodoList 的复选框都受 React 状态控制,杜绝 DOM 直接操作。
  • 惰性初始化与副作用useState 的函数初始器避免重复读取存储,useEffect 负责同步外部系统。

一些总结

  1. 性能优化:如果 todos 数量很大,可以在 TodoList 中使用 React.memo 包裹,避免无关 props 变化导致的重渲染。另外,可以用 useCallback 包裹回调函数,防止因函数引用变化导致子组件不必要的更新。

  2. 唯一 ID 生成:当前使用 Date.now() 在高并发快速添加时可能产生重复。在生产环境中可以改用 crypto.randomUUID() 或成熟库(如 nanoid)。

  3. 类型安全:加入 TypeScript,为 todosprops 定义接口,能大幅减少拼写错误并提升可维护性。

  4. 状态管理扩展:若应用规模扩大,可以考虑使用 useReducer 重构 App 的状态逻辑,将操作集中在 reducer 中,更便于测试和跟踪状态变化;或者引入 Context API 避免深层 props 传递(prop drilling),但小型 Todo 应用目前的模式已足够清晰。

  5. 自定义 Hook:可以将 useState + useEffect 的持久化逻辑封装成 useLocalStorageState 自定义 Hook,提高复用性。

结语

通过这个 React Todo 应用,我们深入剖析了 组件化设计、状态提升、单向数据流、受控组件以及本地持久化 的核心实践。源码虽然精简,却覆盖了 React 开发中绝大部分的思维范式。掌握这些模式后,无论是构建表单系统、管理后台还是复杂交互页面,都能游刃有余。