React: useEffect 与生命周期
最后更新:2026-08-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) 定时器的清理
JSX
function Timer() {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setSeconds(s => s + 1)
}, 1000)
// ✅ 组件卸载时清除定时器
return () => {
clearInterval(timer)
console.log('定时器已清除')
}
}, []) // 只在挂载时启动
return <p>已运行:{seconds} 秒</p>
}
// 不清理的话:组件卸载后定时器还在跑,报错"在已卸载组件上更新状态"
(2) 事件监听的清理
JSX
function WindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight })
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight })
}
window.addEventListener('resize', handleResize)
// ✅ 组件卸载时移除事件监听
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
return <p>窗口:{size.width} × {size.height}</p>
}
(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 → 不更新状态(避免闪现)
▶ 示例:清理函数综合演示
JSX
📖 仅展示
// ============================================
// 示例:useEffect 声明周期演示
// 功能:挂载时启动计时器 + 监听键盘事件
// 卸载时清理所有资源
// ============================================
function LifecycleDemo() {
const [count, setCount] = useState(0)
const [logs, setLogs] = useState([])
// 效果 1:标题更新
useEffect(() => {
document.title = `计数:${count}`
}, [count])
// 效果 2:计时器
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 2000)
addLog('⏱️ 计时器已启动')
return () => {
clearInterval(id)
addLog('⏱️ 计时器已清理')
}
}, [])
// 效果 3:键盘事件
useEffect(() => {
function handleKey(e) {
if (e.key === ' ') {
setCount(c => c + 5)
}
}
window.addEventListener('keydown', handleKey)
return () => {
window.removeEventListener('keydown', handleKey)
addLog('⌨️ 键盘监听已移除')
}
}, [])
function addLog(msg) {
setLogs(prev => [...prev, `${new Date().toLocaleTimeString()} ${msg}`])
}
return (
<div>
<h2>useEffect 生命周期演示</h2>
<p>计数:{count}(每 2 秒 +1,按空格键 +5)</p>
<div style={{ maxHeight: '200px', overflow: 'auto', backgroundColor: '#f5f5f5', padding: '10px', borderRadius: '4px', fontSize: '12px', fontFamily: 'monospace' }}>
{logs.map((log, i) => <div key={i}>{log}</div>)}
</div>
</div>
)
}
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
| 类组件生命周期 | 对应的 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
// 组件卸载时 → 断开当前连接(清理函数执行)
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 尺寸、读取布局、同步动画 |
JSX
import { useState, useEffect, useLayoutEffect } from 'react'
function MeasureExample() {
const [width, setWidth] = useState(0)
const ref = useRef(null)
// ❌ useEffect 读取 DOM 尺寸 → 用户可能看到闪烁
useEffect(() => {
setWidth(ref.current.offsetWidth) // 绘制后读取 → 可能触发额外渲染
}, [])
// ✅ useLayoutEffect → 绘制前读取,用户无感知
useLayoutEffect(() => {
setWidth(ref.current.offsetWidth)
}, [])
return <div ref={ref}>宽度:{width}px</div>
}
规则:先写
useEffect,只有遇到"闪烁"或"布局抖动"问题才换成useLayoutEffect。
7. 完整示例:数据仪表盘
JSX
// ============================================
// 完整示例:数据仪表盘(useEffect 综合应用)
// 功能:定时刷新数据 + 窗口自适应 + 资源清理
// ============================================
import { useState, useEffect } from 'react'
function Dashboard() {
const [data, setData] = useState(null)
const [isLive, setIsLive] = useState(true)
const [lastUpdate, setLastUpdate] = useState(null)
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
// 效果 1:首次加载 + 定时刷新
useEffect(() => {
function fetchData() {
// 模拟 API 请求
const mockData = {
users: Math.floor(Math.random() * 1000) + 500,
orders: Math.floor(Math.random() * 200) + 100,
revenue: Math.floor(Math.random() * 50000) + 10000,
growth: (Math.random() * 20 - 5).toFixed(1)
}
setData(mockData)
setLastUpdate(new Date().toLocaleTimeString())
}
// 立即执行一次
fetchData()
// 定时刷新
let interval
if (isLive) {
interval = setInterval(fetchData, 5000)
}
return () => {
clearInterval(interval)
}
}, [isLive]) // isLive 变化时重新设置定时器
// 效果 2:窗口大小监听
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
})
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// 效果 3:页面标题
useEffect(() => {
document.title = `仪表盘 - ${data ? `${data.users} 用户` : '加载中...'}`
}, [data])
// 效果 4:离开页面提示
useEffect(() => {
function handleBeforeUnload(e) {
if (isLive) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isLive])
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2>📊 实时仪表盘</h2>
<div>
<span style={{ color: '#666', fontSize: '14px', marginRight: '12px' }}>
窗口:{windowSize.width}×{windowSize.height}
</span>
<label>
<input type="checkbox" checked={isLive} onChange={e => setIsLive(e.target.checked)} />
实时刷新
</label>
</div>
</div>
{!data ? (
<p style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
⏳ 加载中...
</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
<StatCard label="总用户" value={data.users.toLocaleString()} color="#1890ff" />
<StatCard label="今日订单" value={data.orders.toLocaleString()} color="#52c41a" />
<StatCard label="月收入" value={`$${data.revenue.toLocaleString()}`} color="#ff4d4f" />
<StatCard label="增长率" value={`${data.growth}%`} color={parseFloat(data.growth) >= 0 ? '#52c41a' : '#ff4d4f'} />
</div>
)}
<p style={{ textAlign: 'right', color: '#999', fontSize: '12px', marginTop: '12px' }}>
最后更新:{lastUpdate} {isLive ? '(每 5 秒自动刷新)' : '(已暂停)'}
</p>
</div>
)
}
function StatCard({ label, value, color }) {
return (
<div style={{
border: '1px solid #f0f0f0',
borderRadius: '8px',
padding: '20px',
textAlign: 'center',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}>
<p style={{ color: '#666', margin: '0 0 8px 0' }}>{label}</p>
<p style={{ fontSize: '28px', fontWeight: 'bold', color, margin: 0 }}>{value}</p>
</div>
)
}
预期输出:数据仪表盘,含 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>
)
}
▶ 示例 4:useEffect 监听窗口尺寸变化
JSX
function useWindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight })
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight })
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}
function ResponsiveLayout() {
const { width } = useWindowSize()
const layout = width < 640 ? 'mobile' : width < 1024 ? 'tablet' : 'desktop'
return (
<div style={{ padding: 16, maxWidth: 600, margin: '0 auto' }}>
<h3>Viewport: {width}px ({layout})</h3>
{layout === 'mobile' && <p>📱 Single column layout</p>}
{layout === 'tablet' && <p>📟 Two column layout</p>}
{layout === 'desktop' && <p>💻 Three column layout with sidebar</p>}
</div>
)
}
▶ 示例 5:useEffect + localStorage 持久化状态
JSX
📖 仅展示
function usePersistedState(key, initial) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved !== null ? JSON.parse(saved) : initial
} catch { return initial }
})
useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)) }
catch (e) { console.error('Save failed:', e) }
}, [key, value])
return [value, setValue]
}
function TodoApp() {
const [todos, setTodos] = usePersistedState('todos', [])
const [input, setInput] = useState('')
function addTodo() {
if (!input.trim()) return
setTodos(prev => [...prev, { id: Date.now(), text: input.trim(), done: false }])
setInput('')
}
function toggleTodo(id) {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t))
}
return (
<div style={{ maxWidth: 400, margin: '0 auto' }}>
<h3>Todos (persisted)</h3>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input value={input} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="Add task..." style={{ flex: 1, padding: 8, borderRadius: 4 }} />
<button onClick={addTodo} style={{ padding: '8px 16px', cursor: 'pointer' }}>Add</button>
</div>
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(t => (
<li key={t.id} onClick={() => toggleTodo(t.id)}
style={{ padding: 4, cursor: 'pointer', textDecoration: t.done ? 'line-through' : 'none', color: t.done ? '#999' : '#333' }}>
{t.text}
</li>
))}
</ul>
</div>
)
}
❓ 常见问题
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 },支持暂停/恢复轮询。