副作用与数据请求 — 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>
}
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"
}
]
[
{
"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
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) // 清理定时器
}, [])
// 副作用:订阅事件
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 === true | loading 动画、骨架屏 |
| 成功 | 数据已返回 | 正常渲染内容 |
| 失败 | error !== null | 错误提示 + 重试按钮 |
| 空数据 | 数据为空数组 | 空态提示 |
VS Vue3 生命周期对照
| Vue3 | React | 说明 |
|---|---|---|
onMounted | useEffect(fn, []) | 首次渲染后执行 |
watch(keyword, fn) | useEffect(fn, [keyword]) | 响应特定数据变化 |
onBeforeUnmount | useEffect 的 cleanup | 组件销毁前清理 |
| 没有直接对应 | useEffect(() => { fn; return cleanup }, [dep]) | dep 变化时先 cleanup 再 fn |
本章小结
本章你掌握了 useEffect 的核心用法:依赖数组的三种写法、用 fetch 异步加载数据、四种 UI 状态处理、以及 cleanup 清理函数的作用。
现在博客数据从外部 JSON 动态加载,不再硬编码在 JS 中。
