Skip to content

Lesson 05:持久化 + 编辑 — useEffect 处理副作用 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 1(基础篇)
  • 推荐时长:60~90 分钟(首次学习)
  • 先修要求:完成 Lesson 04,已有添加、完成、删除和筛选功能
  • 学习产出:持久化任务、处理存储失败,并实现可取消的文字编辑
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:用 useEffect + localStorage 实现数据持久化,添加双击编辑功能。

📦 本节产出:刷新页面数据不丢失,可以双击编辑任务文字。

一、什么是副作用(Side Effect)? ​

React 组件的核心职责是:输入 Props/State → 输出 UI。读写外部系统、订阅或操作 DOM 等行为属于副作用。用户点击触发的操作通常放在事件处理函数中,需要随组件显示或依赖变化保持同步的逻辑才放进 Effect:


二、useEffect 基础 ​

2.1 语法 ​

tsx
useEffect(() => {
  // 副作用代码
  console.log('执行副作用')

  return () => {
    // 清理函数(可选)
    console.log('清理')
  }
}, [dep1, dep2])  // 依赖数组

2.2 依赖数组决定执行时机 ​

tsx
// 每次渲染都执行(几乎不用)
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 保存数据 ​

先新增存储模块,校验读取的数据,并处理浏览器禁用存储或存储空间不足的情况:

ts
// 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 保留:

tsx
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 下方加入提示:

tsx
{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 需要"撤销"之前的操作时,返回清理函数:

tsx
// 例子:监听窗口大小
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 ​

tsx
// 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 TodoItem

4.2 App.tsx 添加 editTodo ​

tsx
// 在 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 ​

tsx
// 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 元素 ​

tsx
const inputRef = useRef<HTMLInputElement>(null)
// inputRef.current 指向 <input> DOM 元素

<input ref={inputRef} />

// 之后可以操作 DOM
inputRef.current?.focus()
inputRef.current?.select()

用途 2:可变容器(存储跨渲染的值,但不触发重新渲染!) ​

useRef 本质是一个在整个组件生命周期中持久存在的可变盒子。

场景 A:存储 Timer ID

tsx
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 来决定界面。需要显示价格变化时,可以保存上一轮价格:

tsx
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:避免闭包陷阱(存储最新的回调)

tsx
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 位置计算 ​

tsx
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(数据获取) ​

tsx
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 ​

tsx
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 与异步数据。


七、练习 ​

  1. filter 也持久化:用 useEffect 把 filter 的值也保存到 localStorage
  2. 添加时间戳:给 Todo 加 createdAt: number 字段,显示"X 分钟前"
  3. 批量编辑:添加"全部标记完成/全部取消"的切换按钮
  4. 思考:为什么 useEffect 的依赖数组很重要?如果漏掉依赖会怎样?

📌 本节小结 ​

你做了什么你学到了什么
用 useEffect + localStorage 持久化useEffect 语法和依赖数组
实现双击编辑功能useRef 访问 DOM 元素
编辑状态的本地 state 管理组件内部 state vs 提升的 state
—清理函数和 StrictMode 行为
—React 19 use() + Suspense 概念

项目驱动 · 边写边学 · React 19