现在位置: 首页 > React 教程 > 正文

副作用与数据请求 — useEffect

本章你将学会 React 最重要的副作用 Hook——useEffect,并在恰当的时机用 fetch 从 JSON 文件加载博客数据。


什么是副作用?

在 React 中,副作用(Side Effect) 指的是那些与渲染无关的操作:

  • 向服务器请求数据
  • 操作 localStorage
  • 设置定时器 setInterval
  • 手动修改 DOM
  • 订阅外部事件

副作用不能在组件函数体里直接执行(会导致每次渲染都执行),必须放在 useEffect 中。


useEffect 基本用法

实例

import { useState, useEffect } from 'react'

function HomePage() {
  const [message, setMessage] = useState('加载中...')

  // useEffect(fn, deps):在组件渲染后执行副作用
  useEffect(() => {
    console.log('组件已挂载到 DOM,可以执行副作用了')
    setMessage('数据加载完成!')
  }, [])  // 空依赖数组 = 仅在首次渲染后执行一次

  return <p>{message}</p>
}

依赖数组的三种写法

写法执行时机使用场景
useEffect(fn)每次渲染后都执行极少使用,容易造成死循环
useEffect(fn, [])仅首次渲染后执行一次数据加载、初始化第三方库
useEffect(fn, [dep])首次渲染后 + dep 变化时执行监听特定数据变化,执行响应操作

省略依赖数组 vs 空数组的区别:useEffect(fn) 每次渲染后都执行;useEffect(fn, []) 只在首次渲染后执行一次。前者极易造成死循环(effect 中 setState → 触发渲染 → 再执行 effect → ...),几乎从来不用。


用 fetch 加载 JSON 数据

与 Vue3 一样,我们把文章数据放到 public/posts.json 文件中。

第一步:创建数据文件

实例

// 文件路径:public/posts.json
[
  {
    "id": 1,
    "title": "React 入门完全指南",
    "summary": "从零开始学习 React Hooks,涵盖 useState、useEffect 等核心概念。",
    "content": "<h2>为什么学 React?</h2><p>React 是目前最流行的前端框架之一...</p>",
    "category": "React",
    "date": "2024-05-10"
  },
  {
    "id": 2,
    "title": "JavaScript 异步编程详解",
    "summary": "一文搞懂 Promise、async/await、事件循环与微任务队列。",
    "content": "<h2>什么是异步?</h2><p>JS 是单线程的...</p>",
    "category": "JavaScript",
    "date": "2024-05-08"
  },
  {
    "id": 3,
    "title": "CSS Grid 布局实战",
    "summary": "用 CSS Grid 轻松实现复杂的响应式布局。",
    "content": "<h2>Grid 入门</h2><p>Grid 是二维布局系统...</p>",
    "category": "CSS",
    "date": "2024-05-05"
  }
]

第二步:在 useEffect 中加载数据

实例

// 文件路径:src/pages/HomePage.jsx
import { useState, useEffect, useMemo } from 'react'
import BlogCard from '../components/BlogCard'
import CategoryFilter from '../components/CategoryFilter'

function HomePage() {
  const [articles, setArticles] = useState([])   // 文章数据
  const [isLoading, setIsLoading] = useState(true) // 加载状态
  const [error, setError] = useState(null)        // 错误信息
  const [activeCategory, setActiveCategory] = useState('全部')

  // useEffect 加载数据:空依赖数组 = 仅在首次渲染后执行一次
  useEffect(() => {
    let cancelled = false  // 防止组件卸载后 setState 的标记

    async function fetchPosts() {
      setIsLoading(true)
      setError(null)
      try {
        const res = await fetch('/posts.json')
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const data = await res.json()
        if (!cancelled) {
          setArticles(data)
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message)
        }
      } finally {
        if (!cancelled) {
          setIsLoading(false)
        }
      }
    }

    fetchPosts()

    // 清理函数:组件卸载时将 cancelled 设为 true
    return () => { cancelled = true }
  }, [])

  const categories = useMemo(() => {
    return ['全部', ...new Set(articles.map(a => a.category))]
  }, [articles])

  const filteredArticles = useMemo(() => {
    if (activeCategory === '全部') return articles
    return articles.filter(a => a.category === activeCategory)
  }, [articles, activeCategory])

  return (
    <div>
      <h2 className="section-title">最新文章</h2>

      {/* 加载中 */}
      {isLoading && <p className="status-msg">加载中,请稍候...</p>}

      {/* 加载出错 */}
      {error && (
        <div className="status-msg error">
          <p>加载失败:{error}</p>
          <button onClick={() => window.location.reload()}>重试</button>
        </div>
      )}

      {/* 数据加载完成 */}
      {!isLoading && !error && (
        <>
          <CategoryFilter
            categories={categories}
            activeCategory={activeCategory}
            onCategoryChange={setActiveCategory}
          />
          <p className="result-info">{filteredArticles.length}</p>
          {filteredArticles.length === 0 ? (
            <p className="empty-tip">该分类下暂无文章</p>
          ) : (
            <div className="article-grid">
              {filteredArticles.map(article => (
                <BlogCard key={article.id} {...article} />
              ))}
            </div>
          )}
        </>
      )}
    </div>
  )
}

export default HomePage

cancelled 标记是 React 中的常见模式。当 fetch 还在进行中时,如果用户离开页面(组件卸载),后续的 setState 会报错警告。cleanup 函数中设置 cancelled = true 可以在卸载后跳过 setState。


清理函数(Cleanup)

useEffect 的回调函数可以返回一个函数,称为 清理函数

它在两个时机执行:组件卸载时下一次 effect 执行前

实例

useEffect(() => {
  // 副作用:订阅事件
  const handleScroll = () => console.log('滚动了')
  window.addEventListener('scroll', handleScroll)

  // 返回清理函数
  return () => {
    // 组件卸载时自动移除事件监听,防止内存泄漏
    window.removeEventListener('scroll', handleScroll)
  }
}, [])

useEffect(() => {
  // 副作用:设置定时器
  const timer = setInterval(() => {
    console.log('每秒执行')
  }, 1000)

  return () => clearInterval(timer)  // 清理定时器
}, [])
副作用需要清理?清理方式
fetch 请求用 cancelled 标记或 AbortController
事件监听removeEventListener
定时器clearInterval / clearTimeout
修改 DOM 后恢复在 cleanup 中恢复原状
localStorage 写入不需要清理

处理异步请求的四种状态

任何数据请求都应覆盖以下四种 UI 状态:

状态条件UI
加载中isLoading === trueloading 动画、骨架屏
成功数据已返回正常渲染内容
失败error !== null错误提示 + 重试按钮
空数据数据为空数组空态提示

VS Vue3 生命周期对照

Vue3React说明
onMounteduseEffect(fn, [])首次渲染后执行
watch(keyword, fn)useEffect(fn, [keyword])响应特定数据变化
onBeforeUnmountuseEffect 的 cleanup组件销毁前清理
没有直接对应useEffect(() => { fn; return cleanup }, [dep])dep 变化时先 cleanup 再 fn

本章小结

本章你掌握了 useEffect 的核心用法:依赖数组的三种写法、用 fetch 异步加载数据、四种 UI 状态处理、以及 cleanup 清理函数的作用。

现在博客数据从外部 JSON 动态加载,不再硬编码在 JS 中。