第 11 章:useEffect
学习目标
- 深入掌握 useEffect 的工作时机
- 熟练写 cleanup 函数防止内存泄漏
- 理解依赖数组的判断规则
- 掌握数据获取 / 订阅 / 防抖 3 大实战
- 避开 useEffect 的 6 个常见坑
一、useEffect 时机
tsx
useEffect(() => {
// 这里在浏览器绘制完成后执行(异步)
return () => {
// 这里在下一次 effect 跑之前/组件卸载时执行
};
}, [deps]);执行顺序(单次 mount):
- React 渲染组件(return JSX)
- React 更新 DOM(屏幕仍显示旧内容)
- React 触发 useEffect(此时屏幕可能仍显示旧内容一帧)
- 浏览器绘制新内容
useEffect 不影响渲染性能,渲染慢不归它。
二、依赖数组的判断规则
tsx
const [count, setCount] = useState(0);
useEffect(() => {
console.log('mount 或 count 变化时');
}, [count]);
useEffect(() => {
console.log('只在 mount 时');
}, []);React 用 Object.is 比较每个依赖项,变化才重跑。
⚠️ 坑 1:依赖是对象/数组字面量时,每次渲染引用都变,effect 会无限重跑:
tsx
// ❌ 每次渲染 deps 都是新对象
useEffect(() => { ... }, [{ a: 1 }]);
// ✅ 提取到外面或用 useMemo 缓存
const opts = useMemo(() => ({ a: 1 }), []);
useEffect(() => { ... }, [opts]);三、cleanup 函数详解
tsx
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(id); // ⚠️ cleanup
}, []);什么时候 cleanup?
- 组件 unmount(从 DOM 移除)
- 依赖变了,下次 effect 跑之前
tsx
const [roomId, setRoomId] = useState('1');
useEffect(() => {
const ws = new WebSocket(`wss://chat/${roomId}`);
ws.onmessage = (e) => console.log(e.data);
return () => ws.close(); // 切房间/退出时关旧连接
}, [roomId]);四、实战:数据获取
tsx
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const ctrl = new AbortController();
async function load() {
setLoading(true);
try {
const r = await fetch('/api/users', { signal: ctrl.signal });
const data = await r.json();
setUsers(data);
} catch (e) {
if (!ctrl.signal.aborted) setError(e as Error);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
}
load();
return () => ctrl.abort(); // ⚠️ 取消未完成的请求
}, []);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}⚠️ 坑 2:组件卸载后 setState 会报 "Can't perform state update on unmounted component",用
AbortController或ignore标志防 setState。场景:
fetch没回来,组件就卸载了 → 请求回来时调 setState,组件已经不在了。tsxuseEffect(() => { const ctrl = new AbortController() async function load() { try { const r = await fetch('/api/users', { signal: ctrl.signal }) const data = await r.json() setUsers(data) // 卸载后这行不会跑(请求被取消) } catch (e) { if (!ctrl.signal.aborted) setError(e as Error) } finally { if (!ctrl.signal.aborted) setLoading(false) } } load() return () => ctrl.abort() // 卸载时中断 fetch }, [])时间线:
0s: 组件挂载,fetch 发出 0s: 组件卸载,cleanup 跑 → ctrl.abort() → fetch 直接中断 3s: 原来的 then 不再执行 → 没有 setState,React 安静简单方案:
ignore标志(不能省请求,只忽略结果):tsxuseEffect(() => { let ignore = false fetch('/api/users') .then(r => r.json()) .then(data => { if (!ignore) setUsers(data) }) return () => { ignore = true } }, [])
AbortController ignore 标志 能不能省请求 ✅ 能(直接中断) ❌ 不能(请求跑完) 代码复杂度 略复杂 简单 适用 推荐(大请求) 简单 demo / 小请求 💡 fetch 没回来组件就卸载 → setState 会警告——用
AbortController在 cleanup 里ctrl.abort()取消请求。React 18 起警告没了,但主动取消仍然是好习惯。
五、实战:搜索防抖
tsx
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (!query) return setResults([]);
const id = setTimeout(async () => {
const r = await fetch(`/api/search?q=${query}`);
const data = await r.json();
setResults(data);
}, 300);
return () => clearTimeout(id); // ⚠️ 输入时取消旧请求
}, [query]);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
</>
);
}六、useEffect 事件(React 18.3 实验)
解决 effect 里读最新 state 的问题:
tsx
function Comp() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('count:', count); // ⚠️ 闭包陷阱:永远是 0
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 没依赖 count
// React 18.3 实验 API:
useEffect(() => {
const id = setInterval(() => {
console.log('count:', count); // ✅ 始终是最新值
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []); // eslint-disable-line
// 需要 @enable EffectEvent 实验编译选项
}实际项目用
setCount(c => c + 1)函数式更新解决闭包陷阱。
七、什么时候不需要 useEffect?
| 场景 | 错误 | 正确 |
|---|---|---|
| 从 props 计算数据 | useEffect 改 state | 直接 const x = props.a * 2 |
| 重置组件 state | useEffect | key={id}(见下) |
| 触发事件 | useEffect | 事件 handler 里调 |
tsx
// ❌ 用 effect 同步 props 到 state(会双渲染)
const [name, setName] = useState('');
useEffect(() => setName(props.name), [props.name]);
// ✅ 直接读 props
return <div>{props.name}</div>;
// ✅ 重置 state 用 key
<UserForm key={userId} userId={userId} />八、本章小结
| 要点 | 关键 |
|---|---|
| 异步执行 | 浏览器绘制后跑,不影响渲染 |
| 依赖数组 | Object.is 比较 |
| cleanup | 定时器/订阅/请求必须清理 |
| 数据获取 | AbortController 取消未完成请求 |
| 防抖搜索 | cleanup 用 clearTimeout |
| 闭包陷阱 | 用函数式更新或 ref 存最新值 |
| 不滥用 | 能直接算就不要 useEffect |
动手练习
- 计时器组件:开始/停止/重置,cleanup 处理定时器
- 搜索框:300ms 防抖拉数据,cleanup 取消旧请求
- 窗口尺寸:封装
useWindowSizehook - 找坑:以下代码为什么无限循环?tsx
useEffect(() => { setCount(count + 1); }, [count]);
下一章:第 12 章:useRef →