三分钟学会使用requestAnimationFrame实现倒计时

9,127 阅读2分钟

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金

前言:倒计时功能相信大家都很熟悉,脑海中最首先闪过的念头是使用setInterval实现,如此简单,两下子就实现了倒计时需求,开始了一天愉快的搬砖,然而了解了requestAnimationFrame后,发现使用它来实现更香

倒计时用 setInterval 实现

来了来了,先献上最简单的使用setInterval实现倒计时的代码方案

const totalDuration = 10 // 倒计时 10s
let duration = totalDuration
let countDownInterval = null

function countDown() {
    countDownInterval = setInterval(() => {
        duration = duration - 1
        console.log(duration)
        if (duration <= 0) {
            clearInterval(countDownInterval)
        }
    }, 1000)
}

countDown()

setInterval实现DEMO可点击

使用 setInterval 实现的缺点

但是,使用setInterval实现的倒计时准确吗?我们可以做个DEMO验证下,可以看到,每一秒大概有-2ms-7ms的偏差值不等,「网上很多文章说每次setInterval 设置1000ms的间隔,实际执行是大于1000ms的」,但经过我用做DEMO实现,设置1000ms间隔,其实有可能是会小于1000ms,因此用setInterval实现每秒的偏差值并不是直接简单累计,我们可以在倒计时结束后才计算结束时间距离开始时间的相差值

有可能出现时间间隔是小于1000msimage.png

下图是倒计时100s的时间差8ms image.png

setInterval 偏差值计算实现DEMO可点击

const totalDuration = 100; // 100s
let duration = totalDuration;
let countDownInterval = null;
let startTime = new Date().getTime();
let endTime = startTime;
let prevEndTime = startTime
let timeDifferance = 0; // 每1s倒计时偏差值,单位ms
let totalTimeDifferance = 0; // 总共倒计时偏差值,单位ms

function countDown() {
  countDownInterval = setInterval(() => {
    duration = duration - 1;
    prevEndTime = endTime
    endTime = new Date().getTime();
    console.log(`当前执行时间:${endTime}, 首次执行时间:${startTime}`);
    console.log(`当前和首次执行的时间间隔差:${endTime - startTime}`)
    timeDifferance = endTime - prevEndTime
    document.getElementById("app").innerText = duration;
    document.getElementById("differance").innerText = timeDifferance;
    if (duration <= 0) {
      totalTimeDifferance = endTime - startTime - totalDuration * 1000;
      console.log(`累计时间差: ${totalTimeDifferance}`)
      clearInterval(countDownInterval);
    }
  }, 1000);
}

countDown();

但是,假如前面加有段线程阻塞代码,每秒偏差值就会变得很大了,让人吃惊

 setInterval( () => {
     let n = 0;
     while(n++ < 1000000000);
 }, 0);

加了这段线程阻塞代码后,只是倒计时10s,累计时间差已经高达8s

image.png

总而言之,我们要尽量修正这个误差值,大概思路是下一次执行倒计时时,把执行的偏差值提前,那么,每次执行的时间间隔不是固定为1s的,因此使用setTimeout替代setInterval方案

使用 setTimeout 实现倒计时

按照上面所述,为了修正倒计时的计算误差,我们可把上面的代码修改成

const totalDuration = 100; // 100s
let duration = totalDuration;
let countDownInterval = null;
let startTime = new Date().getTime();
let endTime = startTime;
let prevEndTime = startTime;
let timeDifferance = 0; // 每1s倒计时偏差值,单位ms
let totalTimeDifferance = 0; // 总共倒计时偏差值,单位ms
let interval = 1000; // 1s

function countDown() {
  duration = duration - 1;
  endTime = new Date().getTime();
  timeDifferance = endTime - prevEndTime;
  console.log(`当前倒计时:${duration}, 每秒执行的偏差值:${timeDifferance}`)
  let nextTime = interval - timeDifferance
  // 如果下一次执行时间超过当前周期,需要特俗处理一下
  if (nextTime < 0) {
     nextTime = 0
  }
  document.getElementById("nextTime").innerText = nextTime;
  if (duration <= 0) {
    totalTimeDifferance = endTime - startTime - totalDuration * 1000;
    console.log(`累计执行的偏差值:${totalTimeDifferance}`)
    clearTimeout(countDownInterval);
  } else {
    countDownInterval = setTimeout(() => countDown(), nextTime);
  }
}

countDownInterval = setTimeout(() => countDown(), interval);

注意:如果下次执行的时间间隔小于0秒,则需要特俗处理一下

如下图所示,累计时间差可以控制在1s内了

同样加了线程阻塞代码倒计时10s,误差值可降低到2.5s,说明还是优化效果是很明显的,当然实际变成过程中阻塞这么严重是很极端的情况,重点不再是去降低延迟,而是关注重构导致阻塞的代码啦

image.png

setTimeout 实现倒计时实现DEMO可点击

那么除了使用setTimeout实现倒计时,是否有更好的解决方案呢?

requestAnimationFrame是什么

RAF主要是按照显示器的刷新频率(60Hz 或者 75Hz)对页面进行重绘,大概按照这个刷新频率同步重绘页面,就是大概1s最多重绘60次或者75次的频次,按照60Hz计算每次重绘大概16.67ms重绘一次,如果setInterval设置的频次低于16.67ms,会造成过渡绘制的问题,如果高于16.67ms,有可能会出现掉帧的情况

我们要知道的是,window.requestAnimationFrame(callback) 的执行时机是在浏览器下一次重绘前调用RAF里的回调函数获取最新的动画计算结果

setTimeout 和 requestAnimationFrame 对比

现代浏览器的tab处于不被激活状态时,requestAnimationFrame是会停止执行的

当前tab页面不处于被「激活」状态,setTimeout会继续在后台执行任务,我们可以再上面的setTimeout的DEMO增加log,当我们切换去其他tab,当前tab页处于失焦状态,setTimeout是会继续执行的,然而使用requestAnimationFrame(以下简称RAF),可以看到log控制台,我们切换出去的时间段是没有执行的,这会利于提升性能

image.png

当然setTimeout可以通过监听window.visibilityChange()事件去优化处理

setTimeout,setInterval属于JS引擎,RAF属于GUI引擎

做项目过程中发现假如在加载一张很大的图,用setInterval制作的倒计时会出现卡顿然后突然加速的情况,原因在于JS里的JS线程和GUI线程是互斥的,如果在执行GUI线程很久,会对JS线程进行阻塞,因此出现了这种情况,我们下面会做DEMO进行验证

使用 requestAnimationFrame 替代 setInterval 实现倒计时方案

const totalDuration = 10 * 1000;
let requestRef = null;
let startTime;
let prevEndTime;
let prevTime;
let currentCount = totalDuration;
let endTime;
let timeDifferance = 0; // 每1s倒计时偏差值,单位ms
let interval = 1000;
let nextTime = interval;

setInterval(() => {
  let n = 0;
  while (n++ < 1000000000);
}, 0);

const animate = (timestamp) => {
  if (prevTime !== undefined) {
    const deltaTime = timestamp - prevTime;
    if (deltaTime >= nextTime) {
      prevTime = timestamp;
      prevEndTime = endTime;
      endTime = new Date().getTime();
      currentCount = currentCount - 1000;
      console.log("currentCount: ", currentCount / 1000);
      timeDifferance = endTime - startTime - (totalDuration - currentCount);
      console.log(timeDifferance);
      nextTime = interval - timeDifferance;
      // 慢太多了,就立刻执行下一个循环
      if (nextTime < 0) {
        nextTime = 0;
      }
      console.log(`执行下一次渲染的时间是:${nextTime}ms`);
      if (currentCount <= 0) {
        currentCount = 0;
        cancelAnimationFrame(requestRef);
        console.log(`累计偏差值: ${endTime - startTime - totalDuration}ms`);
        return;
      }
    }
  } else {
    startTime = new Date().getTime();
    prevTime = timestamp;
    endTime = new Date().getTime();
  }
  requestRef = requestAnimationFrame(animate);
};

requestRef = requestAnimationFrame(animate);

requestAnimationFrame实现DEMO可点击

就算执行线程阻塞代码,偏差值也只是98ms

image.png

特别注意的点

兼容性问题

requestAnimationFrame兼容性可点击查看,目前我们兼容范围都在IE10及以上,大可不用再太担心兼容性问题了

image.png

离开页面时销毁监听事件

  • RAF使用cancelAnimationFrame销毁

  • wekit内核浏览器使用 webkitCancelRequestAnimationFrame 优于使用 webkitCancelAnimationFrame

使用react hooks和requestAnimationFrame实现倒计时案例

若要处理偏差值,可以参考上面的写法处理哦

import React, { useState, useEffect, useRef } from "react";
const [count, setCount] = useState<number>(0)
const [duration, setTotalDuration] = useState<number>(0)
const requestRef = useRef(null);
const previousTimeRef = useRef(null);
const currentCountRef = useRef<number>(0);

const animate = time => {
    if (previousTimeRef.current !== undefined) {
      const deltaTime = time - previousTimeRef.current;
      if (deltaTime > 1000) {
        if (currentCountRef.current > 0) {
          previousTimeRef.current = time;
          setCount(prevCount => {
            currentCountRef.current = prevCount - 1000
            return prevCount - 1000
          });
        } else {
          setCount(0)
          cancelAnimationFrame(requestRef.current);
          return
        }
      }
    } else {
      previousTimeRef.current = time;
    }
    requestRef.current = requestAnimationFrame(animate);
  }

  useEffect(() => {
    const totalDuration = 60 * 1000
    setCount(totalDuration)
    setTotalDuration(totalDuration)
  }, [])

  useEffect(() => {
    if (duration <= 0) {
      return
    }
    currentCountRef.current = duration
    previousTimeRef.current = undefined
    if (requestRef.current) {
      cancelAnimationFrame(requestRef.current)
    }
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current)
  }, [duration])

最后

以上是我使用requestAnimationFrame实现倒计时的一些小心得,希望能对大家有帮助~如果能获得一个小小的赞作为鼓励会十分感激!!

更多文章:「不再迷茫!看了这篇文章让你上手Vue3.0开发有丝滑般体验」

「欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章」