React 用 useEffect 做轮询实战:setInterval 拿到旧 state 的闭包陷阱与正确清理

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,不泄漏靠清理函数------三件套齐了才算写对。

相关推荐
hiahiahia1231 小时前
AI Web 项目的文件到底应该怎么放?
前端·人工智能
愚公搬代码1 小时前
【愚公系列】《Web应用安全》003-测试环境的搭建
前端·安全
_codemonster1 小时前
主流前端技术分层选型
前端
SendTomo2 小时前
P2P文件传输工具对比:SendTomo vs send.wang
javascript·网络协议·webrtc·html5·p2p
犹豫的果冻布丁3 小时前
从零给 DeepSeek Harness 写一个壁纸皮肤插件(已开源)
前端·后端
漏刻有时4 小时前
数据可视化Three.js 3D 地图实战:单文件原生实现省域区县拉伸建模
前端
会说话的番茄4 小时前
AI 满嘴跑火车怎么办?给它配个"小抄"
前端·aigc
计算机魔术师4 小时前
AI 权力集中辩论实录:从「token 工厂」到「唯一幸存者」
前端
eric-sjq4 小时前
WanlyFrontend 中文声明式前端语言完全指南:让 0.6B 模型写出漂亮网页
前端