前言
手机号验证码登录的时候需要在点击获取验证码后显示 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 写一个完事,知道思路就可以