PAYATHON 2026

React 中 setInterval 和 useEffect 为何无法让 timer 每秒递减?

数据小陈

问题出在两个地方:setInterval 的回调捕获了创建时的 timer,每次都提交相同的值;页面显示的 timerMsg 又是单独保存的字符串,不会随 timer 自动更新。要修好倒计时,还需要清理旧定时器,并根据剩余秒数显示过期提示。

这个 5 秒倒计时可以用 useEffect 每次安排一个 setTimeout,状态变化时清理旧定时器,也方便处理重复点击和组件卸载。

为什么只减少一次?

原代码中的关键部分是:

setInterval(() => setTimer(timer - 1), 1000);

假设点击按钮时 timer 是 5,创建的回调捕获的就是该次渲染中的 5。此后每次触发,相当于执行:

setTimer(4);

所以状态从 5 变为 4 后,回调仍然提交 4,不会继续减到 3。

如果使用 setInterval,递减应写成函数式更新:

setTimer(previousTimer => Math.max(previousTimer - 1, 0));

React 会把待处理的前一个状态传给更新函数。不过,显示文本和定时器管理仍需要另外处理。

原代码还有哪些问题?

  • 显示文本只在点击时生成:setTimerMsg(...) 保存的是当时拼好的字符串,后续 timer 变化不会自动更新其中的秒数。
  • 过期回调使用了旧状态:useEffect 的依赖只有 [count],回调使用的是该次 Effect 捕获的 timer。setTimer(timer + 5) 在这个值上加 5,不能保证重置为 5;需要重置时应直接使用 setTimer(5)。
  • 首次挂载时就会启动过期定时器:依赖 [count] 的 Effect 首次挂载时也会执行,因此还没点击按钮,原代码就安排了过期回调。
  • 每次点击都会增加一个 interval:原代码没有保存或清理 setInterval 返回的标识。Effect 中的 clearTimeout(countDownTimer) 只能清理那个 timeout,无法清理按钮中创建的 interval。
  • 过期提示和倒计时各自计时:等待 5000 毫秒的 timeout 没有检查 timer 是否为 0,两者缺少同步关系。

这些代码都能正常执行,因此控制台没有报错也属正常。

可以怎样修改?

下面的组件可以替换原实现。generation 记录按钮触发的轮次,timer 保存剩余秒数,提示文本在渲染时根据这两个状态计算。

const { useState, useEffect } = React;

export const OTPGenerator = () => {
  const [generation, setGeneration] = useState(0);
  const [timer, setTimer] = useState(5);

  useEffect(() => {
    // 点击按钮后才开始;到 0 后不再安排下一次更新。
    if (generation === 0 || timer === 0) {
      return;
    }

    const timeoutId = setTimeout(() => {
      setTimer(previousTimer => Math.max(previousTimer - 1, 0));
    }, 1000);

    return () => clearTimeout(timeoutId);
  }, [generation, timer]);

  function handleTimer() {
    setTimer(5);
    setGeneration(previousGeneration => previousGeneration + 1);
  }

  let timerMsg = '';

  if (generation > 0) {
    timerMsg = timer > 0
      ? `Expires in: ${timer} seconds`
      : 'OTP expired. Click the button to generate a new OTP.';
  }

  return (
    <div className="container">
      <h1 id="otp-title">OTP Generator</h1>
      <h2 id="otp-display">Click 'Generate OTP' to get a code</h2>
      <p id="otp-timer" aria-live="polite">{timerMsg}</p>
      <button onClick={handleTimer} id="generate-otp-button">
        Generate OTP
      </button>
    </div>
  );
};

这段代码如何处理倒计时和重复点击?

  1. 首次渲染时,generation 为 0,Effect 不创建定时器。
  2. 点击按钮后,剩余时间设为 5,generation 增加,启动这一轮倒计时。
  3. timeout 触发后,通过函数式更新将剩余时间减 1。timer 变化会触发 Effect,安排下一秒的更新。
  4. 当 timer 变成 0 时,不再创建定时器,页面直接显示过期提示。
  5. 再次点击按钮时,generation 改变,尚未触发的旧 timeout 会被清理,随后重新开始计时。即使此时 timer 已经是 5,也会重新安排完整的一秒等待。

清理函数也会在组件卸载时执行,避免留下尚未触发的 timeout。提示文本由当前状态计算,无需将 timerMsg 单独存为状态。

使用时需要注意什么?

[generation, timer] 适用于这里每次只安排一次 timeout 的实现。不要直接把 timer 加入原有过期 Effect 的依赖数组,否则每次秒数变化都会重新安排那个 5000 毫秒的过期回调。

如果保留 setInterval,除了使用函数式更新,还要保存其返回值,并在重启、结束和组件卸载时调用 clearInterval。只改 setTimer 的写法无法修复完整流程。

另外,1000 毫秒是安排回调的等待时间。浏览器忙碌或标签页处于后台时,回调可能延迟执行。这个示例适合练习逐秒更新;如果需要按实际经过的时间判断是否过期,应保存截止时间,再根据当前时间计算剩余秒数。

备注:内容仅供参考。