React Todo 实战:useEffect 生命周期与 localStorage 持久化
- 前言
- [1. 项目功能与组件结构](#1. 项目功能与组件结构)
- [2. 完整代码](#2. 完整代码)
-
- [2.1 App.jsx](#2.1 App.jsx)
- [2.2 TodoInput.jsx](#2.2 TodoInput.jsx)
- [2.3 TodoList.jsx](#2.3 TodoList.jsx)
- [2.4 TodoStats.jsx](#2.4 TodoStats.jsx)
- [3. 函数组件生命周期:函数执行不等于副作用执行](#3. 函数组件生命周期:函数执行不等于副作用执行)
- [4. `useEffect` 到底解决什么问题](#4.
useEffect到底解决什么问题) - [5. 四种依赖项写法](#5. 四种依赖项写法)
-
- [5.1 `count`:挂载后执行,count 改变后再次执行](#5.1
[count]:挂载后执行,count 改变后再次执行) - [5.2 不传依赖数组:每次提交更新后执行](#5.2 不传依赖数组:每次提交更新后执行)
- [5.3 `\[\]`:只在挂载后执行一次](#5.3
[]:只在挂载后执行一次) - [5.4 `todos`:任务变化后持久化](#5.4
[todos]:任务变化后持久化)
- [5.1 `count`:挂载后执行,count 改变后再次执行](#5.1
- [6. `localStorage` 懒初始化与数据持久化](#6.
localStorage懒初始化与数据持久化) -
- [6.1 为什么读取操作放在 `useState` 函数初始值中](#6.1 为什么读取操作放在
useState函数初始值中) - [6.2 为什么不能在 `setTodos` 后立即保存旧数组](#6.2 为什么不能在
setTodos后立即保存旧数组)
- [6.1 为什么读取操作放在 `useState` 函数初始值中](#6.1 为什么读取操作放在
- [7. Demo:挂载、定时器与卸载清理](#7. Demo:挂载、定时器与卸载清理)
-
- [7.1 `count` 如何控制 Demo 的存在](#7.1
count如何控制 Demo 的存在) - [7.2 为什么定时器必须清理](#7.2 为什么定时器必须清理)
- [7.1 `count` 如何控制 Demo 的存在](#7.1
- [8. Todo 子组件如何参与生命周期更新](#8. Todo 子组件如何参与生命周期更新)
- [9. 当前代码的改进版关键片段](#9. 当前代码的改进版关键片段)
- [10. 常见误区](#10. 常见误区)
- 总结
前言
本文基于一个之前讲过的Todo 项目作出更新,在这里贴出了完整代码,但只基于更新内容进行讲解,完整版看React 待办事项实战:从 Vite 初始化到组件化与单向数据流。
在本项目中useState 负责保存任务和计数器状态,组件负责根据状态返回界面;但有些工作并不属于"计算 JSX"本身,例如启动定时器、读取浏览器存储、把最新任务保存到 localStorage,这些都需要在渲染完成后与外部系统同步。
这正是 useEffect 要解决的问题。本文基于一个包含 TodoInput、TodoList、TodoStats 和 Demo 子组件的最新 Todo 示例,重点讲解 React 函数组件的生命周期过程:挂载、更新、卸载 ,以及不同依赖项数组对 useEffect 执行时机的影响。
文章会给出完整代码,同时指出当前实现中两个容易被忽略的问题:localStorage 没有数据时可能得到 null,以及在 setTodos 后立即保存 todos 时读取到的仍然是旧状态。
1. 项目功能与组件结构
当前项目的组件树如下:
text
App
├── Demo 通过 useEffect 创建和清理定时器
├── TodoInput 管理输入框临时状态,并通过回调请求添加任务
├── TodoList 展示任务,报告勾选和删除操作
└── TodoStats 展示统计数据,报告清理操作
App 是状态所有者,负责保存 count 和 todos,并把数据和事件回调通过 props 传给子组件。子组件不能直接修改 App 的状态,只能调用父组件传下来的函数报告用户意图。
| 状态或函数 | 所属组件 | 作用 |
|---|---|---|
count |
App |
控制计数器和 Demo 是否挂载 |
todos |
App |
保存全部任务 |
inputValue |
TodoInput |
保存输入框当前内容 |
addTodo |
App |
创建并添加任务 |
toggleTodo |
App |
切换任务完成状态 |
deleteTodo |
App |
删除指定任务 |
clearCompleted |
App |
清理所有已完成任务 |
2. 完整代码
2.1 App.jsx
下面是当前项目的核心 App.jsx。其中 Demo 组件用于演示卸载前的清理逻辑,四个 useEffect 分别演示不同依赖项数组的执行时机。
js
import { useState, useEffect } from 'react';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
import TodoStats from './components/TodoStats';
import './App.css';
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log('interval,is here');
}, 1000);
return () => {
console.log('组件卸载前执行,做什么内存清理工作');
clearInterval(interval);
};
}, []);
return <>Demo</>;
};
const App = () => {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos'));
});
useEffect(() => {
console.log('挂载后执行');
console.log('count改变也会执行');
}, [count]);
useEffect(() => {
console.log('挂载后执行');
console.log('每次更新都执行');
});
useEffect(() => {
console.log('只会在挂载后执行');
}, []);
useEffect(() => {
console.log('挂载后执行');
console.log('todos 更新后执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
console.log('组件函数运行,组件准备渲染');
const countBy = () => {
setCount(count + 1);
};
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: +Date.now(), text, completed: false },
...todos,
]);
// 这里读取到的 todos 仍然是更新前的旧快照
localStorage.setItem('todos', JSON.stringify(todos));
};
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo,
),
);
};
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const clearCompleted = () => {
setTodos(todos.filter((todo) => !todo.completed));
};
const activeCount = todos.filter((todo) => !todo.completed).length;
const completedCount = todos.length - activeCount;
return (
<div>
Count: {count}
<button onClick={countBy}>count++</button>
{count % 2 === 0 && <Demo />}
<h1>My Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
};
export default App;
2.2 TodoInput.jsx
js
import { useState } from 'react';
const TodoInput = ({ onAdd }) => {
console.log(onAdd);
const [inputValue, setInputValue] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
onAdd(inputValue);
setInputValue('');
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
placeholder="What needs to be done?"
autoFocus
/>
<button type="submit">Add</button>
</form>
);
};
export default TodoInput;
2.3 TodoList.jsx
js
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>
);
};
export default TodoList;
2.4 TodoStats.jsx
js
const TodoStats = ({
total,
active,
completed,
onClearCompleted,
}) => {
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>
);
};
export default TodoStats;
3. 函数组件生命周期:函数执行不等于副作用执行
React 函数组件的生命周期可以从三个阶段理解:
text
挂载 Mount
↓
组件函数执行,React 生成并提交初始 UI
↓
更新 Update
↓
状态或 props 变化,组件函数重新执行
↓
卸载 Unmount
↓
组件从页面移除,执行 effect 返回的清理函数
需要区分两个概念:
- 组件函数执行 :React 为了得到新的 JSX,会重新调用
App或Demo函数。 - 副作用执行 :
useEffect的回调会在渲染提交后执行,用于同步外部系统。
例如点击 count++ 时,App 会重新执行;但这并不意味着四个 useEffect 都按照同一种规则执行。每个 effect 的执行时机由它自己的依赖项数组决定。
4. useEffect 到底解决什么问题
渲染阶段应该尽量只做计算:读取状态、计算数据、返回 JSX。定时器、网络请求、浏览器存储和事件监听都属于与 React 外部世界交互的操作,称为副作用。
useEffect 的基本结构是:
js
useEffect(() => {
// 渲染提交后执行的副作用
return () => {
// 下一次 effect 执行前,或组件卸载前执行的清理逻辑
};
}, [依赖项]);
执行顺序不是"组件函数执行到 useEffect 就立刻执行回调",而是:
text
组件函数执行
↓
React 得到 JSX
↓
React 提交 DOM 更新
↓
useEffect 回调执行
这样设计可以避免在渲染尚未完成时就修改外部系统。
5. 四种依赖项写法
5.1 [count]:挂载后执行,count 改变后再次执行
代码如下:
js
useEffect(() => {
console.log('挂载后执行');
console.log('count改变也会执行');
}, [count]);
依赖数组中的 count 表示:这个 effect 依赖 count。因此它有两个特点:
- 首次挂载并完成 DOM 提交后执行一次。
- 后续渲染中,如果
count与上一次不同,再执行一次。
点击按钮时,setCount(count + 1) 提交新状态,App 重新执行,count 变化,于是这个 effect 再次执行。修改 todos 不会触发它,因为依赖数组中没有 todos。
| 操作 | App 是否重新执行 |
[count] effect 是否执行 |
|---|---|---|
| 首次打开页面 | 是 | 是 |
点击 count++ |
是 | 是 |
| 添加 Todo | 是 | 否,除非 count 同时变化 |
5.2 不传依赖数组:每次提交更新后执行
js
useEffect(() => {
console.log('挂载后执行');
console.log('每次更新都执行');
});
没有第二个参数时,effect 会在每次渲染提交后执行:首次挂载执行,count 改变执行,todos 改变也执行。
但这里的"每次更新"指的是每次组件完成一次渲染提交,不是组件函数中每执行一行代码就执行一次。它适合确实需要响应所有提交的场景,不适合随意放置昂贵计算,否则每次交互都会重复执行。
5.3 []:只在挂载后执行一次
js
useEffect(() => {
console.log('只会在挂载后执行');
}, []);
空依赖数组表示这个 effect 没有需要跟踪的响应式依赖。在正常生产运行中,它会在组件首次挂载并提交 DOM 后执行一次;后续 count 或 todos 改变时不会重复执行。
常见用途包括:首次读取外部资源、注册一次性的订阅、初始化第三方库。但如果 effect 中使用了会变化的状态或 props,就不能机械地写空数组,需要认真判断依赖关系。
5.4 [todos]:任务变化后持久化
js
useEffect(() => {
console.log('挂载后执行');
console.log('todos 更新后执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
这个 effect 的目的,是把 React 内存中的 todos 同步到浏览器的 localStorage。它会在首次挂载后保存初始任务,也会在添加、勾选、删除和清理任务后重新保存。
这里必须使用 JSON.stringify,因为 localStorage 的值只能以字符串形式保存:
javascript
localStorage.setItem('todos', JSON.stringify(todos));
const savedTodos = JSON.parse(localStorage.getItem('todos'));
保存流程是:数组先转换成 JSON 字符串,浏览器保存字符串;下次初始化时再把字符串解析回数组。
6. localStorage 懒初始化与数据持久化
6.1 为什么读取操作放在 useState 函数初始值中
当前代码使用:
js
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos'));
});
这段代码的意图是:组件首次初始化时读取本地任务,后续重新渲染不重复读取。箭头函数是懒初始化函数,只有状态初始化阶段才会执行。
但当前写法有一个实际问题:第一次打开页面时,如果浏览器中还没有 todos,localStorage.getItem('todos') 返回 null,JSON.parse(null) 的结果也是 null。后面执行 todos.filter(...) 时就会报错,因为 null 没有 filter 方法。
更安全的写法是给空值准备默认数组:
js
const [todos, setTodos] = useState(() => {
const savedTodos = localStorage.getItem('todos');
return savedTodos ? JSON.parse(savedTodos) : [];
});
也可以使用空值合并:
js
const [todos, setTodos] = useState(() =>
JSON.parse(localStorage.getItem('todos')) ?? [],
);
6.2 为什么不能在 setTodos 后立即保存旧数组
当前 addTodo 中有这样的代码:
js
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: +Date.now(), text, completed: false },
...todos,
]);
localStorage.setItem('todos', JSON.stringify(todos));
};
这里的 todos 仍然是当前这次渲染的状态快照。调用 setTodos 只是提交更新请求,不会立即把当前作用域中的 todos 改成新数组。因此紧接着保存的仍然是旧数据,新添加的任务可能不会被保存。
正确做法是删除 addTodo 中的直接保存,让 [todos] effect 统一负责持久化(函数式更新):
js
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos((currentTodos) => [
{ id: Date.now(), text: text.trim(), completed: false },
...currentTodos,
]);
};
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
这样,React 先完成状态更新,再在新的 todos 已经成为当前状态后运行 effect,保存的就是最新数组。
7. Demo:挂载、定时器与卸载清理
7.1 count 如何控制 Demo 的存在
App 中有一段条件渲染:
js
{count % 2 === 0 && <Demo />}
它使用逻辑与表达式表示:当 count 是偶数时渲染 Demo,当 count 是奇数时不渲染 Demo。
初始 count 是 0,因此 Demo 首次挂载;点击一次按钮后 count 变成 1,条件变为假,React 会把 Demo 从页面和组件树中移除;再次点击变成 2,Demo 重新挂载。
生命周期变化如下:
text
count = 0 → Demo 挂载 → effect 创建 interval
count = 1 → Demo 卸载 → cleanup 清除 interval
count = 2 → Demo 再次挂载 → effect 创建新的 interval
7.2 为什么定时器必须清理
Demo 中的 effect:
js
useEffect(() => {
const interval = setInterval(() => {
console.log('interval,is here');
}, 1000);
return () => {
console.log('组件卸载前执行,做什么内存清理工作');
clearInterval(interval);
};
}, []);
effect 创建了一个每秒执行的定时器。定时器属于浏览器外部资源,即使 Demo 从页面移除,浏览器也不会因为组件消失而自动停止它。如果不调用 clearInterval:
- 组件已经看不见了,控制台仍然持续打印。
- 每次重新挂载都会新增一个定时器。
- 多次挂载和卸载后可能同时运行多个定时器。
- 外部回调仍然引用组件相关数据,造成资源无法及时释放。
因此,useEffect 返回的函数就是清理函数。它会在组件卸载前执行;如果 effect 由于依赖变化需要重新建立,也会先清理旧 effect,再运行新的 effect。
凡是 effect 创建了定时器、事件监听、订阅或连接,就应该在返回函数中释放对应资源。
这就是函数组件中对应类组件 componentWillUnmount 的常见写法。
8. Todo 子组件如何参与生命周期更新
TodoInput、TodoList 和 TodoStats 本身没有保存完整任务数组,它们通过 props 接收父组件数据和回调:
js
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
以勾选任务为例:
TodoList根据todos渲染受控复选框。- 用户点击复选框,执行
onToggle(todo.id)。 - 实际执行的是
App中的toggleTodo。 setTodos提交新的任务数组。App重新执行,重新计算activeCount和completedCount。- 新的
todos和统计值通过props传给子组件。 [todos]effect 在提交更新后把新数组保存到localStorage。
数据流可以表示为:
text
App 持有 todos
↓ props
TodoList 展示任务
↓ 回调
用户勾选或删除
↓
App 修改 todos
↓
React 重新渲染并运行 [todos] effect
↓
子组件更新 + localStorage 持久化
9. 当前代码的改进版关键片段
为了避免空值、旧快照和连续更新问题,核心逻辑可以调整为:
js
const [todos, setTodos] = useState(() => {
const savedTodos = localStorage.getItem('todos');
return savedTodos ? JSON.parse(savedTodos) : [];
});
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
const addTodo = (text) => {
const normalizedText = text.trim();
if (!normalizedText) return;
setTodos((currentTodos) => [
{
id: Date.now(),
text: normalizedText,
completed: false,
},
...currentTodos,
]);
};
const toggleTodo = (id) => {
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo,
),
);
};
这里的改动并不是改变功能,而是让代码更符合 React 的状态更新模型:初始化时保证得到数组,持久化统一放在 effect 中,新状态依赖旧状态时使用函数式更新,输入内容保存前先去除首尾空格。
10. 常见误区
- 把
useEffect当成普通函数使用。它的回调是在渲染提交之后运行,不是执行到这一行就立即运行。 - 误以为
useEffect(..., [])在开发环境控制台绝对只打印一次。StrictMode可能为了检查副作用而进行额外的开发期调用,不能把开发日志直接等同于生产行为。 - 在
setTodos后立即读取todos,以为拿到的是新数组。当前渲染中的状态仍是旧快照。 - 从
localStorage读取后不处理null,导致首次运行时todos.filter报错。 - 创建
setInterval却没有clearInterval,造成定时器不断累积。 - 把
filtered、统计数量等可由现有状态计算出的值再次存进useState,增加同步成本。 - 让子组件直接修改父组件传入的数组,破坏父组件统一管理状态的边界。
总结
这次 Todo 实战的重点从单纯的状态管理扩展到了副作用与生命周期。App 在首次挂载时通过懒初始化从 localStorage 读取任务,状态更新后通过 [todos] effect 持久化最新数据;[count]、无依赖数组和空依赖数组分别展示了 effect 的不同执行范围。条件渲染让 count 控制 Demo 的挂载与卸载,定时器则通过 effect 返回的清理函数释放资源。理解这些过程后,可以明确区分组件函数重新执行、DOM 提交、effect 执行和卸载清理,并能使用单向数据流组织 Todo 子组件。实际开发中还要处理本地存储为空、状态更新旧快照和副作用清理等问题,才能让组件行为稳定可靠。