实现一个完全不依赖客户端时间的倒计时

共 7189字,需浏览 15分钟

 ·

2023-08-02 10:15


作者:xinglee

原文:https://juejin.cn/post/7229898205256417341

最近在做一个调查问卷系统,其中有一个需求就是倒计 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[1],但是被拒绝了。后来想了一下 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 默认是开启的,但是我们项目中给关了,所以需要重新激活
    revalidateOnFocustrue,
    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

参考资料

[1]

https://github.com/ant-design/ant-design/pull/41918: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fant-design%2Fant-design%2Fpull%2F41918

浏览 107
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报