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

引言

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

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

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

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

数据流原则

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

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

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

jsx 复制代码
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 ------ 状态管理与业务逻辑

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

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) 原则,不直接修改原数组,而是返回一个新数组:

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

4.3 派生状态与副作用

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

4.4 组合视图

jsx 复制代码
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:受控组件与表单提交

jsx 复制代码
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:列表渲染与条件样式

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)}>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:统计展示与批量操作

jsx 复制代码
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,无参数,父组件据此清除所有已完成项。

数据流总结与表格分析

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

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

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

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

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

关键设计要点

  • 状态提升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 开发中绝大部分的思维范式。掌握这些模式后,无论是构建表单系统、管理后台还是复杂交互页面,都能游刃有余。


相关推荐
子兮曰2 小时前
深入 Superpowers:180k Stars 的开源 AI 编程方法论是如何工作的
前端·javascript·后端
骑自行车的码农2 小时前
数据的源头 —— JSX
react.js
隔壁的大叔2 小时前
Markdown 渲染如何穿插自定义组件
前端·javascript·vue.js
薯老板2 小时前
JavaScript原型,原型链
javascript
愚者Pro3 小时前
Flutter基础学习
前端·javascript·vue.js
时光足迹3 小时前
Tiptap 简单编辑器模版
前端·javascript·react.js
吴声子夜歌3 小时前
Vue3——使用Mock.js
javascript·vue·mock.js
时光足迹3 小时前
ThreeJS之GUI控制器
前端·javascript·three.js
时光足迹3 小时前
Tiptap编辑器
前端·javascript·react.js