// 堆代码 duidaima.com import { Statistic } from 'antd'; const { Countdown } = Statistic; const TOTAL_TIME = 40; const deadline = dayjs(startTime).add(TOTAL_TIME, 'minute').valueOf(); function TitleAndCountDown() { useEffect(() => { if (currentTime >= deadline) { onFinish(); } }, []); return ( <Countdown value={deadline} onFinish={onFinish} format="mm:ss" prefix={<img src={clock} style={{ width: 25, height: 25 }} />} /> ); }其中 startTime ,currentTime 是服务端给我返回的开始答题时间以及现在的时间,onFinish 是提交问卷的函数。测试一切正常,并且看起来好像没有依赖客户端时间,于是我就愉快的提交了代码。
// 30帧 const REFRESH_INTERVAL= 1000 / 30; const stopTimer = () => { onFinish?.(); if (countdown.current) { clearInterval(countdown.current); countdown.current = null; } }; const syncTimer = () => { const timestamp = getTime(value); if (timestamp >= Date.now()) { countdown.current = setInterval(() => { forceUpdate(); onChange?.(timestamp - Date.now()); if (timestamp < Date.now()) { stopTimer(); } }, REFRESH_INTERVAL); } }; React.useEffect(() => { syncTimer(); return () => { if (countdown.current) { clearInterval(countdown.current); countdown.current = null; } }; }, [value]);核心代码就是这段,本质 CountDown 并不是一个倒计时,而是根据客户端时间算出来的一个时间差值,这也能解释为啥这个倒计时相对比较准确。但是依赖了客户端时间,就意味客户的本地时间会影响这个倒计时的准确性,甚至可以直接通过修改本地时间来绕过倒计时。一开始我的方案是加入 diff 值修正客户端时间,我也给 antd 官方提了一个 PR,但是被拒绝了。后来想了一下 CountDown 组件可以直接传入 diff 后的 value,确实没有必要新增 props。
const INTERVAL = 1000; interface CountDownProps { restTime: number; format?: string; onFinish: () => void; key: number; } export const CountDown = ({ restTime, format = 'mm:ss', onFinish }: CountDownProps) => { const timer = useRef<NodeJS.Timer | null>(null); const [remainingTime, setRemainingTime] = useState(restTime); useEffect(() => { if (remainingTime < 0 && timer.current) { onFinish?.(); clearTimeout(timer.current); timer.current = null; return; } timer.current = setTimeout(() => { setRemainingTime((time) => time - INTERVAL); }, INTERVAL); return () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } }; }, [remainingTime]); return <span>{dayjs(remainingTime > 0 ? remainingTime : 0).format(format)}</span>; };为了修正 setTimeout 的时间误差,我们需要在 聚焦页面的时候 以及 定时一分钟请求一次服务器时间来修正误差。这里我们使用 swr 来轻松实现这个功能。
const REFRESH_INTERVAL = 60 * 1000; export function useServerTime() { const { data } = useSWR('/api/getCurrentTime', swrFetcher, { // revalidateOnFocus 默认是开启的,但是我们项目中给关了,所以需要重新激活 revalidateOnFocus: true, refreshInterval: REFRESH_INTERVAL, }); return { currentTime: data?.currentTime }; }最后我们把 CountDown 组件和 useServerTime 结合起来
function TitleAndCountDown() { const { currentTime } = useServerTime(); return ( <Countdown restTime={deadline - currentTime} onFinish={onFinish} key={deadline - currentTime} /> ); }这样,就完成了一个完全不依赖客户端时间的倒计时组件。
1.上面方案中的 setTimeout 其实换成 requestAnimationFrame 计时会更加准确,也解决了 requestAnimationFrame 在 未被激活的页面中 中不会执行的问题。
2.setInterval 和 setTimeout 的时间误差是由于主线程的渲染时间造成的,所以如果我们的页面中有很多的动画,那么这个误差会更大。
3.未激活的页面,setTimeout 的最小执行间隔是 1000ms