Appearance
React 副作用(Side Effects)全面详解
一、什么是副作用?
在编程中,副作用(Side Effect) 是指函数或表达式在执行过程中,除了返回值之外,还对程序状态或外部环境产生了可观察到的变化。 简单概况就是组件和外部世界交互的那些动作
副作用的定义
简单来说,副作用就是函数与其外部世界发生的任何交互。如果一个函数:
- 修改了外部变量
- 进行了网络请求
- 操作了DOM
- 设置了定时器
- 进行了日志记录
- 修改了全局状态
那么这个函数就产生了副作用。
React中的副作用
在React组件中,副作用是指那些不属于组件渲染过程的一部分,但需要在组件生命周期中执行的操作。React组件的主要职责是根据props和state返回JSX,但实际应用中,组件往往需要:
- 与浏览器API交互(DOM操作、事件监听)
- 发起网络请求(API调用)
- 订阅外部数据源
- 设置定时器
- 操作本地存储
这些都是典型的副作用。
二、React副作用的具体类型
1. 数据获取(Data Fetching)
最常见的副作用之一,包括:
- 从API获取数据
- 提交表单数据
- 上传文件
2. 订阅(Subscriptions)
- 订阅浏览器事件(resize、scroll、keydown等)
- 订阅WebSocket连接
- 订阅状态管理库的变化
- 订阅第三方库的事件
3. 手动DOM操作
- 直接修改DOM元素
- 操作canvas
- 集成第三方DOM库(如D3.js、jQuery插件)
4. 定时器(Timers)
- setTimeout
- setInterval
- requestAnimationFrame
5. 本地存储操作
- localStorage
- sessionStorage
- IndexedDB
6. 日志和性能监控
- console.log
- 发送性能指标
- 错误追踪
7. 清理操作
- 取消网络请求
- 清除定时器
- 移除事件监听器
- 关闭WebSocket连接
三、useEffect Hook 详解
React提供了 useEffect Hook来处理副作用。它允许你在函数组件中执行副作用操作。
useEffect 的基本语法
jsx
useEffect(() => {
// 副作用代码
return () => {
// 清理函数(可选)
}
}, [dependencies]) // 依赖数组useEffect 的执行时机
- 组件首次渲染后:effect会在DOM更新后执行
- 依赖项变化后:当依赖数组中的值发生变化时重新执行
- 组件卸载前:执行清理函数
依赖数组的三种形式
jsx
// 1. 无依赖数组 - 每次渲染后都执行
useEffect(() => {
console.log('每次渲染后都执行')
})
// 2. 空依赖数组 - 仅在挂载时执行一次
useEffect(() => {
console.log('仅在组件挂载时执行一次')
}, [])
// 3. 有依赖项 - 依赖变化时执行
useEffect(() => {
console.log('count变化时执行')
}, [count])四、具体使用案例
案例1:数据获取
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(() => {
let ignore = false
async function fetchUser() {
setLoading(true)
try {
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
if (!ignore) {
setUser(data)
setError(null)
}
} catch (err) {
if (!ignore) {
setError(err.message)
}
} finally {
if (!ignore) {
setLoading(false)
}
}
}
fetchUser()
return () => {
ignore = true
}
}, [userId])
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}要点说明:
- 使用
ignore标志避免竞态条件 - 当
userId变化时重新获取数据 - 清理函数防止组件卸载后更新状态
案例2:事件监听
jsx
import { useState, useEffect } from 'react'
function WindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
})
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
return (
<div>
<p>窗口宽度: {windowSize.width}px</p>
<p>窗口高度: {windowSize.height}px</p>
</div>
)
}要点说明:
- 空依赖数组表示仅在挂载时添加监听器
- 清理函数在卸载时移除监听器,防止内存泄漏
案例3:定时器
jsx
import { useState, useEffect } from 'react'
function Timer() {
const [seconds, setSeconds] = useState(0)
const [isRunning, setIsRunning] = useState(false)
useEffect(() => {
if (!isRunning) return
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000)
return () => {
clearInterval(intervalId)
}
}, [isRunning])
return (
<div>
<p>已运行: {seconds} 秒</p>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? '暂停' : '开始'}
</button>
<button onClick={() => setSeconds(0)}>重置</button>
</div>
)
}要点说明:
- 当
isRunning变化时重新设置定时器 - 清理函数清除旧的定时器
案例4:订阅WebSocket
jsx
import { useState, useEffect } from 'react'
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([])
const [connectionStatus, setConnectionStatus] = useState('disconnected')
useEffect(() => {
const ws = new WebSocket(`wss://example.com/chat/${roomId}`)
ws.onopen = () => {
setConnectionStatus('connected')
}
ws.onmessage = event => {
const message = JSON.parse(event.data)
setMessages(prev => [...prev, message])
}
ws.onclose = () => {
setConnectionStatus('disconnected')
}
ws.onerror = () => {
setConnectionStatus('error')
}
return () => {
ws.close()
}
}, [roomId])
return (
<div>
<p>状态: {connectionStatus}</p>
<ul>
{messages.map((msg, index) => (
<li key={index}>{msg.text}</li>
))}
</ul>
</div>
)
}要点说明:
- 切换房间时关闭旧连接并建立新连接
- 清理函数确保WebSocket正确关闭
案例5:操作本地存储
jsx
import { useState, useEffect } from 'react'
function ThemeToggle() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') || 'light'
})
useEffect(() => {
localStorage.setItem('theme', theme)
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'))
}
return <button onClick={toggleTheme}>当前主题: {theme}</button>
}要点说明:
- 初始值从localStorage读取
- 主题变化时同步到localStorage和DOM
案例6:集成第三方DOM库
jsx
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'
function BarChart({ data }) {
const svgRef = useRef(null)
useEffect(() => {
const svg = d3.select(svgRef.current)
svg
.selectAll('rect')
.data(data)
.join('rect')
.attr('x', (d, i) => i * 30)
.attr('y', d => 200 - d.value)
.attr('width', 25)
.attr('height', d => d.value)
.attr('fill', 'steelblue')
}, [data])
return <svg ref={svgRef} width={400} height={200} />
}要点说明:
- 使用ref获取DOM元素
- 数据变化时重新渲染图表
案例7:鼠标位置追踪
jsx
import { useState, useEffect } from 'react'
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
function handleMouseMove(event) {
setPosition({
x: event.clientX,
y: event.clientY
})
}
document.addEventListener('mousemove', handleMouseMove)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return (
<div>
<p>
鼠标位置: X: {position.x}, Y: {position.y}
</p>
</div>
)
}案例8:页面标题更新
jsx
import { useEffect } from 'react'
function DocumentTitle({ title }) {
useEffect(() => {
const previousTitle = document.title
document.title = title
return () => {
document.title = previousTitle
}
}, [title])
return null
}
function App() {
return (
<div>
<DocumentTitle title='我的应用' />
<h1>欢迎</h1>
</div>
)
}案例9:防抖搜索
jsx
import { useState, useEffect } from 'react'
function SearchInput() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [searching, setSearching] = useState(false)
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
setSearching(true)
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${query}`)
const data = await response.json()
setResults(data)
} catch (error) {
console.error('搜索失败:', error)
} finally {
setSearching(false)
}
}, 500)
return () => {
clearTimeout(timeoutId)
setSearching(false)
}
}, [query])
return (
<div>
<input
type='text'
value={query}
onChange={e => setQuery(e.target.value)}
placeholder='搜索...'
/>
{searching && <p>搜索中...</p>}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}要点说明:
- 使用setTimeout实现防抖
- 清理函数取消未完成的请求
案例10:自定义Hook封装副作用
jsx
import { useState, useEffect } from 'react'
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(error)
return initialValue
}
})
const setValue = value => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(error)
}
}
return [storedValue, setValue]
}
function useFetch(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let ignore = false
async function fetchData() {
setLoading(true)
try {
const response = await fetch(url)
if (!response.ok) throw new Error('请求失败')
const json = await response.json()
if (!ignore) {
setData(json)
setError(null)
}
} catch (err) {
if (!ignore) {
setError(err.message)
}
} finally {
if (!ignore) {
setLoading(false)
}
}
}
fetchData()
return () => {
ignore = true
}
}, [url])
return { data, loading, error }
}
function App() {
const [name, setName] = useLocalStorage('name', '')
const { data, loading, error } = useFetch('/api/users')
return (
<div>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder='输入名字'
/>
{loading && <p>加载中...</p>}
{error && <p>错误: {error}</p>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
)
}五、其他副作用相关Hook
useLayoutEffect
useLayoutEffect 与 useEffect 类似,但执行时机不同:
jsx
import { useLayoutEffect, useRef } from 'react'
function Tooltip({ children, targetRect }) {
const tooltipRef = useRef(null)
useLayoutEffect(() => {
const tooltip = tooltipRef.current
if (!tooltip || !targetRect) return
const tooltipRect = tooltip.getBoundingClientRect()
tooltip.style.left = `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`
tooltip.style.top = `${targetRect.top - tooltipRect.height - 5}px`
}, [targetRect])
return (
<div ref={tooltipRef} className='tooltip'>
{children}
</div>
)
}区别:
useEffect:在DOM更新后异步执行,不阻塞浏览器绘制useLayoutEffect:在DOM更新后同步执行,阻塞浏览器绘制
使用场景:
- 需要在浏览器绘制前读取或修改DOM布局
- 防止视觉闪烁
useInsertionEffect
React 18新增,用于CSS-in-Js库注入样式:
jsx
import { useInsertionEffect } from 'react'
function useCSS(rule) {
useInsertionEffect(() => {
const style = document.createElement('style')
style.textContent = rule
document.head.appendChild(style)
return () => {
document.head.removeChild(style)
}
}, [rule])
}六、副作用最佳实践
1. 正确设置依赖数组
jsx
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
return <h1>{count}</h1>
}2. 避免在effect中直接使用props或state
jsx
function BadExample({ userId }) {
const [data, setData] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData)
}, [])
return <div>{data?.name}</div>
}
function GoodExample({ userId }) {
const [data, setData] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData)
}, [userId])
return <div>{data?.name}</div>
}3. 处理竞态条件
jsx
function SearchResults({ query }) {
const [results, setResults] = useState([])
useEffect(() => {
let ignore = false
async function search() {
const response = await fetch(`/api/search?q=${query}`)
const data = await response.json()
if (!ignore) {
setResults(data)
}
}
if (query) {
search()
}
return () => {
ignore = true
}
}, [query])
return (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)
}4. 使用AbortController取消请求
jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
const controller = new AbortController()
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
})
const data = await response.json()
setUser(data)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('获取用户失败:', error)
}
}
}
fetchUser()
return () => {
controller.abort()
}
}, [userId])
return <div>{user?.name}</div>
}5. 将副作用逻辑提取到自定义Hook
jsx
function useWindowResize(callback) {
useEffect(() => {
window.addEventListener('resize', callback)
return () => window.removeEventListener('resize', callback)
}, [callback])
}
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
function App() {
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
useEffect(() => {
if (debouncedSearch) {
console.log('搜索:', debouncedSearch)
}
}, [debouncedSearch])
return <input value={search} onChange={e => setSearch(e.target.value)} />
}6. 避免在循环或条件中调用useEffect
jsx
function BadExample({ items }) {
return items.map((item, index) => {
useEffect(() => {
console.log(item)
}, [item])
return <div key={index}>{item}</div>
})
}
function GoodExample({ items }) {
useEffect(() => {
items.forEach(item => console.log(item))
}, [items])
return items.map((item, index) => <div key={index}>{item}</div>)
}七、常见错误和解决方案
错误1:忘记清理副作用
jsx
function BadExample() {
useEffect(() => {
setInterval(() => {
console.log('tick')
}, 1000)
}, [])
return <div>Timer</div>
}
function GoodExample() {
useEffect(() => {
const timer = setInterval(() => {
console.log('tick')
}, 1000)
return () => clearInterval(timer)
}, [])
return <div>Timer</div>
}错误2:依赖数组遗漏
jsx
function BadExample({ count }) {
useEffect(() => {
console.log('count is', count)
}, [])
}
function GoodExample({ count }) {
useEffect(() => {
console.log('count is', count)
}, [count])
}错误3:在effect中直接修改state导致无限循环
jsx
function BadExample() {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(count + 1)
}, [count])
return <div>{count}</div>
}
function GoodExample() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
return <div>{count}</div>
}八、总结
副作用的核心要点
- 理解副作用本质:任何与组件外部世界的交互都是副作用
- 使用useEffect处理:React提供的标准方式来处理副作用
- 正确设置依赖:确保effect在正确的时机执行
- 及时清理:防止内存泄漏和不必要的操作
- 提取自定义Hook:复用副作用逻辑
何时使用副作用
- ✅ 数据获取和提交
- ✅ 订阅外部数据源
- ✅ 操作浏览器API
- ✅ 设置定时器
- ✅ 操作本地存储
何时避免副作用
- ❌ 在渲染过程中执行副作用
- ❌ 在事件处理器中应该处理的逻辑
- ❌ 可以通过纯计算得到的值
副作用处理流程
1. 确定副作用类型
↓
2. 选择合适的Hook(useEffect/useLayoutEffect)
↓
3. 编写副作用逻辑
↓
4. 设置正确的依赖数组
↓
5. 添加清理函数(如需要)
↓
6. 测试和验证通过正确理解和使用React副作用,可以构建出更加健壮、可维护的应用程序。