React 中 useEffect 详解
useEffect 是 React Hooks 中用于处理副作用 的核心 Hook,它替代了类组件中的生命周期方法(componentDidMount、componentDidUpdate、componentWillUnmount),让函数组件能够执行异步操作、修改 DOM、订阅事件、清理资源等副作用逻辑。而且现在函数组件才是主流,类组件多是一些老项目
一、核心概念:什么是 "副作用"?
副作用(Side Effect)是指函数执行过程中,除了返回值之外对外部环境产生的影响,常见场景:
- 数据请求(AJAX/fetch)
- DOM 操作(修改样式、添加事件监听)
- 订阅 / 取消订阅(如 WebSocket、定时器)
- 本地存储(localStorage/sessionStorage)
- 手动修改 state(需谨慎)
二、基本语法
javascript
import { useEffect } from 'react';
function Component() {
// 基础用法
useEffect(() => {
// 副作用逻辑(执行时机:组件挂载/更新后)
// 可选:清理函数(执行时机:组件卸载/下一次副作用执行前)
return () => {
// 清理逻辑(如取消订阅、清除定时器)
};
}, [依赖项数组]); // 关键:依赖项决定副作用的执行时机
}
语法拆解:
-
第一个参数(必选) :副作用函数
- 可以是普通函数,执行核心的副作用逻辑;
- 可选返回一个清理函数(Cleanup Function),用于清除副作用(如取消定时器、解绑事件)。
-
第二个参数(可选) :依赖项数组
- 控制副作用的执行时机;
- 若省略:组件每次渲染后都会执行副作用;
- 若为空数组
[]:仅在组件挂载(mount) 时执行一次,清理函数在组件卸载(unmount) 时执行; - 若包含依赖项(如
[count, data]):仅在依赖项的值发生变化时执行副作用(挂载时执行第一次,后续仅依赖项变化触发)。
三、执行时机
useEffect 的执行时机是组件渲染完成后 (浏览器绘制 DOM 之后),属于异步执行,不会阻塞浏览器的渲染,这一点与类组件的 componentDidMount/componentDidUpdate 一致。
不同依赖项的执行行为对比:
| 依赖项写法 | 执行时机 | 对应类组件生命周期 |
|---|---|---|
| 无依赖项 | 每次组件渲染后(挂载 + 每次更新) | componentDidMount + componentDidUpdate |
空数组 [] |
仅挂载时执行一次 | componentDidMount |
含依赖项 [a, b] |
挂载时执行 + 依赖项 a/b 变化时执行 | 自定义 componentDidUpdate |
| 清理函数 | 卸载时执行 + 下一次副作用执行前执行 | componentWillUnmount |
四、常见使用场景
场景 1:仅挂载时执行(初始化)
比如请求初始数据、添加全局事件监听:
ini
import { useEffect, useState } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
// 仅挂载时请求数据(对应 componentDidMount)
useEffect(() => {
const fetchUsers = async () => {
const res = await fetch('https://api.example.com/users');
const data = await res.json();
setUsers(data);
};
fetchUsers();
}, []); // 空依赖:仅执行一次
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
场景 2:依赖项变化时执行
比如根据输入关键词筛选数据、监听状态变化:
javascript
import { useEffect, useState } from 'react';
function Search() {
const [keyword, setKeyword] = useState('');
const [result, setResult] = useState([]);
// 关键词变化时请求数据
useEffect(() => {
if (!keyword) return; // 无关键词时不请求
const timer = setTimeout(async () => {
const res = await fetch(`https://api.example.com/search?kw=${keyword}`);
const data = await res.json();
setResult(data);
}, 300); // 防抖:300ms 后请求
// 清理函数:取消定时器(关键词快速变化时避免无效请求)
return () => clearTimeout(timer);
}, [keyword]); // 依赖:仅 keyword 变化时执行
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ul>{result.map(item => <li key={item.id}>{item.name}</li>)}</ul>
</div>
);
}
场景 3:清理副作用(卸载时)
比如清除定时器、取消 WebSocket 订阅、解绑事件:
javascript
import { useEffect, useState } from 'react';
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
// 挂载时启动定时器
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// 清理函数:卸载时清除定时器(避免内存泄漏)
return () => clearInterval(timer);
}, []); // 仅挂载时执行
return <div>Count: {count}</div>;
}
// 父组件控制 Timer 的挂载/卸载
function Parent() {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow(!show)}>Toggle Timer</button>
{show && <Timer />}
</div>
);
}
五、关键注意事项
1. 依赖项必须完整
React 会根据依赖项数组的浅比较判断是否执行副作用,若依赖项缺失:
- 副作用可能无法触发(依赖项变化但未声明);
- 或捕获到过时的 state/prop(闭包陷阱)。
反例(错误) :
scss
function BadExample() {
const [count, setCount] = useState(0);
useEffect(() => {
// 依赖 count,但未声明在数组中
const timer = setInterval(() => {
console.log(count); // 始终打印 0(闭包捕获初始值)
}, 1000);
return () => clearInterval(timer);
}, []); // 错误:缺失 count 依赖
return <button onClick={() => setCount(count + 1)}>Click</button>;
}
正例(正确) :
scss
function GoodExample() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 正确打印最新 count
}, 1000);
return () => clearInterval(timer);
}, [count]); // 声明依赖 count
return <button onClick={() => setCount(count + 1)}>Click</button>;
}
2. 副作用函数不能是 async 函数
useEffect 的第一个参数若返回清理函数,则不能直接用 async(因为 async 函数返回 Promise,而非清理函数)。
反例(错误) :
scss
useEffect(async () => {
const res = await fetch('/api/data');
// ...
}, []); // 错误:async 函数返回 Promise,无法返回清理函数
正例(正确) :
ini
useEffect(() => {
// 内部定义 async 函数
const fetchData = async () => {
const res = await fetch('/api/data');
// ...
};
fetchData();
}, []);
3. 避免无限循环
若副作用中修改了依赖项,且依赖项未正确控制,会导致无限执行:
反例(错误) :
scss
function LoopExample() {
const [data, setData] = useState({});
useEffect(() => {
// 每次渲染都会创建新对象,依赖项浅比较为 false,触发无限执行
setData({ name: 'test' });
}, [data]); // 依赖 data,但修改了 data
return <div>{data.name}</div>;
}
解决方法:
- 避免修改依赖项,或调整依赖项(如仅依赖必要字段);
- 若必须修改,可使用函数式更新(
setData(prev => ({ ...prev, name: 'test' })))。
4. 清理函数的执行时机
清理函数不仅在组件卸载时执行,还会在下一次副作用执行前执行(比如依赖项变化时),用于清除上一次的副作用残留。
六、与类组件生命周期的对比
| 类组件生命周期 | useEffect 实现方式 |
|---|---|
| componentDidMount | useEffect(() => { ... }, []) |
| componentDidUpdate | useEffect(() => { ... }, [dep1, dep2]) |
| componentWillUnmount | useEffect(() => { return () => { ... } }, []) |
| 合并三者 | useEffect(() => { ... })(无依赖项) |
总结
useEffect 的核心是声明式地处理副作用:
- 通过依赖项数组控制执行时机,替代类组件的多个生命周期;
- 清理函数用于消除副作用的副作用(避免内存泄漏);
- 依赖项必须完整且准确,避免闭包陷阱和无限循环;
- 异步逻辑需在副作用函数内部定义
async函数,而非直接返回 Promise。