用 React Hooks 手把手实现一个持久化 Todo List:从组件拆分到状态提升的深度解析

70 阅读11分钟

用 React Hooks 手把手实现一个持久化 Todo List:从组件拆分到状态提升的深度解析

大家好!今天我们来一起打造一个经典却永不过时的项目——Todo List(待办事项列表)。这个小应用看似简单,却能完美诠释 React 的核心哲学:组件化、单向数据流、状态管理。成功实现添加、删除、完成/未完成切换、清空已完成等功能,还会加入 localStorage 持久化,让你的待办事项刷新页面后依然存在。

更重要的是,这篇文章会深入剖析 React 的底层逻辑:为什么 React 强调单向数据流?父子组件如何通信?兄弟组件又该怎么“对话”?我们会结合实际代码,一层层剥开这些知识点,同时提醒常见坑点,帮助你真正理解而不是死记硬背。

项目用 Vite + React 搭建,如果你还没搭建过 Vite 项目,推荐直接用 npm create vite@latest 选择 React 模板,极速启动开发体验远超老派 CRA。

一、项目整体架构:组件拆分与职责分离

一个优秀的 React 应用,首先要学会“拆组件”。我们将 Todo List 拆分成四个核心组件:

  • App.jsx:父组件,负责持有全局状态(todos 数组)和状态修改方法,是整个应用的“大脑”。
  • TodoInput.jsx:负责添加新待办,接收父组件传来的 onAdd 回调。
  • TodoList.jsx:负责展示待办列表,接收 todos 数据和 onDeleteonToggle 回调。
  • TodoStats.jsx :显示统计信息(总计、进行中、已完成),并提供“清空已完成”按钮。

这种拆分遵循 “聪明父组件 + 傻子组件” 的原则:子组件只负责渲染和简单交互,所有数据和复杂逻辑都集中在父组件。这就是 React 推荐的 状态提升(Lifting State Up)

为什么不让子组件自己管理状态?因为如果 TodoInput、TodoList、TodoStats 各自持有 todos,一改动就乱套了——数据不一致、同步困难。提升到共同父组件,就能保证“单一数据源”(Single Source of Truth),这是 React 状态管理的金科玉律。

二、核心状态管理:useState + 响应式更新

在 App.jsx 中,我们用 useState 初始化 todos:

const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

这里用了 惰性初始化(传入函数),只在组件首次渲染时执行,避免每次渲染都读 localStorage。完美!

添加待办:addTodo
const addTodo = (text) => {
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

Date.now() 生成唯一 id(生产环境推荐 uuid 库,但简单项目够用)。注意:永远不要直接修改 state,而是用新数组替换。这是因为 React 靠对象引用比较来判断是否重渲染。

易错提醒:很多人会写 todos.push(newTodo); setTodos(todos); —— 这错!因为 push 修改了原数组,引用没变,React 认为状态没更新,不会重渲染。

删除与切换:函数式更新

删除:

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

这里用了 immutable 操作:map 返回新数组,spread 创建新对象。底层逻辑是 不可变数据(Immutability),确保旧状态可追溯,便于调试和时间旅行(Redux DevTools 就是靠这个)。

清空已完成:

const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

统计:

const activeCount = todos.filter(todo => !todo.completed).length;
const completedCount = todos.filter(todo => todo.completed).length;

这些计算放在父组件,避免子组件重复计算。

三、组件通信详解:单向数据流下的优雅传递

React 的数据流是 单向的:从父到子通过 props,下层不能直接改上层。

父 → 子:Props 下钻

App 中:

<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onDelete={deleteTodo} onToggle={toggleTodo} />
<TodoStarts 
  total={todos.length}
  active={activeCount}
  completed={completedCount}
  onClearCompleted={clearCompleted}
/>

子组件接收 props,像普通参数一样使用。这就是 父传子

子 → 父:回调函数上报

子组件不能直接改父状态,只能“上报事件”。比如 TodoInput:

const TodoInput = ({ onAdd }) => {
  const [inputValue, setInputValue] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      onAdd(inputValue.trim());
      setInputValue('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
      />
      <button type="submit">Add</button>
    </form>
  );
};

这里 onAdd 是父传来的函数,子调用它上报“添加文本”。这就是 子传父 的标准姿势。

TodoList 中的删除和切换同理,通过 onDelete(id)onToggle(id) 上报。

兄弟组件通信:通过共同父组件中转

TodoInput、TodoList、TodoStats 是兄弟,它们不直接通信,而是通过父 App 的 todos 状态间接同步。

  • 输入新待办 → TodoInput 调用 onAdd → App 更新 todos → TodoList 自动重渲染显示新项 → TodoStats 统计更新。

这就是 状态提升 的威力!没有 Redux 或 Context,小项目完美解决兄弟通信。

底层逻辑:React 的 diff 算法只比较 props 和 state 变化。todos 变了,所有依赖它的子组件重渲染,视图自动同步。生动形象地说,父组件像“数据中心”,子组件像“订阅者”,数据一变,全员更新。

如果项目更大,跨多层级传 props 麻烦时,再考虑 Context 或 Redux。

四、数据持久化:useEffect + localStorage

刷新页面 todos 没了?用 localStorage 存!

在 App 中:

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

todos 一变,就序列化存本地。结合初始化时的读取,实现完美持久化。

useEffect 底层:模拟类组件的 componentDidMount 和 componentDidUpdate。只在依赖 [todos] 变化时运行,避免无限循环。

易错提醒

  • 必须 JSON.stringify 存,JSON.parse 取——localStorage 只存字符串。
  • 别把 useEffect 依赖写错成 [],那样只存一次初始值。
  • 大数据别滥用 localStorage(上限 5MB),复杂状态考虑 IndexedDB。

五、视图层细节优化

TodoList 渲染
{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>
  ))
)}

用 key={todo.id} 优化列表 diff。checked 用 controlled 模式(React 推荐)。

易错:别用 index 做 key,排序/删除时会导致错乱。

TodoStats 条件渲染
{completed > 0 && (
  <button onClick={onClearCompleted}>Clear Completed</button>
)}

只有有已完成时显示按钮,用户体验更好。

输入框 controlled component

React 不像 Vue 有 v-model 双向绑定,而是手动 onChange 同步 —— 这其实更好!因为数据流明确,可控。

六、几个细节小知识点

fc962ce0cd306c49bc54248e80437e81.jpg

1. React 靠对象引用比较来判断是否重渲染

React 的重渲染机制超级“抠门”——它不会深比较你的 state 对象里每一层的内容,而是只看引用(内存地址)是否变了

举个生活例子:

  • 你有一个数组 todos = [A, B, C],它在内存里占着一个位置(假设地址是 0x123)。
  • React 记住的就是这个地址 0x123。

现在你想加一个新 todo:

错误写法(很多人这么干):

JavaScript

todos.push(newTodo);  // 原地修改数组
setTodos(todos);      // 把同一个数组传回去
  • push 只是往原数组里塞东西,数组的内存地址还是 0x123,一点没变
  • React 一看:“state 的引用没变啊?那我认为你没改数据,不用重渲染了。”
  • 结果:界面死活不更新!你气得想砸键盘。

正确写法

JavaScript

setTodos([...todos, newTodo]);  // 或者 todos.concat(newTodo)
  • [...todos, newTodo] 会创建一个全新的数组,内存地址变成 0x456。
  • React 看到引用变了:“哦?state 更新了!该重渲染了!”
  • 界面立刻刷新,新 todo 出现。

这就是“对象引用比较”的底层逻辑:浅比较(shallow compare) ,快得飞起,但要求我们必须返回新引用。

小结:React 为了性能,牺牲了“智能深比较”,把责任交给我们开发者——“你要改数据,就给我一个新对象,别在原地偷偷改!”

2. spread(展开运算符) 创建新对象 + 不可变数据(Immutability)

Immutability(不可变性)就是:一旦创建了一个对象/数组,就永远不原地修改它,改就创建一个新版本

我们看 toggleTodo 的代码:

JavaScript

setTodos(todos.map(todo => 
  todo.id === id 
    ? { ...todo, completed: !todo.completed }  // 创建新对象
    : todo                                   // 老对象直接复用
));
  • { ...todo } 用 spread 把原 todo 的所有属性拷贝到一个新对象里。
  • 然后只改 completed 属性。

这样做的三大好处:

  1. 旧状态永远可追溯 你可以随时把旧的 todos 数组保存下来,回溯历史状态(比如实现 Undo/Redo 功能超简单)。
  2. 调试和时间旅行神器 Redux DevTools 的“时间旅行调试”就是靠这个:每次 dispatch action 都产生一个全新 state,工具可以让你往前/往后跳,瞬间看到每一步界面是怎么变的。
  3. React 能精准优化 因为引用变了,React + React DevTools 能清楚知道“到底哪部分数据变了”,配合 React.memo、useMemo 等,能避免很多不必要的渲染。

生动比喻:

  • 可变数据像在同一张纸上反复涂改,改乱了你都不知道之前写过啥。
  • 不可变数据像每次改都复印一张新纸,旧纸永远保留,历史一目了然。
3. 为什么说 React 的单向数据流 + 手动 onChange 比 Vue 的 v-model 双向绑定性能更好?

先说区别:

  • Vue 的 v-model:语法糖,本质是 :value + @input 的双向绑定。数据改了视图自动更新,视图改了数据也自动更新,看起来很爽。
  • React:只有单向数据流。数据(state) → 视图(value),视图变化必须通过 onChange 事件明确告诉 state:“我要改了!”

为什么 React 故意不提供双向绑定,还说它性能更好?

核心原因有三个:

  1. 数据流向完全可预测 React 强制所有数据变化都走 setState(或 dispatch),你一眼就能看出数据从哪来、去哪改。 Vue 的双向绑定隐藏了数据修改路径,组件一复杂你就找不到“到底是谁偷偷改了我的数据”。
  2. 更容易优化和调试 因为每次变化都显式调用 setState,React 可以批量处理这些更新(batching),减少 DOM 操作。 在复杂表单里,你可以轻松控制“什么时候批量更新”,比如等用户输入完再提交,而不是每敲一个字就更新 state。
  3. 更适合大规模复杂应用 当项目很大、状态很多时,双向绑定容易造成“数据乱飞”,调试成噩梦。 单向 + 不可变让状态变化像流水线一样清晰,配合 Redux、Zustand、Recoil 等状态管理工具,扩展性爆强。

真实性能对比:

  • 小项目:Vue 的 v-model 可能更丝滑(写得少)。
  • 大项目:React 的单向 + 不可变 + 虚拟 DOM diff 能带来更可控的性能,尤其在列表渲染、复杂交互场景下。

比喻一下:

  • Vue 的双向绑定像自动挡汽车,开着爽,但你不知道它内部到底怎么换挡的。
  • React 的单向像手动挡,你每一步都得自己控制,但你对车子的掌控是绝对的,飙起车来更稳更快。

总结来说: React 不是不能做双向绑定(你完全可以用 useEffect 模拟),而是故意不鼓励,因为它相信“显式 > 隐式,可预测 > 魔法”。这正是 React 在复杂应用中长盛不衰的根本原因。

4. “惰性初始化”(Lazy Initialization)

正常写法(很多人这么干):

jsx

const saved = localStorage.getItem('todos');
const initial = saved ? JSON.parse(saved) : [];
const [todos, setTodos] = useState(initial);

或者更简洁:

jsx

const [todos, setTodos] = useState(
  localStorage.getItem('todos') ? JSON.parse(localStorage.getItem('todos')) : []
);

这样写有什么问题?

  • 组件每次渲染(re-render)时,这个初始化代码都会重新执行一次
  • 第一次渲染(mount)时当然需要读 localStorage 来初始化。
  • 但之后每次 setTodos 导致重渲染,useState(...) 里面的代码还是会跑一遍(读 localStorage + JSON.parse),虽然 React 会忽略结果(因为已经不是初始状态了),但这个计算过程本身就白白浪费了 CPU。

你可以把 useState 想象成一个“带记忆的盒子”:

  1. 出生时(首次渲染 / mount) React 问你:“这个盒子一开始想装什么?” 你把初始值(或者惰性函数的返回值)塞进去。 → 这时候初始值生效。
  2. 长大后(后续所有重渲染) React 完全不care你又想塞什么初始值,它直接说: “我已经有记忆了(上一次 setState 留下的值),你就用这个吧!” → 括号里的东西虽然代码执行了,但被无情忽略。
  3. 只有你明确说“我要换内容了”(调用 setTodos) React 才会更新盒子里的东西,并记住新值,下次渲染继续用这个新的。

所以:

  • 初始值 = 出生礼物,只收一次。
  • state 值 = 活到老的记忆,一直在变,一直权威。

如果 todos 数组很大,或者 JSON.parse 很重,这就成了性能杀手。

惰性初始化的写法:

jsx

const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

关键在于:传给 useState 的不是值,而是一个函数

React 的规则是:

  • 如果你传函数,React 只在组件首次渲染(initial render / mount)时调用这个函数来获取初始值。
  • 后续所有重渲染,React 直接跳过这个函数,不会执行里面的代码。
  • 这就是“惰性”(lazy):延迟执行,只在真正需要的时候(初始化)才跑。

完美避开了“每次渲染都白跑一遍 localStorage”的坑,尤其适合读本地存储、复杂计算、读取 props 等场景。

七、样式与项目搭建小贴士

代码用 Stylus(.styl),Vite 原生支持,无需额外配置。样式类名如 todo-app、todo-list 等,建议用 BEM 或 CSS Modules 防冲突。

Vite 搭建:npm init vite@latest my-todo -- --template react,再装 stylus:npm i -D stylus

八、总结与进阶思考

通过这个 Todo List,我们看到了 React 的精髓:

  • 组件化:拆小复用。
  • 单向数据流:props 下传,回调上报。
  • 状态提升:共同祖先管理共享状态。
  • Hooks:useState 管状态,useEffect 管副作用。
  • Immutability:新数据替换旧数据。

这些不是规则,而是为了可预测性、可维护性。掌握了,你就能轻松应对复杂应用。