注册
web

如何完成一个完全不依赖客户端时间的倒计时

前言


最近在做一个调查问卷系统,其中有一个需求就是倒计 40 分钟以后自动提交问卷。由于 UI 库使用的是 antd,所以我第一反应是使用 antdCountDown 组件。
于是我就愉快的写出以下代码:


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 }} />
}
/>

);
}

其中 startTimecurrentTime 是服务端给我返回的开始答题时间以及现在的时间,onFinish 是提交问卷的函数。测试一切正常,并且看起来好像没有依赖客户端时间,于是我就愉快的提交了代码。


antd 的问题


上线后,有客户反映倒计时不正常,进入系统后直接显示 9000 多秒,导致业务直接进行不下去。这个时候我就懵了,我的代码中并没有依赖任何客户端时间,问题肯定是出现在 antdCountDown 组件上。于是我就去看了一下 antdCountDown 组件的源码,果不其然


 // 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


这个方案后来也是被否了,因为还是依赖了客户端时间。客户的机房条件比较复杂,可能一开始时间不对,但是做题途中时间会校正回来。因为我们这个调查系统短时间有几十万人参加调查,为了不给服务器过多的压力,查询服务器时间接口的频率是 1 分钟一次,所以会有很长时间的倒计时异常。


完全不依赖客户端时间的倒计时


倒计时的方案大致有 4 种, setTimeoutsetIntervalrequestAnimationFrameWeb WorkerrequestAnimationFrameWeb Worker 因为兼容性问题暂时放弃。


setInterval 实现倒计时是比较方便的,但是 setInterval 有两个缺点



  1. 使用 setInterval 时,某些间隔会被跳过;
  2. 可能多个定时器会连续执行;

每个 setTimeout 产生的任务会直接 push 到任务队列中;而 setInterval 在每次把任务push到任务队列前,都要进行一下判断(看上次的任务是否仍在队列中)。


可以看到,主线程的渲染都会对 setTimeoutsetInterval 的执行时间产生影响,但是 setTimeout 的影响小一点。所以我们可以使用 setTimeout 来实现倒计时.


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}
/>

);
}

这样,就完成了一个完全不依赖客户端时间的倒计时组件。


总结



  • 上面方案中的 setTimeout 其实换成 requestAnimationFrame 计时会更加准确,也解决了 requestAnimationFrame未被激活的页面中 中不会执行的问题。
  • setIntervalsetTimeout 的时间误差是由于主线程的渲染时间造成的,所以如果我们的页面中有很多的动画,那么这个误差会更大。
  • 未激活的页面,setTimeout 的最小执行间隔是 1000ms

作者:xinglee
来源:juejin.cn/post/7229898205256417341

0 个评论

要回复文章请先登录注册