第9章 useEffect与副作用
Chapter-09 - useEffect——副作用的正确打开方式
9.1 useEffect 基础
如果把 React 组件比作一个"加工厂",那 useEffect 就是这个加工厂的"生产许可证"——它告诉 React:“除了正常生产产品(渲染 UI)之外,我还需要做一些别的事情,比如:采购原材料(请求数据)、处理废水废气(清理订阅)、和外面的世界打交道(操作 DOM、播放音乐、记录日志)。” useEffect 让函数组件拥有了"和外界互动"的能力。
9.1.1 useEffect 的基本概念:副作用的容器
**副作用(Side Effect)**是什么?简单说就是:除了返回值之外,对外部环境产生的影响。
纯函数:y = f(x) — 相同的输入,永远得到相同的输出,不产生任何副作用。
有副作用的函数:修改全局变量、发送网络请求、操作 DOM、设置定时器、订阅事件、打印日志……
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import { useEffect } from 'react'
function UserProfile({ userId }) {
// useEffect:处理副作用
// 第一个参数:副作用要执行的函数
// 第二个参数:依赖数组
useEffect(() => {
// 这个函数里可以做任何"有副作用"的事情
console.log('开始请求用户数据,userId =', userId)
document.title = `加载中...` // 修改页面标题(副作用)
// 例如发送网络请求
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
console.log('用户数据获取成功:', data)
})
}, [userId]) // 依赖数组:userId 变化时重新执行
return <div>用户资料页面</div>
}
|
📝 useEffect 就是在告诉 React:“我有一段代码,它不只是用来计算 UI 的,它还会做一些其他事情。这段代码在什么时候运行,由依赖数组决定。”
9.1.2 没有依赖数组:每次渲染都执行
如果不写依赖数组,useEffect 的函数会在每次渲染后都执行一次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import { useState, useEffect } from 'react'
function LoggingComponent() {
const [count, setCount] = useState(0)
// ⚠️ 注意:没有依赖数组,每次渲染都会执行(包括首次渲染和更新渲染)
useEffect(() => {
console.log('组件渲染了,当前 count =', count)
// 每次 count 变化,都会触发这条日志
})
return (
<button onClick={() => setCount(count + 1)}>
点我 {count}
</button>
)
}
// 打印顺序:
// 初始渲染:组件渲染了,当前 count = 0
// 点击一次:组件渲染了,当前 count = 1
// 点击两次:组件渲染了,当前 count = 2
|
9.1.3 空依赖数组:[] 只在首次渲染后执行一次
空数组 [] 意味着"这个 effect 不依赖任何状态,初始化一次就够了"。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| function Analytics() {
useEffect(() => {
// ✅ 只在组件首次挂载时执行一次(类似 componentDidMount)
console.log('页面访问了!')
// 通常在这里:发送访问统计、设置页面标题等一次性的初始化工作
document.title = '欢迎来到我的网站'
// 如果 useEffect 返回一个函数,那就是"清理函数"
return () => {
console.log('组件卸载了,清理工作')
}
}, []) // 空数组:只在首次渲染后执行
return <div>页面内容</div>
}
|
9.1.4 指定依赖:[dep1, dep2] 在依赖变化时执行
当依赖数组里有值时,useEffect 会在初始渲染执行一次,之后每当依赖值变化时重新执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| function SearchResults({ query }) {
useEffect(() => {
console.log('搜索词变了,执行搜索:', query)
// 当 query 变化时,重新发起搜索请求
// 模拟搜索请求
const timer = setTimeout(() => {
console.log(`搜索"${query}"的结果已加载`)
}, 500)
// 返回清理函数:清除上一个定时器,避免请求重叠
return () => clearTimeout(timer)
}, [query]) // query 变化时重新执行
return <div>搜索结果页面</div>
}
// 使用
function App() {
const [query, setQuery] = useState('')
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<SearchResults query={query} />
</div>
)
}
|
9.2 依赖数组的正确使用
9.2.1 依赖数组的三大规则
规则一:依赖数组里的值,必须是"在 useEffect 里用到的所有外部值"
1
2
3
4
5
6
7
8
9
| function Search({ keyword, category }) {
useEffect(() => {
// 在这里用了 keyword 和 category
fetchResults(keyword, category)
}, [keyword, category]) // ✅ 两个都加进去
// ❌ 错误:漏了 category
// useEffect(() => { fetchResults(keyword, category) }, [keyword])
}
|
规则二:依赖数组里的值,必须是稳定的(不应该在渲染中创建新引用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| function BadExample() {
const [items, setItems] = useState([])
// ❌ 错误:没有依赖数组(或依赖不对),在 effect 里调用 setState
// 效果:每次渲染后执行 effect → 调用 setItems → 触发新渲染 → 无限循环!
useEffect(() => {
setItems([...items, { id: 1, name: '新物品' }]) // setState 在 effect 里!
}) // 没有依赖数组,每次渲染都执行 → 无限循环!
return <button onClick={() => setItems(items => [...items, { id: 1, name: '新物品' }])}>添加</button>
}
// ✅ 正确:用空数组,只在首次渲染执行一次
function GoodExample() {
const [items, setItems] = useState([])
useEffect(() => {
// 初始化逻辑,只执行一次
console.log('初始化完成')
}, []) // ✅ 空数组:真的不依赖任何东西
return <button onClick={() => setItems(items => [...items, { id: 1, name: '新物品' }])}>添加</button>
}
|
规则三:如果 effect 确实不依赖任何东西,用空数组
9.2.2 ESLint exhaustive-deps 规则的作用
ESLint 的 react-hooks/exhaustive-deps 规则会强制检查你的 useEffect 依赖是否完整。它能帮你发现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| function Component({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
// ❌ ESLint 会警告:缺少依赖 'userId'
useEffect(() => {
setLoading(true)
fetchUser(userId).then(data => {
setUser(data)
setLoading(false)
})
}, []) // ❌ 警告:userId 在 effect 中使用,但没有在依赖数组中!
// ✅ 修复:加上 userId
useEffect(() => {
setLoading(true)
fetchUser(userId).then(data => {
setUser(data)
setLoading(false)
})
}, [userId])
}
|
9.2.3 常见错误:忘记添加依赖导致 bug
场景一:闭包陷阱——定时器里的旧数据
定时器回调函数是一个闭包,它"记住"的是首次渲染时的 count 值。如果依赖数组为空,effect 永不更新,闭包里的 count 就永远是初始值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| function Counter() {
const [count, setCount] = useState(0)
// ❌ 错误:setInterval 的回调里 count 永远是 0
useEffect(() => {
const timer = setInterval(() => {
// 这里的 count 是首次渲染时的值,永远是 0
setCount(count + 1) // count 永远是 0,所以每次都是 setCount(1)
}, 1000)
}, []) // 空数组,effect 永不更新
// ✅ 正确:用函数式更新,或者把 count 加到依赖数组
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1) // 用函数式更新,不依赖外部变量
}, 1000)
return () => clearInterval(timer)
}, [])
}
|
场景二:忘记清理订阅——内存泄漏的元凶
如果 effect 创建了某种"连接"(订阅、定时器、WebSocket等),组件卸载时没有断开这个连接,就会导致内存泄漏。举个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| function ChatRoom({ roomId }) {
useEffect(() => {
// ❌ 错误:没有清理订阅
const connection = createConnection(roomId)
connection.connect() // 建立连接
// 如果 roomId 变了,这个连接没断开,新的又建立了
// 造成连接泄漏!
}, [roomId])
// ✅ 正确:返回清理函数
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => {
connection.disconnect() // 清理:断开旧连接
}
}, [roomId])
}
|
9.2.4 依赖数组常见误解:空数组不代表"只执行一次"
空数组 [] 确实只在首次渲染后执行一次,但要注意:
1
2
3
4
5
6
7
8
9
10
11
12
| function Example() {
useEffect(() => {
console.log('Effect 执行')
return () => console.log('清理函数执行')
}, [])
return <div>示例</div>
}
// 初始渲染:Effect 执行
// 卸载(路由切换等):清理函数执行
// 重新挂载:Effect 再次执行
|
9.3 清理副作用
9.3.1 为什么需要清理?取消订阅、清除定时器
很多副作用在完成后需要清理,否则会造成资源泄漏:
- 定时器:
setInterval / setTimeout → 用 clearInterval / clearTimeout 清理 - 事件监听:
addEventListener → 用 removeEventListener 清理 - WebSocket 连接:
connection.connect() → 用 connection.disconnect() 清理 - 订阅:
subscribe(callback) → 用 unsubscribe() 清理
9.3.2 清理函数的执行时机:下次 effect 执行前 + 卸载时
useEffect 的清理函数在两个时机执行:
- 下次 effect 执行之前(在同一个 effect 重新运行之前)
- 组件卸载时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| function Timer() {
useEffect(() => {
console.log('设置定时器')
const timer = setInterval(() => {
console.log('定时器 tick')
}, 1000)
// 清理函数
return () => {
console.log('清理定时器')
clearInterval(timer)
}
}, [])
return <div>计时器组件</div>
}
// 时机分析:
// 1. 初始挂载:打印 "设置定时器"
// 2. 每秒打印一次:"定时器 tick"
// 3. 卸载时:打印 "清理定时器",定时器停止
|
9.3.3 常见的清理场景:定时器、事件监听、WebSocket、AbortController
定时器清理:
1
2
3
4
5
6
7
8
9
10
11
12
| function AutoSave() {
useEffect(() => {
const timer = setInterval(() => {
saveData()
}, 5000)
// 清理:组件卸载时清除定时器
return () => clearInterval(timer)
}, [])
return <div>自动保存组件</div>
}
|
事件监听清理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| function WindowSize() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
window.addEventListener('resize', handleResize)
// 清理:移除事件监听
return () => window.removeEventListener('resize', handleResize)
}, [])
return <div>窗口宽度:{width}px</div>
}
|
AbortController 清理(网络请求):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
// AbortController 可以取消正在进行的请求
const controller = new AbortController()
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal // 把 signal 传给 fetch
})
const data = await res.json()
setUser(data)
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求被取消了')
} else {
console.error('请求失败', err)
}
}
}
fetchUser()
// 清理:取消请求
return () => controller.abort()
}, [userId])
return <div>{user ? user.name : '加载中...'}</div>
}
|
9.3.4 React 19 中的 ref 清理改进
React 19 改进了 ref 的清理机制——现在可以把清理函数作为 ref callback 的返回值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // React 18 及之前:ref callback 不能返回清理函数
<input
ref={node => {
console.log('挂载了')
// 不能返回清理函数!
}}
/>
// React 19:ref callback 可以返回清理函数
<input
ref={node => {
console.log('挂载了')
// 返回清理函数
return () => console.log('卸载了')
}}
/>
|
9.4 防止无限循环
9.4.1 useEffect 内部 setState 导致无限循环
这是最常见的 useEffect 错误之一:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import { useState, useEffect } from 'react'
function BadComponent({ userId }) {
const [user, setUser] = useState(null)
// ❌ 错误:在 effect 里调用 setState,但没有加依赖
// 导致 effect 每次执行都调用 setState,触发重新渲染,重新渲染又触发 effect……无限循环!
useEffect(() => {
fetchUser(userId).then(setUser) // setUser 触发重新渲染
}) // 没有依赖数组,或者依赖不对 → 无限循环!
// ✅ 正确:加上正确的依赖
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId]) // userId 变化才重新执行
}
|
9.4.2 网络请求放在 useEffect 中的正确姿势
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| function UserList({ search }) {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false // 防止请求完成后组件已卸载的情况
async function load() {
setLoading(true)
setError(null)
try {
const data = await searchUsers(search)
// 只有组件还在挂载时才更新 state
if (!cancelled) {
setUsers(data)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
// 清理函数:组件卸载或 search 变化时执行
return () => {
cancelled = true // 标记"请求作废"
}
}, [search])
if (loading) return <div>加载中...</div>
if (error) return <div>错误:{error}</div>
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
)
}
|
9.4.3 依赖数组的正确设置方法
判断依赖的法则:在 useEffect 内部使用的所有变量,都应该出现在依赖数组里。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| function Example({ userId, enabled }) {
const [data, setData] = useState(null)
useEffect(() => {
if (!enabled) return // enabled 为 false 时不执行
async function fetchData() {
const result = await api.getData(userId)
setData(result)
}
fetchData()
}, [userId, enabled]) // ✅ userId 和 enabled 都用了,都加进来
}
|
9.4.4 useEffect 中的 async/await:常见陷阱与解决方案
陷阱一:useEffect 不能直接是 async 函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // ❌ 错误:useEffect 不能是 async 函数
useEffect(async () => {
const data = await fetchData()
setData(data)
}, [])
// ✅ 正确:在 useEffect 内部定义 async 函数
useEffect(() => {
async function load() {
const data = await fetchData()
setData(data)
}
load()
}, [])
|
陷阱二:async 函数返回的是 Promise,不能当清理函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // ❌ 错误:清理函数不能是 async
useEffect(() => {
async function load() {
const data = await fetchData()
setData(data)
}
load()
return async () => {
// ❌ 这不是正确的清理函数!async 函数返回 Promise
await cancelRequest()
}
}, [])
// ✅ 正确:清理函数应该是同步的
useEffect(() => {
let cancelled = false
async function load() {
const data = await fetchData()
if (!cancelled) setData(data)
}
load()
return () => {
cancelled = true // ✅ 同步的清理
}
}, [])
|
9.5 useEffect 实战:数据获取三状态
9.5.1 三个状态的设计:isLoading / error / data
数据请求有三种典型状态:加载中(Loading)、错误(Error)、成功(Data)。合理管理这三种状态,能让你的应用体验流畅。
1
2
3
4
| // 三状态模型
const [data, setData] = useState(null) // 数据
const [loading, setLoading] = useState(false) // 加载中
const [error, setError] = useState(null) // 错误
|
9.5.2 初始状态与加载状态的处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| function DataFetcher({ url }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchData() {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP 错误!状态码:${response.status}`)
}
const result = await response.json()
if (!cancelled) {
setData(result)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
cancelled = true
}
}, [url])
// 渲染分支
if (loading) {
return <div className="loading">加载中...</div>
}
if (error) {
return <div className="error">请求失败:{error}</div>
}
if (!data) {
return null // 没有数据且没有错误,显示空白
}
return (
<div className="data">
{/* 渲染数据 */}
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
|
9.5.3 错误处理:try-catch 与 error 状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // 常见错误类型和对应的处理
async function fetchData() {
try {
const res = await fetch(url)
// 网络错误(fetch 本身抛出的)
if (!res.ok) {
// HTTP 错误(如 404、500)
throw new Error(`请求失败:${res.status} ${res.statusText}`)
}
const data = await res.json()
setData(data)
} catch (err) {
if (err.name === 'AbortError') {
// 请求被取消了,不显示错误(组件已卸载)
console.log('请求被取消')
} else {
// 网络错误或解析错误
setError(err.message)
}
}
}
|
9.5.4 完整示例:从 API 获取数据并展示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
async function loadUserData() {
setLoading(true)
setError(null)
try {
// 同时获取用户信息和文章列表(Promise.all)
const [userRes, postsRes] = await Promise.all([
fetch(`/api/users/${userId}`, { signal: controller.signal }),
fetch(`/api/users/${userId}/posts`, { signal: controller.signal })
])
if (!userRes.ok || !postsRes.ok) {
throw new Error('获取数据失败')
}
const userData = await userRes.json()
const postsData = await postsRes.json()
setUser(userData)
setPosts(postsData)
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message)
}
} finally {
setLoading(false)
}
}
loadUserData()
return () => controller.abort()
}, [userId])
if (loading) return <div className="skeleton-loader">加载中...</div>
if (error) return <div className="error-banner">❌ {error}</div>
if (!user) return <div>用户不存在</div>
return (
<div className="user-profile">
<div className="profile-header">
<img src={user.avatar} alt={user.name} />
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
</div>
<div className="posts-section">
<h2>文章列表({posts.length} 篇)</h2>
{posts.map(post => (
<div key={post.id} className="post-card">
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</div>
))}
</div>
</div>
)
}
|
本章小结
本章我们深入学习了 React 中处理"副作用"的 Hook——useEffect:
- useEffect 基础:useEffect 是副作用的容器,依赖数组决定执行时机;无依赖每次渲染都执行,空数组只在首次执行,指定依赖则在变化时执行
- 依赖数组规则:依赖数组里的值必须是在 effect 中用到的所有外部值;ESLint exhaustive-deps 规则帮你检查依赖是否完整
- 清理副作用:清理函数在下次 effect 执行前和组件卸载时执行;常见的清理场景有定时器、事件监听、WebSocket、AbortController
- 防止无限循环:在 effect 里调用 setState 但不加依赖或依赖错误,是导致无限循环的主要原因
- async/await 陷阱:useEffect 不能是 async,但可以在内部定义 async 函数;清理函数不能是 async,要用同步标记位
- 数据获取三状态:loading / error / data 三个状态覆盖了数据请求的完整生命周期
useEffect 是 React 中最复杂也最强大的 Hook。掌握好它,就掌握了 React 与外部世界交互的能力!下一章我们将学习更多 React Hooks——useState 深入和自定义 Hook!🪝