React: useEffect 与生命周期
最后更新:2026-10-26
如果说
useState是组件的"记忆",那useEffect就是组件的"照相机"——它让你在组件渲染到屏幕后("拍完照")去做一些额外的事情:请求数据、订阅事件、操作 DOM、设置定时器。
1. 你将学到
- useEffect 的作用和基本用法
- 依赖数组的三种模式
- 清理函数(防止内存泄漏)
- useEffect 与组件生命周期的关系
- useEffect vs useLayoutEffect
2. 一个数据加载页面的故事
(1) 痛点:数据请求导致无限循环
Alice 想在组件加载时请求用户数据,她这样写:
JSX
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null)
// ❌ 错误:在组件函数体中直接请求数据
fetch('/api/users/' + userId)
.then(res => res.json())
.then(data => setUser(data))
return <div>{user?.name}</div>
}
问题:
fetch→setUser(data)→ 组件重新渲染- 重新渲染 → 又执行
fetch→ 又设置状态 → 又渲染... - 无限循环! 页面卡死
Alice 需要一种方式:"只在组件挂载时执行一次"。
(2) useEffect 的解法
JSX
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// ✅ 正确:useEffect + 空依赖数组
useEffect(() => {
fetch('/api/users/' + userId)
.then(res => {
if (!res.ok) throw new Error('加载失败')
return res.json()
})
.then(data => {
setUser(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [userId]) // 只在 userId 变化时重新执行
if (loading) return <p>加载中...</p>
if (error) return <p>错误:{error}</p>
return <h1>{user.name}</h1>
}
收益:useEffect 让副作用和渲染逻辑分离——渲染只负责 UI,副作用在渲染完成后执行。
3. useEffect 基础
(1) 基本语法
JSX
useEffect(() => {
// 副作用逻辑(渲染完成后执行)
console.log('组件渲染完成!')
return () => {
// 清理函数(组件卸载或重新执行前运行)
console.log('清理副作用')
}
}, [依赖数组])
| 部分 | 说明 |
|---|---|
() => { ... } |
副作用回调,在渲染提交到屏幕后执行 |
return () => { ... } |
清理函数,组件卸载或依赖变化重新执行前运行 |
[依赖] |
控制 useEffect 何时重新执行的数组 |
graph TB
A[组件渲染] --> B[React 提交 DOM 更新]
B --> C[useEffect 回调执行]
C --> D{依赖变化?}
D -->|是| E[执行上一个清理函数]
E --> F[重新执行 useEffect]
D -->|否| G[跳过]
F --> H{组件卸载?}
G --> H
H -->|是| I[执行清理函数]
style C fill:#61dafb,color:#000
style I fill:#ff6b6b,color:#fff
(2) 依赖数组的三种模式
JSX
// 模式 1:空数组 [] — 只在挂载时执行一次
useEffect(() => {
console.log('组件挂载')
fetch('/api/init').then(setData)
}, []) // 永不重新执行
// 模式 2:有依赖 [a, b] — 依赖变化时重新执行
useEffect(() => {
console.log('userId 变化了:', userId)
fetch('/api/users/' + userId).then(setUser)
}, [userId]) // userId 变化时重新执行
// 模式 3:没有依赖数组 — 每次渲染都执行
useEffect(() => {
console.log('每次渲染都执行')
}) // 注意:没有数组!
| 依赖数组 | 执行时机 | 使用场景 |
|---|---|---|
[] |
仅挂载时一次 | 初始化数据请求、订阅 |
[dep1, dep2] |
挂载时 + 依赖变化时 | 数据随参数变化 |
| 无数组 | 每次渲染后 | 极少用(通常用别的方式替代) |
4. 清理函数
清理函数是防止内存泄漏和状态竞争的关键。
(1) 定时器的清理
▶ 试一试(2) 事件监听的清理
▶ 试一试(3) 请求取消(避免状态竞争)
JSX
function SearchResults({ query }) {
const [results, setResults] = useState([])
useEffect(() => {
let cancelled = false
fetch('/api/search?q=' + query)
.then(res => res.json())
.then(data => {
// ✅ 如果组件已卸载或 query 已变,不更新状态
if (!cancelled) {
setResults(data)
}
})
// ✅ 清理:标记取消
return () => {
cancelled = true
}
}, [query])
return <div>{results.map(r => <p key={r.id}>{r.name}</p>)}</div>
}
// 用户快速切换搜索词时:
// 1. 搜索"A" → useEffect 执行
// 2. 搜索"AB" → 清理函数标记 cancelled=true → useEffect 重新执行
// 3. 搜索"A" 的响应回来 → cancelled=true → 不更新状态(避免闪现)
输出:
TEXT
副标题:"useEffect Life Cycle Demonstration"。状态:count、logs。通过 useEffect 管理副作用。定时器更新
▶ 示例:清理函数综合演示
▶ 试一试输出:
TEXT
计数每 2 秒自动 +1,空格键 +5。日志:"⏱️ Timer started"。卸载 → "⏱️ Timer cleared"、"⌨️ Keyboard listening removed"
5. useEffect 与组件生命周期的映射
类组件时代有 3 个生命周期钩子,Hooks 时代的 useEffect 一个顶三个:
graph LR
subgraph "类组件的生命周期"
A1[componentDidMount] --> A2[componentDidUpdate]
A2 --> A3[componentWillUnmount]
end
subgraph "Hooks 的 useEffect"
B1["useEffect(fn, [])<br/>挂载时执行"]
B2["useEffect(fn, [dep])<br/>挂载 + 依赖更新时执行"]
B3["return cleanup<br/>卸载时或重新执行前清理"]
end
输出:
TEXT
副标题:"useEffect Life Cycle Demonstration"。显示:"useEffect Life Cycle Demonstration"。状态:count(setter: setCount)、logs(setter: setLogs)。useEffect 管理副作用。基于定时器的更新
| 类组件生命周期 | 对应的 useEffect |
|---|---|
componentDidMount |
useEffect(() => { ... }, []) |
componentDidUpdate |
useEffect(() => { ... })(无数组)或 useEffect(() => { ... }, [dep]) |
componentWillUnmount |
useEffect(() => { return () => { ... } }, []) 中的清理函数 |
▶ 示例:单个 useEffect 完成多个生命周期
JSX
// ============================================
// 示例:聊天室组件——一个 useEffect 覆盖全部生命周期
// ============================================
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([])
useEffect(() => {
// === componentDidMount + componentDidUpdate ===
console.log(`🟢 连接到聊天室:${roomId}`)
const connection = createConnection(roomId)
connection.connect()
connection.on('message', (msg) => {
setMessages(prev => [...prev, msg])
})
// === componentWillUnmount + 重新执行前清理 ===
return () => {
console.log(`🔴 断开聊天室:${roomId}`)
connection.disconnect()
}
}, [roomId]) // roomId 变化时:断开旧连接 → 连接新聊天室
return (
<div>
<h3>聊天室:{roomId}</h3>
{messages.map((msg, i) => <p key={i}>{msg}</p>}
</div>
)
}
// 使用
<ChatRoom roomId="general" /> // 连接 general 聊天室
<ChatRoom roomId="random" /> // roomId 变化 → 断开 general → 连接 random
// 组件卸载时 → 断开当前连接(清理函数执行)
输出:
TEXT
挂载:console "🟢 Connected to chat room: general"。切换 roomId:"🔴 Left: general" → "🟢 Connected: random"。卸载:断开连接。
6. useEffect vs useLayoutEffect
React 提供了两个"副作用"Hook,区别在于执行时机。
graph LR
A[触发渲染] --> B[计算虚拟 DOM]
B --> C[DOM 更新]
C --> D{useLayoutEffect?}
D -->|是| E[同步执行<br/>阻塞浏览器绘制]
D -->|否| F[浏览器绘制完成]
F --> G[useEffect 异步执行]
style E fill:#faad14,color:#000
style G fill:#52c41a,color:#fff
| Hook | 执行时机 | 阻塞绘制 | 适用场景 |
|---|---|---|---|
useEffect |
浏览器绘制之后(异步) | ❌ | 90% 的场景:数据请求、事件监听、日志 |
useLayoutEffect |
DOM 更新后、浏览器绘制之前(同步) | ✅ | 测量 DOM 尺寸、读取布局、同步动画 |
规则:先写
useEffect,只有遇到"闪烁"或"布局抖动"问题才换成useLayoutEffect。
7. 完整示例:数据仪表盘
▶ 展开运行示例(98 行)
输出:
TEXT
🟢 连接到聊天室:...
🔴 断开聊天室:...
预期输出:数据仪表盘,含 4 个统计卡片、实时刷新开关、窗口大小显示、最后更新时间。
▶ 示例 3:useEffect 实现实时数据同步
JSX
function LiveSearch({ apiBase }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query.trim()) { setResults([]); return }
setLoading(true)
const controller = new AbortController()
fetch(`${apiBase}/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then(res => res.json())
.then(data => { setResults(data); setLoading(false) })
.catch(err => { if (err.name !== 'AbortError') setLoading(false) })
return () => controller.abort()
}, [query, apiBase])
return (
<div style={{ maxWidth: 400, margin: '0 auto' }}>
<input value={query} onChange={e => setQuery(e.target.value)}
placeholder="Search users..." style={{ width: '100%', padding: 8, borderRadius: 4, marginBottom: 8 }} />
{loading && <p>Searching...</p>}
<ul style={{ listStyle: 'none', padding: 0 }}>
{results.map(r => <li key={r.id} style={{ padding: 4, borderBottom: '1px solid #f0f0f0' }}>{r.name}</li>)}
</ul>
</div>
)
}
输出:
TEXT
useEffect 管理副作用
输出:
TEXT
显示:"Searching..."。状态:query(setter: setQuery)、results(setter: setResults)、loading(setter: setLoading)。输入框:Search users...。列表:{r.name}。useEffect 管理副作用。异步数据获取/加载状态
▶ 示例 4:useEffect 监听窗口尺寸变化
▶ 试一试输出:
TEXT
useEffect 管理副作用
输出:
TEXT
显示:"📱 Single column layout"。状态:size(setter: setSize)。useEffect 管理副作用
▶ 示例 5:useEffect + localStorage 持久化状态
▶ 试一试输出:
TEXT
待办列表保存到 localStorage。添加任务,点击切换(删除线)。刷新页面 → 任务保留。按回车添加。
❓ 常见问题
Q useEffect 和直接在组件里写代码有什么区别?
A useEffect 的代码在渲染完成后才执行,不会阻塞 UI。直接在组件函数体里写的代码在渲染过程中执行(会阻塞渲染),而且每次渲染都会执行。最关键的区别是:useEffect 可以在组件卸载时做清理。
Q 为什么 useEffect 的清理函数会在每次重新执行前运行?
A 这是 React 的设计保证——防止因依赖变化导致的"竞争状态"。比如依赖 userId 变化时,先清理旧请求再发起新请求,确保新数据覆盖旧数据。如果直接新请求覆盖旧请求,可能出现"旧请求后完成 → 数据回退"的 bug。
Q 多个 useEffect 的执行顺序是怎样的?
A 按代码书写顺序依次执行。所以应该把"初始化类"的 useEffect 放在前面(如数据加载),"响应变化"的 useEffect 放在后面(如同步状态到 localStorage)。同一次渲染中,所有 useEffect 按顺序执行。
Q useEffect 和 useLayoutEffect 有什么区别?
A 执行时机不同:useEffect 在 DOM 更新后异步执行(浏览器绘制之后),用户不会看到闪烁;useLayoutEffect 在 DOM 更新后同步执行(浏览器绘制之前),可以修改 DOM 再让浏览器绘制。99% 的场景用 useEffect。useLayoutEffect 只在"需要根据 DOM 布局计算再同步修改 DOM"时使用(如测量元素尺寸后调整位置)。
📖 小节
useEffect(fn, deps)在渲染完成后执行副作用,不阻塞 UI- 空依赖
[]= 只在挂载执行一次;有依赖 = 挂载 + 依赖变化执行 - 清理函数
return () => {}是防内存泄漏的关键——定时器、事件监听、订阅都要清理 - 90% 的场景用
useEffect,只有测量 DOM 才用useLayoutEffect - 多个 useEffect 按书写顺序执行
📝 作业
- 基础题(难度⭐):创建一个
PageTitle组件,使用 useEffect 在组件挂载时把页面标题改为"欢迎访问我的网站",卸载时恢复为原标题。 - 进阶题(难度⭐⭐):创建一个
OnlineStatus组件,使用 useEffect 监听navigator.onLine和online/offline事件,实时显示网络状态。 - 挑战题(难度⭐⭐⭐):创建一个
usePolling自定义 Hook,接收url和interval参数,每 interval 毫秒轮询一次接口,返回{ data, isLoading, error },支持暂停/恢复轮询。