React useEffect 依赖数组踩坑:闭包陷阱与清理函数
写 React 的时候,useEffect 大概是最容易「看起来对、跑起来错」的 Hook。定时器读到的永远是旧数据、请求回来把已经卸载的组件更新报警告、依赖数组填了个对象结果每次渲染都重新执行......这些坑几乎每个 React 开发者都踩过。这篇把最常见的两类问题------闭包陷阱和清理函数------讲透。
先看一个必翻车的例子
需求很简单:一个计数器,点按钮后每秒自动加一。很多人第一版会这么写:
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 这里的 count 是哪一次的?
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 依赖数组空的
return <h1>{count}</h1>;
}
跑起来你会发现:数字从 0 变成 1,然后就卡住了,再也不动。
问题出在闭包
useEffect 的回调只在挂载时执行了一次(依赖数组是 [])。执行那一刻,count 的值是 0,这个 0 被 setInterval 里的闭包永久捕获 了。之后每一秒执行的都是 setCount(0 + 1),也就是永远把 count 设成 1。
这就是「闭包陷阱」:effect 里引用的 state/props,锁定的是那次渲染时的快照值,不会自动跟着后续渲染更新。
修法一:函数式更新,绕开对旧值的依赖
如果你只是想基于上一个值递增,根本不需要读 count,用 setState 的函数式写法即可:
jsx
useEffect(() => {
const id = setInterval(() => {
// 不读外部的 count,拿到的永远是最新值
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 依赖数组可以安心留空
setCount(c => c + 1) 里的 c 是 React 传进来的最新 state,和闭包捕获的那个 count 无关,所以不会卡住。
修法二:老老实实把依赖写进去
如果 effect 内部确实要读某个 state 做别的事,那就把它放进依赖数组,让 effect 在值变化时重新绑定:
jsx
useEffect(() => {
const id = setInterval(() => {
console.log('当前 count:', count); // 这里能读到最新值
setCount(count + 1);
}, 1000);
return () => clearInterval(id); // 每次重建前先清掉旧定时器
}, [count]); // count 变了就重新执行
注意:这样每次 count 变化都会「清掉旧 interval + 建新 interval」。逻辑正确,但对高频场景开销更大。能用函数式更新就优先用修法一。
记住一条规则:effect 里用到的所有响应式值(state、props、以及基于它们算出来的函数),都应该出现在依赖数组里。不想它进依赖,就想办法在 effect 内部不引用它(比如函数式更新、useRef)。
清理函数:被忽略的一半
useEffect 的返回值是清理函数,它在两个时机执行:组件卸载时 ,以及下一次 effect 执行前。忘了写清理,轻则内存泄漏,重则报错。
典型场景:请求回来时组件已经卸载
jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // 标志位,标记这次 effect 是否已失效
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
// 组件卸载 / userId 变了,就别再 setState
if (!cancelled) setUser(data);
});
return () => {
cancelled = true; // 清理:让这次未完成的请求结果作废
};
}, [userId]);
return <div>{user?.name ?? '加载中...'}</div>;
}
如果不加 cancelled 标志,用户快速切换 userId 时会有两个隐患:一是慢请求后回来覆盖快请求的结果(竞态);二是组件已卸载还调 setState,控制台报 "Can't perform a React state update on an unmounted component"。
更彻底的做法是用 AbortController 直接取消请求:
jsx
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
// 主动取消会抛 AbortError,忽略它
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // 清理:直接中断请求
}, [userId]);
事件监听、订阅、WebSocket 同理,建了就要在清理函数里对应地拆掉:
jsx
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
依赖数组里放对象/函数:每次都触发
还有一个高频坑:把对象或函数直接塞进依赖数组。
jsx
function List({ filters }) {
useEffect(() => {
fetchData(filters);
}, [filters]); // 如果父组件每次渲染都传新对象,这里就每次都执行
}
React 用 Object.is 比较依赖。对象、数组、函数比的是引用,父组件每次渲染 { status: 'active' } 都是新引用,于是 effect 每次都触发。解决办法:
- 依赖具体的原始值,而不是整个对象:
}, [filters.status, filters.page]); - 父组件用
useMemo/useCallback稳定住引用后再传下来。
jsx
// 父组件里
const filters = useMemo(() => ({ status, page }), [status, page]);
小结
- 闭包陷阱 :effect 引用的 state 是渲染快照。要么用
setState(prev => ...)函数式更新绕开,要么把依赖如实写进数组。 - 依赖数组:effect 里用到的响应式值必须全部列出;不想让它进依赖,就在 effect 内部别引用它。
- 清理函数 :定时器、监听、订阅、请求,建了必须在返回的清理函数里拆掉;异步请求用
cancelled标志或AbortController防竞态和卸载后 setState。 - 对象/函数依赖:比的是引用,拆成原始值或用 useMemo/useCallback 稳定引用。
一句话记忆点:effect 里读到的是快照,清理函数拆的是上一次的自己。 想清楚「这个值是哪一次渲染的」和「这次 effect 留了什么需要收拾」,大部分 useEffect 的坑就都绕开了。