useEffect
副作用的处理中枢 - 连接 React 纯函数世界与外部命令式世界的桥梁
核心概述
React 组件的核心哲学是纯函数渲染:给定相同的 props,总是返回相同的 JSX。 但现实应用中,你需要与外部世界交互——获取数据、订阅事件、操作 DOM、设置定时器。 这些操作被称为副作用(Side Effects),因为它们会影响到组件外部, 或者在渲染之外产生可见效果。
在类组件时代,副作用分散在各个生命周期方法中(componentDidMount,componentDidUpdate, componentWillUnmount),导致相关逻辑被拆分到不同地方。useEffect 的出现革命性地解决了这个问题:它将相关的副作用逻辑组织在一起, 并通过依赖数组机制精确控制副作用的执行时机。
执行时机:useEffect 在浏览器完成绘制后异步执行, 这确保了副作用不会阻塞浏览器渲染,提升用户体验。 这也是为什么它被称为"Effect"而不是"Effect Immediately"——它发生在渲染"之后"。
适用场景:任何需要与外部世界同步的操作——数据获取、 手动 DOM 操作、事件订阅/取消订阅、WebSocket 连接、使用第三方库等。 但如果你需要在浏览器绘制前同步执行操作(如测量 DOM 布局),应该使用 useLayoutEffect。
💡 心智模型
将 useEffect 想象成"渲染后的承诺":
- • React 先完成渲染(把画面画好)
- • 然后记住你的承诺:"渲染完去做这件事"
- • 检查依赖是否变化——如果变化了,履行承诺
- • 如果返回了清理函数,记住下次渲染前先执行清理
关键:依赖数组是判断"是否需要重新履行承诺"的唯一依据。
技术规格
类型签名
function useEffect(
effect: () => (void | (() => void)),
deps?: DependencyList
): void参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
effect | () => (void | (() => void)) | 副作用函数。必须返回一个清理函数(可选)或 undefined。 清理函数会在下次 effect 执行前或组件卸载时调用。 |
deps | DependencyList | 依赖数组(可选)。数组中的值变化时会重新执行 effect。 省略时每次渲染后都执行;空数组 [] 时只在挂载时执行一次。 |
执行时机对比
| Hook | 执行时机 | 适用场景 |
|---|---|---|
useEffect | 浏览器绘制后异步执行 | 大多数副作用(数据获取、订阅等) |
useLayoutEffect | 浏览器绘制前同步执行 | 需要同步读取 DOM 布局、避免闪烁 |
useInsertionEffect | DOM 变化前、布局计算前 | CSS-in-JS 库作者 |
实战演练
1. 基础用法:三种依赖模式
// 模式 1: 每次渲染后都执行(省略依赖数组)
useEffect(() => {
console.log('组件渲染了');
});
// 模式 2: 只在挂载时执行一次(空依赖数组)
useEffect(() => {
console.log('组件挂载了');
}, []);
// 模式 3: 依赖变化时执行(指定依赖)
useEffect(() => {
console.log('count 变化了:', count);
}, [count]);2. 生产级案例:数据获取与清理
import { useState, useEffect } from 'react';
import type { User } from './types';
interface UserProfileProps {
userId: number;
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 定义异步获取函数
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: User = await response.json();
setUser(data);
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误');
} finally {
setLoading(false);
}
};
// 执行获取
fetchUser();
// 清理函数:组件卸载或 userId 变化时取消请求
const controller = new AbortController();
return () => {
controller.abort(); // 取消进行中的请求
};
}, [userId]); // ✅ 正确:userId 是外部依赖
if (loading) {
return <div>加载中...</div>;
}
if (error) {
return <div>错误: {error}</div>;
}
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</div>
);
}3. 生产级案例:事件订阅与清理
import { useState, useEffect, useRef } from 'react';
export function KeyboardListener() {
const [key, setKey] = useState<string>('');
const [count, setCount] = useState(0);
// 使用 ref 存储最新的 count,避免闭包陷阱
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
setKey(event.key);
// 通过 ref 访问最新值,避免依赖 count
console.log('当前 count:', countRef.current);
};
// 添加事件监听
window.addEventListener('keydown', handleKeyDown);
// 清理函数:移除事件监听
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []); // ✅ 空依赖数组:只在挂载时设置一次
return (
<div>
<p>最后按下的键: {key}</p>
<p>计数: {count}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}4. 生产级案例:WebSocket 连接
import { useState, useEffect, useRef } from 'react';
type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
interface ChatMessage {
id: string;
content: string;
timestamp: number;
}
export function ChatRoom({ roomId }: { roomId: string }) {
const [status, setStatus] = useState<WebSocketStatus>('disconnected');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
// 建立 WebSocket 连接
const ws = new WebSocket(`wss://example.com/chat/${roomId}`);
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
};
ws.onmessage = (event) => {
const message: ChatMessage = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
ws.onerror = () => {
setStatus('error');
};
ws.onclose = () => {
setStatus('disconnected');
};
// 清理函数:关闭连接
return () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.close();
}
};
}, [roomId]); // roomId 变化时重新连接
const sendMessage = (content: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ content, timestamp: Date.now() }));
}
};
return (
<div>
<div>状态: {status}</div>
<ul>
{messages.map(msg => (
<li key={msg.id}>{msg.content}</li>
))}
</ul>
</div>
);
}避坑指南
陷阱 1: 依赖数组缺少响应式值
问题:effect 内部使用了响应式值(如 props、state),但没有添加到依赖数组。 React 会报警告,并可能导致使用旧的闭包值。
// ❌ 错误:缺少 userId 依赖
useEffect(() => {
const subscription = dataSource.subscribe(userId);
return () => subscription.unsubscribe();
}, []); // 缺少 userId!// ✅ 正确:添加所有依赖
useEffect(() => {
const subscription = dataSource.subscribe(userId);
return () => subscription.unsubscribe();
}, [userId]); // userId 变化时重新订阅ESLint 规则:使用 exhaustive-deps 规则自动检查依赖数组。 不要刻意使用 // eslint-disable-next-line 来忽略警告,99% 的情况下警告是正确的。
陷阱 2: setState 导致无限循环
问题:在 effect 中调用 setState,而该 state 又在依赖数组中, 导致无限循环:渲染 → effect → setState → 重新渲染 → effect ...
// ❌ 致命错误:无限循环
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
}, [count]); // count 变化 → effect 执行 → count 变化 → ...// ✅ 解决方案 1:移除依赖(如果你真的想每次都执行)
useEffect(() => {
setCount(c => c + 1);
}, []); // 不依赖 count
// ✅ 解决方案 2:添加条件判断
useEffect(() => {
if (someCondition) {
setCount(count + 1);
}
}, [count, someCondition]);
// ✅ 解决方案 3:重构逻辑
// 如果需要在特定条件下更新状态,考虑是否应该用 useReducer陷阱 3: 误解清理函数执行时机
常见误区:认为清理函数只在组件卸载时执行。 实际上,清理函数在每次新的 effect 执行前都会执行。
useEffect(() => {
console.log('1. effect 执行');
return () => {
console.log('2. 清理函数执行');
};
}, [userId]);
// 执行顺序:
// 1. 首次渲染 → effect 执行
// 2. userId 变化 → 清理函数执行 → effect 执行
// 3. 组件卸载 → 清理函数执行实际意义:这意味着你可以在清理函数中"撤销"上一次 effect 的操作, 然后在新的 effect 中建立新操作。例如,userId 变化时取消旧的订阅,建立新的订阅。
陷阱 4: effect 函数不能是 async
问题:effect 函数必须返回清理函数或 undefined,不能返回 Promise。
// ❌ 错误:不能使用 async 作为 effect 函数
useEffect(async () => {
const data = await fetchData(userId);
setState(data);
}, [userId]);// ✅ 正确:在内部定义 async 函数
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData(userId);
setState(data);
};
fetchDataAsync();
}, [userId]);最佳实践
✅ 推荐模式
- 总是添加清理函数(即使只是返回 undefined)
- 使用 ESLint 的 exhaustive-deps 规则
- 每个 effect 只做一件事(单一职责)
- 将相关逻辑组织到同一个 effect 中
- 使用自定义 Hook 封装复杂副作用
- 在 effect 内使用 ref 读取最新值
❌ 避免模式
- 不要忽略依赖数组的警告
- 不要在 effect 中执行计算(用 useMemo)
- 不要忘记清理副作用(内存泄漏风险)
- 不要在渲染中执行副作用
- 不要让 effect 函数返回 Promise
- 不要过度使用 effect(能用状态推导就不用)
何时使用其他 Hook
useLayoutEffect vs useEffect
如果需要在浏览器绘制前同步执行操作:
- 读取 DOM 布局(如 getBoundingClientRect)
- 避免视觉闪烁的 DOM 操作
- 测量元素尺寸
import { useLayoutEffect } from 'react';
useLayoutEffect(() => {
// 在浏览器绘制前执行
const rect = ref.current.getBoundingClientRect();
setState({ width: rect.width });
}, []);useInsertionEffect vs useEffect
这是 React 19 新增的 Hook,主要用于库作者:
- 在 DOM 变化前、布局计算前执行
- 用于注入动态样式(CSS-in-JS)
- 不能访问 ref(因为 DOM 还没创建)