腾讯抢金达人中倒计时的实现与改进
侧边栏壁纸
  • 累计撰写 1,061 篇文章
  • 累计收到 0 条评论

腾讯抢金达人中倒计时的实现与改进

加速器之家
2024-10-20 / 0 评论 / 1 阅读 / 正在检测是否收录...

我们通过之前的《前端中的事件循环 eventloop 机制》中也能知道,setTimeoutsetInterval的计时器并不是准确的,因为有其他任务的执行,会推迟定时器的执行。这对一些相对比较严格计时器来说,倒计时的时间越长,误差就会越大。

倒计时通常有 2 种,一种是固定差值的倒计时,例如我们抢金达人中每题的答题时间是 10s;还有一种是固定时间点的倒计时,比如凌晨 0 点开始抢购。但这两种实现的方式都差不多。

我们以抢金达人中的固定差值倒计时为例,来用几种方法实现这个倒计时。这种固定差值的倒计时,无所谓从什么时间点开始,我只需要有 10 秒钟的时间即可。

1. 计数器 #

最开始,我们在倒计时的过程中,为了增加用户的紧张心理,加了一个小数位,最简单的倒计时就实现了,每 100ms 减去 0.1,当进度减为 0 时,则结束:

this.timer = setInterval(() => {
  const progress = this.state.progress;
  const _progress = (progress * 10 - 1) / 10;
  this.setState({ progress: _progress });

  if (_progress <= 0) {
    this.setState({ status: 'finished' });
    this.execute('onEnd');
    this.stop();
  }
}, 100);

若倒计时要求不严格,则就可以这样按照每 100ms 执行一次。若比较严格的时,我们可以根据上次执行的时间,来校准下次的执行周期:

let lastTime = Date.now();
let delay = 100;
let diff = 0; // 每次校验的误差
let progress = 10;

function countdown() {
  progress = (progress * 10 - 1) / 10;
  console.log(progress);

  const now = Date.now();
  diff = now - lastTime - delay;
  lastTime = now;
  console.log(`diff: ${diff}, 下次周期:${delay - diff}`);
  if (progress > 0) {
    setTimeout(() => {
      countdown();
    }, delay - diff); // 每次进行校准
  }
}
setTimeout(() => {
  countdown();
}, delay);

2. requestAnimationFrame #

与 setTimeout 相比,requestAnimationFrame 最大的优势是由系统来决定回调函数的执行时机。具体一点讲,如果屏幕刷新率是 60Hz,那么回调函数就每 16.7ms 被执行一次,如果刷新率是 75Hz,那么这个时间间隔就变成了 1000/75=13.3ms,换句话说就是,requestAnimationFrame 的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题

3. 固定时间点的倒计时 #

以上两种方案都存在着一个问题,若当前标签页不可见时,或者在移动端被置于后台时,计时器均会被挂起或者变慢,导致重新回来后的计时器出错。比如当用户正在答题倒计时的过程中,当用户把 app 收到后台,倒计时就会停止,重新回到 app 后,倒计时接着之前的继续执行,这样是不对的。

这里我就对之前使用的倒计时进行了改进,不再用计数器来计算当前剩余的时间,而是利用固定时间点的倒计时方式进行计算。每当要开始倒计时器之前,都先计算出结束时的时间戳是多少。然后每次渲染时就获取下当前时间戳与结束时间戳之间的差值。这样就即使是切换 tab,或者将 app 收到后台,都是没有影响的。

但如果用户要修改它的本地时间,那就没办法了。我们也只是从精准度上进行考虑,同时后端也加上对时间的校验,即使修改了前端时间,后端也是校验不通过的。

const CountDown = () => {
  const endTime = useMemo(() => Date.now() + 1000 * 10, []); // 持续10s的时间
  const [leftTime, setLeftTime] = useState(0);

  useEffect(() => {
    const count = () => {
      let now = Date.now();
      const diff = endTime - now;

      if (diff >= 0) {
        setLeftTime((diff / 1000).toFixed(2));
        requestAnimationFrame(count);
      } else {
        setLeftTime(0);
      }
    };
    count();
  }, [endTime]);

  return <p>{leftTime}p>;
};

可以点击链接查看简单的 demo。

当然,这只是很简单的功能,我们可以再添加几个配置,再完善下这个倒计时组件:

  1. 倒计时持续的时间或者结束时间点;
  2. 展示的倒计时格式;
  3. 频率,是每 100ms,还是每 1000ms 执行一次;
  4. 倒计时改变时的回调函数;
  5. 倒计时结束时的回调函数;

这里我们定义组件接收的参数类型为:

interface CountDownProps {
  total?: number; // 倒计时的时间,单位毫秒
  endTime?: number; // 结束的时间点,与 total 二选一,且优先级更高,单位毫秒
  format: string | ((progress: number) => string); // 要展示的时间格式,可以是字符串或者函数
  diff?: number; // 频率,单位毫秒ms
  onStart?: () => void; // 开始时的回调
  onStep?: (step: number) => void; // 每次更新时执行的回调
  onEnd?: () => void; // 结束时的回调
}

一个完整的组件在在于组件的健壮性和扩展性,我们这里也对外提供几个参数,方便调用。

  1. format 接收两种类型:如果是字符串,则直接按照字符串要求的进行格式化;若是函数,调用方可以自己处理时间戳,并返回即可;
  2. 提供了 3 个关键时间点的回调函数,让调用方可以进行一些额外的处理,例如想在倒计时结束时进行弹窗等;
  3. 我们的频率字段 diff,在设定的值小于 17ms 时,直接使用 requestAnimationFrame 来代替进行页面刷新;

完整的代码可以访问 GitHub 仓库:react-countdown

调用方式:

<CountDown
  total={10 * 1000}
  format={(progress) => 'wenzi ' + progress}
  diff={10}
  onStep={(step) => console.log(step)}
  onEnd={() => console.log('end')}
/>

还有更多的 demo 可以链接查看:react-countdown 的样例

4. 总结 #

倒计时在我们日常项目应用地非常广泛,这里也是总结了下倒计时的用法,并形成一个通用的组件,当然,其中还有很多的不足,依然还需要进一步的完善。

哈哈-蚊子的博客

0

评论

博主关闭了当前页面的评论