Lesson 05:持久化 + 编辑 — useEffect 处理副作用
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 1(基础篇)
- 推荐时长:60~90 分钟(首次学习)
- 先修要求:完成 Lesson 04,已有添加、完成、删除和筛选功能
- 学习产出:持久化任务、处理存储失败,并实现可取消的文字编辑
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:用
useEffect+localStorage实现数据持久化,添加双击编辑功能。📦 本节产出:刷新页面数据不丢失,可以双击编辑任务文字。
一、什么是副作用(Side Effect)?
React 组件的核心职责是:输入 Props/State → 输出 UI。读写外部系统、订阅或操作 DOM 等行为属于副作用。用户点击触发的操作通常放在事件处理函数中,需要随组件显示或依赖变化保持同步的逻辑才放进 Effect:
二、useEffect 基础
2.1 语法
useEffect(() => {
// 副作用代码
console.log('执行副作用')
return () => {
// 清理函数(可选)
console.log('清理')
}
}, [dep1, dep2]) // 依赖数组2.2 依赖数组决定执行时机
// 每次渲染都执行(几乎不用)
useEffect(() => { console.log('rendered') })
// 挂载后执行;StrictMode 开发检查会额外执行 setup/cleanup
useEffect(() => { console.log('mounted') }, [])
// count 变化时执行
useEffect(() => { document.title = `Count: ${count}` }, [count])2.3 执行时序
NOTE
useEffect 在提交之后执行;非交互触发时通常允许浏览器先绘制,交互触发时也可能在绘制前运行。Effect 中的同步耗时工作仍会占用主线程。 useLayoutEffect 同样在 DOM 更新之后执行,但会阻塞浏览器绘制,适用于测量后立即调整布局。参见 useEffect 与 useLayoutEffect。
三、实战:localStorage 持久化
3.1 保存数据
先新增存储模块,校验读取的数据,并处理浏览器禁用存储或存储空间不足的情况:
// src/storage.ts
import type { Todo } from './types'
export function loadTodos(): Todo[] {
try {
const saved = localStorage.getItem('todos')
if (saved === null) return [{ id: 1, text: '学习 React 19', completed: false }]
const value: unknown = JSON.parse(saved)
if (!Array.isArray(value)) return []
const valid = value.every((item: unknown) => {
if (typeof item !== 'object' || item === null) return false
const todo = item as Record<string, unknown>
return typeof todo.id === 'number' && Number.isSafeInteger(todo.id)
&& typeof todo.text === 'string' && typeof todo.completed === 'boolean'
})
if (!valid || new Set(value.map(item => item.id)).size !== value.length) return []
return value as Todo[]
} catch {
return []
}
}在 Lesson 04 的 App.tsx 中替换初始化部分,并加入保存 Effect;其他状态、处理函数与 JSX 保留:
import { useState, useEffect } from 'react'
import { loadTodos } from './storage'
// 保留原有类型和组件导入
// 以下代码放在 App 函数内
const [todos, setTodos] = useState<Todo[]>(loadTodos)
const [storageError, setStorageError] = useState('')
/* eslint-disable react-hooks/set-state-in-effect -- 反馈外部存储写入结果,允许一次额外渲染 */
useEffect(() => {
try {
localStorage.setItem('todos', JSON.stringify(todos))
setStorageError('')
} catch {
setStorageError('本次修改未能保存,刷新页面可能丢失。请检查浏览器存储设置。')
}
}, [todos])
/* eslint-enable react-hooks/set-state-in-effect */写入结果只能在尝试保存后得知,因此这里允许一次用于显示结果的额外渲染,并只对这个 Effect 放宽 set-state-in-effect 检查。相同错误字符串不会持续触发更新,不需要关闭整个项目的 Hook 检查。
在 App 的 JSX 中、Header 下方加入提示:
{storageError && <p role="alert">{storageError}</p>}这个本地练习会把无法识别的旧数据视为空列表,随后保存当前列表;需要恢复旧数据时,先备份浏览器中的 todos 项。它不提供多标签页冲突合并或云端备份。
存储写入成功后,刷新页面可以恢复任务。
TIP
关于 SSR(服务端渲染)的伏笔: 目前我们是 Vite 构建的纯客户端应用,可以直接这样初始化读取 localStorage。 但如果在未来 Phase 3 中使用 Next.js 等框架做 SSR,首屏是跑在 Node 环境里的,没有 localStorage,未经保护的读取会抛出 localStorage is not defined;本例虽用 catch 返回空列表,服务端与客户端初始数据不同仍可能造成 hydration mismatch;Lesson 23 会处理这一问题。
3.2 清理函数
当 Effect 需要"撤销"之前的操作时,返回清理函数:
// 例子:监听窗口大小
useEffect(() => {
const handleResize = () => console.log(window.innerWidth)
window.addEventListener('resize', handleResize)
// 清理函数:组件卸载时移除监听
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
// 例子:定时器
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(id) // 清理定时器
}, [])WARNING
根节点启用 StrictMode 时,开发环境会在首次正式 setup 前额外执行一次 setup → cleanup,检查清理是否对称。 这不代表组件状态真的被清空后重建,也不是“Effect 永远只执行两次”;之后依赖变化和真正重新挂载仍会触发它。
四、实战:双击编辑功能
4.1 更新 TodoItem
// src/components/TodoItem.tsx
import { useState, useRef, useEffect } from 'react'
import type { Todo } from '../types'
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
onEdit: (id: number, newText: string) => void // 新增
}
function TodoItem({ todo, onToggle, onDelete, onEdit }: TodoItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editText, setEditText] = useState(todo.text)
const inputRef = useRef<HTMLInputElement>(null)
const skipBlurRef = useRef(false)
// 进入编辑模式时自动聚焦
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
const handleDoubleClick = () => {
if (!todo.completed) {
skipBlurRef.current = false
setIsEditing(true)
setEditText(todo.text)
}
}
const handleSubmit = () => {
if (skipBlurRef.current) return
skipBlurRef.current = true
const trimmed = editText.trim()
if (trimmed && trimmed !== todo.text) {
onEdit(todo.id, trimmed)
}
// 小贴士:如果用户清空了输入框并回车,我们选择不触发修改,
// 直接退出编辑状态并保留原文字。
setIsEditing(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
if (e.key === 'Escape') {
skipBlurRef.current = true
setEditText(todo.text)
setIsEditing(false)
}
}
return (
<div className="flex items-center gap-3 p-4 bg-white rounded-xl border border-gray-100
shadow-sm hover:shadow-md transition-all group">
<input
type="checkbox"
checked={todo.completed}
aria-label={`完成任务:${todo.text}`}
onChange={() => onToggle(todo.id)}
className="w-5 h-5 rounded-md border-gray-300 text-indigo-600
focus:ring-indigo-500/20 cursor-pointer"
/>
{isEditing ? (
// 编辑模式
<input
ref={inputRef}
aria-label="编辑任务文字"
value={editText}
onChange={e => setEditText(e.target.value)}
onBlur={handleSubmit}
onKeyDown={handleKeyDown}
className="flex-1 px-2 py-1 border border-indigo-400 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
) : (
// 显示模式
<span
onDoubleClick={handleDoubleClick}
role="button"
tabIndex={todo.completed ? -1 : 0}
aria-disabled={todo.completed}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleDoubleClick()
}
}}
className={`flex-1 transition-colors cursor-default ${
todo.completed ? 'line-through text-gray-400' : 'text-gray-800'
}`}
title="双击或按 Enter 编辑未完成任务"
>
{todo.text}
</span>
)}
<button
onClick={() => onDelete(todo.id)}
aria-label={`删除任务:${todo.text}`}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 text-gray-400
hover:text-red-500 transition-all text-lg"
>
✕
</button>
</div>
)
}
export default TodoItem4.2 App.tsx 添加 editTodo
// 在 App.tsx 中添加
const editTodo = (id: number, newText: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
)
}
// TodoList 和 TodoItem 需要传递 onEdit
<TodoList todos={filteredTodos} onToggle={toggleTodo} onDelete={deleteTodo} onEdit={editTodo} />4.3 同步更新 TodoList
// src/components/TodoList.tsx — 添加 onEdit prop
interface TodoListProps {
todos: Todo[]
onToggle: (id: number) => void
onDelete: (id: number) => void
onEdit: (id: number, newText: string) => void // 新增
}
function TodoList({ todos, onToggle, onDelete, onEdit }: TodoListProps) {
// ... 空态不变
return (
<div className="space-y-2">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
onEdit={onEdit} // 传递
/>
))}
</div>
)
}4.4 useRef 深度指南
useRef 主要用于以下两类需求:
用途 1:访问 DOM 元素
const inputRef = useRef<HTMLInputElement>(null)
// inputRef.current 指向 <input> DOM 元素
<input ref={inputRef} />
// 之后可以操作 DOM
inputRef.current?.focus()
inputRef.current?.select()用途 2:可变容器(存储跨渲染的值,但不触发重新渲染!)
useRef 本质是一个在整个组件生命周期中持久存在的可变盒子。
场景 A:存储 Timer ID
function AutoSave({ content, onSave }: { content: string; onSave: (text: string) => void }) {
// ✅ timer ID 不需要渲染到 UI,用 useRef
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
// 每次 content 变化,先取消上一次的定时保存
if (timerRef.current) clearTimeout(timerRef.current)
// 设置新的 2 秒后自动保存
timerRef.current = setTimeout(() => {
onSave(content)
}, 2000)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [content, onSave])
return <span>自动保存中...</span>
}本例只演示防抖调用,onSave 由调用方提供,并应保持稳定;真实异步保存还需显示成功或失败。定时器只在这个 Effect 中使用时,也可以用局部变量保存 ID,无须 ref。
场景 B:UI 需要比较前后值时,使用 state
不要在渲染过程中读取可变 ref 来决定界面。需要显示价格变化时,可以保存上一轮价格:
function PriceDisplay({ price }: { price: number }) {
const [snapshot, setSnapshot] = useState({ current: price, previous: price })
if (snapshot.current !== price) {
setSnapshot({ current: price, previous: snapshot.current })
}
const diff = price - snapshot.previous
return <span>¥{price} {diff > 0 ? '📈' : diff < 0 ? '📉' : ''}</span>
}这是对同一组件 state 的有条件调整,条件在下一轮渲染会变为 false;不要无条件调用 setter。常规业务更适合让数据源直接提供前后两次价格。
场景 C:避免闭包陷阱(存储最新的回调)
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
// 这里要求回调变化不重新启动搜索计时器
const onSearchRef = useRef(onSearch)
useEffect(() => {
onSearchRef.current = onSearch
}, [onSearch])
useEffect(() => {
const timer = setTimeout(() => {
onSearchRef.current(query) // 读取最近一次 Effect 同步的回调
}, 300)
return () => clearTimeout(timer)
}, [query])
return <input aria-label="搜索" value={query} onChange={e => setQuery(e.target.value)} />
}IMPORTANT
useState vs useRef 决策表:
| 问题 | 选择 |
|---|---|
| 这个值变了,UI 需要更新吗? | 是 → useState |
| 这个值只是内部使用(timer、前值、DOM)? | 是 → useRef |
| 需要在 useEffect 中读取"最新值"而不是闭包捕获的旧值? | 是 → useRef |
五、useLayoutEffect — 同步 DOM 测量
在 L05 开头我们学了 useEffect。它在提交后执行,通常允许浏览器先绘制,但并非总在绘制之后。 但有一种场景需要在绘制之前同步执行:读取 DOM 尺寸后立即调整布局。
上图展示常见的非交互更新顺序,不是所有 Effect 的固定时间表。
实战场景:Tooltip 位置计算
import { useLayoutEffect, useRef, useState } from 'react'
function Tooltip({ text, targetRef }: { text: string; targetRef: React.RefObject<HTMLElement | null> }) {
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState({ top: 0, left: 0 })
// 必须用 useLayoutEffect!
// 如果用 useEffect,用户会先看到 tooltip 出现在 (0,0) 位置,然后跳到正确位置(闪烁!)
useLayoutEffect(() => {
if (!targetRef.current || !tooltipRef.current) return
const targetRect = targetRef.current.getBoundingClientRect()
const tooltipRect = tooltipRef.current.getBoundingClientRect()
setPosition({
top: targetRect.top - tooltipRect.height - 8,
left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
})
}, [targetRef, text])
return (
<div ref={tooltipRef} style={{ position: 'fixed', top: position.top, left: position.left }}
className="bg-gray-900 text-white text-xs px-3 py-1.5 rounded-lg">
{text}
</div>
)
}WARNING
useLayoutEffect 会阻塞浏览器绘制! 如果你在里面做了耗时操作(如大量计算),用户会感到页面卡顿。只在需要"测量 DOM → 立即调整"的场景使用。本例只演示初次测量;可滚动页面还要处理滚动、窗口缩放和视口边缘定位。
六、🧠 深度专题:React 19 的 use() API
React 19 新增了 use() —— 一个可以在组件或 Hook 内的条件、循环中调用的 API:
6.1 use() + Promise(数据获取)
import { use, Suspense } from 'react'
type User = { id: number; name: string }
// 纯客户端教学数据:模块加载时创建,保持 Promise 身份稳定
const userPromise: Promise<User> = new Promise(resolve => {
setTimeout(() => resolve({ id: 1, name: 'Alice' }), 800)
})
// use() 可以"解包" Promise
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // pending 时挂起组件,显示最近的 Suspense fallback
return <h1>{user.name}</h1>
}
// 配合 Suspense 使用
function App() {
return (
<Suspense fallback={<p>加载中...</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}不要在客户端组件渲染时直接调用 fetchUser() 创建新的 Promise。真实请求应使用支持 Suspense 的数据层,或由服务端组件创建后传入;拒绝的 Promise 交给 Error Boundary 处理。参考 React use API。
6.2 use() + Context
import { use, createContext } from 'react'
const ThemeContext = createContext('light')
function Button({ someCondition }: { someCondition: boolean }) {
// 传统:useContext(ThemeContext) —— 不能在 if 里用
// React 19:use(ThemeContext) —— 可以在条件中使用!
if (someCondition) {
const theme = use(ThemeContext)
return <button className={theme}>Click</button>
}
return <button>Default</button>
}6.3 Suspense 机制
NOTE
use() 是 React 19 的新特性,和 useEffect 定位不同:
useEffect:组件渲染后执行副作用(现在仍然常用)use():在渲染过程中直接等待异步数据(配合 Suspense)
Phase 2 的普通 useQuery 自行管理加载状态,不需要 use();Phase 3 会继续讨论 Server Components 与异步数据。
七、练习
- filter 也持久化:用
useEffect把filter的值也保存到 localStorage - 添加时间戳:给 Todo 加
createdAt: number字段,显示"X 分钟前" - 批量编辑:添加"全部标记完成/全部取消"的切换按钮
- 思考:为什么
useEffect的依赖数组很重要?如果漏掉依赖会怎样?
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 用 useEffect + localStorage 持久化 | useEffect 语法和依赖数组 |
| 实现双击编辑功能 | useRef 访问 DOM 元素 |
| 编辑状态的本地 state 管理 | 组件内部 state vs 提升的 state |
| — | 清理函数和 StrictMode 行为 |
| — | React 19 use() + Suspense 概念 |