【手机验证码】倒计时hook实现与setTimeout优化

106 阅读2分钟

前言

手机号验证码登录的时候需要在点击获取验证码后显示 60s 倒计时,然后倒计时结束才能重新点击获取验证码。这部分抽离成一个可复用的 React Hook 最好,便于在多个组件中使用。同时在实现的时候最简单的做法便是使用 setTimeout,但是由于 js 是单线程,使用事件循环机制处理异步任务,导致 setTimeout 实现倒计时效果可能并不精确。因此本文也在此讨论下如何对其进行优化,提升倒计时的准确性。

setTimeout 实现倒计时

示例代码如下:

import { useState, useEffect, useRef } from 'react';

const useCountdown = (initialCount = 60) => {
  const [count, setCount] = useState(initialCount);
  const [isCounting, setIsCounting] = useState(false);
  const timerRef = useRef(null);

  const startCountdown = () => {
    setIsCounting(true);
    setCount(initialCount);
  };

  useEffect(() => {
    if (!isCounting) return;

    if (count <= 0) {
      setIsCounting(false);
      return;
    }

    timerRef.current = setTimeout(() => {
      setCount(count - 1);
    }, 1000);

    return () => clearTimeout(timerRef.current);
  }, [count, isCounting]);

  return { count, isCounting, startCountdown };
};

使用时则按如下代码即可:

const { count, isCounting, startCountdown } = useCountdown();

<button onClick={startCountdown} disabled={isCounting}>
  {isCounting ? `${count}秒后重新获取` : '获取验证码'}
</button>;

缺点

setTimeout 和 setInterval 是异步函数,js 事件循环机制会在运行完同步代码后才会运行其代码,因此 setTimeout 并不能保证在精确的时间间隔后执行回调,它只是将回调函数放入任务队列,等待主线程空闲时执行。这意味着:

  • 如果主线程被长时间运行的同步代码阻塞,setTimeout 的回调会被延迟执行
  • 浏览器标签页处于非活动状态时,大多数浏览器会降低定时器的执行频率以节省资源

优化方案

requestAnimationFrame​​ 实现

由于 requestAnimationFrame 的回调会在浏览器每次重绘前(通常 60Hz)触发,与屏幕刷新率同步,减少误差。

示例代码如下:

import { useState, useEffect, useRef } from 'react';

const useCountdown = (initialCount = 60) => {
  const [count, setCount] = useState(initialCount);
  const [isCounting, setIsCounting] = useState(false);
  const startTimeRef = useRef(null);
  const rafIdRef = useRef(null);
  const prevTimeRef = useRef(null);

  const startCountdown = () => {
    setIsCounting(true);
    setCount(initialCount);
    startTimeRef.current = performance.now();
    prevTimeRef.current = null;
  };

  useEffect(() => {
    if (!isCounting) return;

    const updateCountdown = (timestamp) => {
      if (!prevTimeRef.current) {
        prevTimeRef.current = timestamp;
      }

      // 计算实际经过的时间(毫秒)
      const elapsed = timestamp - startTimeRef.current;
      const remainingSeconds = Math.max(
        0,
        initialCount - Math.floor(elapsed / 1000)
      );

      setCount(remainingSeconds);

      if (remainingSeconds > 0) {
        rafIdRef.current = requestAnimationFrame(updateCountdown);
      } else {
        setIsCounting(false);
      }
    };

    rafIdRef.current = requestAnimationFrame(updateCountdown);

    return () => {
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
    };
  }, [isCounting, initialCount]);

  return { count, isCounting, startCountdown };
};

使用 webworker 运行倒计时

Web Worker 在独立线程中运行倒计时逻辑,完全不受主线程阻塞影响,确保定时器按预期频率触发,显著提高计时精度

具体代码可以使用第三方库用 webworker 实现的 setTimeout 替换最上面的内容,也可以让 Trae 给一个完整的示例代码

基于时间戳的补偿

该方案通过,记录倒计时开始的时间戳每次更新时计算实际经过的时间,而不是依赖定时器的固定间隔,动态调整下一次更新的时间,补偿误差。具体代码让 Trae 写一个完事,知道思路就可以