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

蚊子前端博客
发布于 2020-03-05 10:45
在前端的倒计时中,都有哪些方式,如何改进提高精度呢?

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

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

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

1. 计数器

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

COPYJAVASCRIPT

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 执行一次。若比较严格的时,我们可以根据上次执行的时间,来校准下次的执行周期:

COPYJAVASCRIPT

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 收到后台,都是没有影响的。

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

COPYJAVASCRIPT

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. 倒计时结束时的回调函数;

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

COPYJAVASCRIPT

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

调用方式:

COPYJAVASCRIPT

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

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

4. 总结

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

哈哈-蚊子的博客-蚊子的前端博客

标签:
阅读(1002)

公众号:

qrcode

微信公众号:前端小茶馆