React 用 useEffect 做轮询实战:setInterval 拿到旧 state 的闭包陷阱与正确清理
需求很常见:一个订单详情页,支付中要每 3 秒轮询一次后端,拿到「已支付」就停;或者一个任务进度条,定时刷新百分比。你顺手写了个 useEffect + setInterval,结果发现两个诡异现象:一是回调里的 count 永远是启动时的那个值,加了半天纹丝不动;二是页面切来切去,定时器越堆越多,一秒钟触发好几次。
这两个都是 React 里 setInterval 的经典翻车点,根子在闭包 和清理。这篇一次讲透。
翻车现场:count 永远是 0
先看最自然的写法:
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 永远打印 0
setCount(count + 1); // 永远是 0 + 1 = 1
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖,只跑一次
return <h1>{count}</h1>;
}
跑起来你会发现:界面上 count 从 0 变成 1 之后,再也不动了 。控制台里 count 也一直打印 0。
原因:useEffect 的依赖数组是空的 [],所以这个 effect 只在挂载时执行一次。那一次里,setInterval 的回调函数捕获了当时的 count,也就是 0 。之后组件重渲染、count 变了,但定时器里的那个回调还是最初创建的那个,它闭包里的 count 永远定格在 0。这就是「闭包陷阱」:回调记住的是创建它那一刻的变量快照,不是最新值。
修法一:函数式更新,绕开对 count 的依赖
如果你只是要「基于上一次的值 +1」,最干净的解法是用 setCount 的函数式更新 ------它把最新值作为参数传进来,根本不需要读闭包里的 count:
jsx
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // prev 永远是 React 里最新的值
}, 1000);
return () => clearInterval(id);
}, []); // 依赖仍是空,定时器只建一次
prev => prev + 1 里的 prev 由 React 保证是当前最新状态,和闭包无关。这样定时器只创建一次、也永远拿对值,是纯粹「累加」类轮询的首选。
修法二:轮询要读最新 props/state 时,用 ref 存回调
但很多真实轮询没这么简单。比如轮询要用到最新的 orderId、要根据最新的某个 state 决定停不停、回调逻辑本身依赖多个外部值。这时函数式更新不够用,你需要「定时器只建一次,但每次触发时执行的是最新的逻辑」。
标准做法是用一个 ref 保存「最新的回调」,让 setInterval 始终调用 ref 里的东西(这也是 Dan Abramov 那篇经典 useInterval 的核心思路):
jsx
import { useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// 每次渲染都把最新的 callback 存进 ref(不触发定时器重建)
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return; // delay 为 null 表示暂停
const id = setInterval(() => {
savedCallback.current(); // 永远调用最新版本
}, delay);
return () => clearInterval(id);
}, [delay]); // 只有 delay 变才重建定时器
}
用起来就很舒服了,回调里随便读最新的 state/props 都不会过期:
jsx
function OrderStatus({ orderId }) {
const [status, setStatus] = useState('paying');
useInterval(async () => {
const res = await fetch(`/api/orders/${orderId}`); // orderId 永远最新
const data = await res.json();
setStatus(data.status);
}, status === 'paying' ? 3000 : null); // 一旦不是 paying,传 null 停止轮询
return <div>订单状态:{status}</div>;
}
这里还顺手实现了「条件停止 」:当 status 不再是 paying,delay 变成 null,useInterval 里的 effect 清理掉旧定时器且不再新建,轮询自然停。这是轮询场景里最实用的模式。
坑:清理函数为什么非写不可
回到最开始,注意每个 effect 都返回了 () => clearInterval(id)。漏掉它是灾难。
useEffect 的清理函数会在两种时机执行:组件卸载时、以及 effect 因依赖变化「重新执行前」。如果你不 clearInterval:
- 组件卸载后,定时器还在跑,回调里
setStatus会对已卸载组件更新状态,轻则 React 警告,重则内存泄漏(闭包持有的东西回收不掉)。 - 依赖变化导致 effect 重跑时,旧定时器没清,新定时器又建一个,越堆越多。你看到的「一秒触发好几次」就是这么来的------每次热更新/依赖变都漏一个没清的定时器叠上去。
验证很简单:去掉清理函数,把 OrderStatus 挂载卸载几次,你会看到 fetch 请求成倍增长。
还有一个隐藏坑:异步回调 + 组件卸载
轮询里常有 await fetch。如果请求发出后组件就卸载了,await 回来再 setStatus 依然是「卸载后更新」。稳妥做法是配合 AbortController,或者在 setState 前用一个 mounted 标志兜一下:
jsx
useEffect(() => {
const controller = new AbortController();
const id = setInterval(async () => {
try {
const res = await fetch(`/api/orders/${orderId}`, { signal: controller.signal });
const data = await res.json();
setStatus(data.status);
} catch (e) {
if (e.name !== 'AbortError') throw e; // 主动取消的不当错误
}
}, 3000);
return () => {
clearInterval(id);
controller.abort(); // 卸载时把在途请求也取消掉
};
}, [orderId]);
小结
setInterval回调会闭包捕获创建时的 state,空依赖 effect 里的定时器永远拿到旧值------这是「count 不动」的根因。- 纯累加用函数式更新
setCount(prev => prev + 1),绕开对 state 的依赖。 - 要读最新 props/state 的复杂轮询,用 ref 存最新回调 (
useInterval模式),定时器只建一次却始终执行最新逻辑。 - 用
delay = null实现条件停止轮询 ,比手动clearInterval更声明式。 - 清理函数必须写 :不
clearInterval会导致定时器堆叠、卸载后 setState;异步回调再加AbortController兜住在途请求。
记忆点:定时器只建一次靠空依赖,拿到最新值靠函数式更新或 ref,不泄漏靠清理函数------三件套齐了才算写对。